diff --git a/apps/vr-tests-react-components/src/stories/Field.stories.tsx b/apps/vr-tests-react-components/src/stories/Field.stories.tsx index d84d951888ddd4..2e4b0f0a4a7868 100644 --- a/apps/vr-tests-react-components/src/stories/Field.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Field.stories.tsx @@ -28,7 +28,7 @@ const AllFields = ( - + diff --git a/apps/vr-tests-react-components/src/stories/Progress.stories.tsx b/apps/vr-tests-react-components/src/stories/Progress.stories.tsx index 05d2c567954f92..dbcb867f9375c8 100644 --- a/apps/vr-tests-react-components/src/stories/Progress.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Progress.stories.tsx @@ -14,4 +14,7 @@ storiesOf('Progress converged', module) includeHighContrast: true, includeRtl: true, }) - .addStory('Determinate with thickness large', () => ); + .addStory('Determinate with thickness large', () => ) + .addStory('Error', () => ) + .addStory('Warning', () => ) + .addStory('Success', () => ); diff --git a/change/@fluentui-react-field-6d4517ea-1bd4-42ab-bc43-7779ccaa3de2.json b/change/@fluentui-react-field-6d4517ea-1bd4-42ab-bc43-7779ccaa3de2.json new file mode 100644 index 00000000000000..9475cef5cad0a8 --- /dev/null +++ b/change/@fluentui-react-field-6d4517ea-1bd4-42ab-bc43-7779ccaa3de2.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: Add support for validationState to ProgressField", + "packageName": "@fluentui/react-field", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-progress-7997b7b6-eeaf-45b7-aa8d-d0ac737e14f1.json b/change/@fluentui-react-progress-7997b7b6-eeaf-45b7-aa8d-d0ac737e14f1.json new file mode 100644 index 00000000000000..bc3cc284e07bdd --- /dev/null +++ b/change/@fluentui-react-progress-7997b7b6-eeaf-45b7-aa8d-d0ac737e14f1.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: Add validationState to Progress, to make the bar red or green", + "packageName": "@fluentui/react-progress", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-field/etc/react-field.api.md b/packages/react-components/react-field/etc/react-field.api.md index 06c25baafdec4a..fdace08fb846f5 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -53,6 +53,7 @@ export type FieldConfig = { component: T; classNames: SlotClassNames>; labelConnection?: 'htmlFor' | 'aria-labelledby'; + ariaInvalidOnError?: boolean; }; // @public diff --git a/packages/react-components/react-field/src/components/Field/Field.types.ts b/packages/react-components/react-field/src/components/Field/Field.types.ts index 6ddc8d99e46fa6..f1644d289d9aec 100644 --- a/packages/react-components/react-field/src/components/Field/Field.types.ts +++ b/packages/react-components/react-field/src/components/Field/Field.types.ts @@ -117,6 +117,13 @@ export type FieldConfig = { * @default htmlFor */ labelConnection?: 'htmlFor' | 'aria-labelledby'; + + /** + * Should the aria-invalid and aria-errormessage attributes be set when validationState="error". + * + * @default true + */ + ariaInvalidOnError?: boolean; }; /** diff --git a/packages/react-components/react-field/src/components/Field/useField.tsx b/packages/react-components/react-field/src/components/Field/useField.tsx index cb5ea6d01f2bb7..acd4da41721cab 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -67,7 +67,7 @@ export const useField_unstable = ( ): FieldState => { const [fieldProps, controlProps] = getPartitionedFieldProps(props); const { orientation = 'vertical', validationState } = fieldProps; - const { labelConnection = 'htmlFor' } = params; + const { labelConnection = 'htmlFor', ariaInvalidOnError = true } = params; const baseId = useId('field-'); @@ -118,7 +118,7 @@ export const useField_unstable = ( control['aria-labelledby'] ??= label.id; } - if (validationState === 'error') { + if (validationState === 'error' && ariaInvalidOnError) { control['aria-invalid'] ??= true; if (validationMessage) { control['aria-errormessage'] ??= validationMessage.id; diff --git a/packages/react-components/react-field/src/components/ProgressField/ProgressField.test.tsx b/packages/react-components/react-field/src/components/ProgressField/ProgressField.test.tsx index 1724b31d2fe815..71d0adff6888f0 100644 --- a/packages/react-components/react-field/src/components/ProgressField/ProgressField.test.tsx +++ b/packages/react-components/react-field/src/components/ProgressField/ProgressField.test.tsx @@ -9,7 +9,7 @@ describe('ProgressField', () => { displayName: 'ProgressField', }); - // Most functionality is tested by Field.test.tsx, and RadioGroup's tests + // Most functionality is tested by Field.test.tsx and Progress.test.tsx it('uses aria-labelledby for the label', () => { const result = render(); @@ -21,4 +21,15 @@ describe('ProgressField', () => { expect(progress.getAttribute('aria-labelledby')).toBe(label.id); expect(label.htmlFor).toBeFalsy(); }); + + it('uses aria-describedby on error, instead of aria-errormessage ', () => { + const result = render(); + + const progress = result.getByRole('progressbar'); + const message = result.getByText('Test error') as HTMLLabelElement; + + expect(message.id).toBeTruthy(); + expect(progress.getAttribute('aria-describedby')).toBe(message.id); + expect(progress.getAttribute('aria-invalid')).toBeNull(); + }); }); diff --git a/packages/react-components/react-field/src/components/ProgressField/ProgressField.tsx b/packages/react-components/react-field/src/components/ProgressField/ProgressField.tsx index d4a5019d9e253b..799ad6c7452fb0 100644 --- a/packages/react-components/react-field/src/components/ProgressField/ProgressField.tsx +++ b/packages/react-components/react-field/src/components/ProgressField/ProgressField.tsx @@ -13,7 +13,9 @@ export const ProgressField: ForwardRefComponent = React.forw component: Progress, classNames: progressFieldClassNames, labelConnection: 'aria-labelledby', + ariaInvalidOnError: false, }); + state.control.validationState = state.validationState; useFieldStyles_unstable(state); return renderField_unstable(state); }); diff --git a/packages/react-components/react-progress/Spec.md b/packages/react-components/react-progress/Spec.md index 611709c849e994..a7ae7cd1719472 100644 --- a/packages/react-components/react-progress/Spec.md +++ b/packages/react-components/react-progress/Spec.md @@ -44,6 +44,9 @@ function App() { - The default Progress that animates indefinitely - Determinate Progress - The determinate form of the Progress component that incrementally loads from 0% to 100% +- Error/success + - The validationState prop can be set to "error", "warning", or "success" to make the bar red, orange, or green, respectively. + - The prop name was chosen to align with the Field prop of the same name, allowing ProgressField to have the same API as other fields. #### Adding Label and Description with ProgressField diff --git a/packages/react-components/react-progress/etc/react-progress.api.md b/packages/react-components/react-progress/etc/react-progress.api.md index 51c8b9d15f1c4a..5c1f21cf89253d 100644 --- a/packages/react-components/react-progress/etc/react-progress.api.md +++ b/packages/react-components/react-progress/etc/react-progress.api.md @@ -23,6 +23,7 @@ export type ProgressProps = Omit, 'size'> & { value?: number; max?: number; thickness?: 'medium' | 'large'; + validationState?: 'success' | 'warning' | 'error'; }; // @public (undocumented) @@ -32,7 +33,7 @@ export type ProgressSlots = { }; // @public -export type ProgressState = ComponentState & Required> & Pick; +export type ProgressState = ComponentState & Required> & Pick; // @public export const renderProgress_unstable: (state: ProgressState) => JSX.Element; diff --git a/packages/react-components/react-progress/src/components/Progress/Progress.types.ts b/packages/react-components/react-progress/src/components/Progress/Progress.types.ts index 1b559e78e60083..93727ae9b068f6 100644 --- a/packages/react-components/react-progress/src/components/Progress/Progress.types.ts +++ b/packages/react-components/react-progress/src/components/Progress/Progress.types.ts @@ -38,6 +38,11 @@ export type ProgressProps = Omit, 'size'> & { * @default 'medium' */ thickness?: 'medium' | 'large'; + + /** + * The status of the progress bar. Changes the color of the bar. + */ + validationState?: 'success' | 'warning' | 'error'; }; /** @@ -45,4 +50,4 @@ export type ProgressProps = Omit, 'size'> & { */ export type ProgressState = ComponentState & Required> & - Pick; + Pick; diff --git a/packages/react-components/react-progress/src/components/Progress/useProgress.tsx b/packages/react-components/react-progress/src/components/Progress/useProgress.tsx index c6e3cc4131db8f..3e7f37bab88411 100644 --- a/packages/react-components/react-progress/src/components/Progress/useProgress.tsx +++ b/packages/react-components/react-progress/src/components/Progress/useProgress.tsx @@ -13,7 +13,7 @@ import type { ProgressProps, ProgressState } from './Progress.types'; */ export const useProgress_unstable = (props: ProgressProps, ref: React.Ref): ProgressState => { // Props - const { max = 1.0, shape = 'rounded', thickness = 'medium', value } = props; + const { max = 1.0, shape = 'rounded', thickness = 'medium', validationState, value } = props; const root = getNativeElementProps('div', { ref, @@ -33,6 +33,7 @@ export const useProgress_unstable = (props: ProgressProps, ref: React.Ref { - const { max, shape, thickness, value } = state; + const { max, shape, thickness, validationState, value } = state; const rootStyles = useRootStyles(); const barStyles = useBarStyles(); const { dir } = useFluent(); @@ -130,6 +140,7 @@ export const useProgressStyles_unstable = (state: ProgressState): ProgressState value === undefined && dir === 'rtl' && barStyles.rtl, barStyles[thickness], value !== undefined && value > ZERO_THRESHOLD && barStyles.nonZeroDeterminate, + validationState && barStyles[validationState], state.bar.className, ); } diff --git a/packages/react-components/react-progress/src/stories/Progress/ProgressValidationState.stories.tsx b/packages/react-components/react-progress/src/stories/Progress/ProgressValidationState.stories.tsx new file mode 100644 index 00000000000000..5a1b00cf0b5c5c --- /dev/null +++ b/packages/react-components/react-progress/src/stories/Progress/ProgressValidationState.stories.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { makeStyles } from '@fluentui/react-components'; +import { Progress } from '@fluentui/react-progress'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + rowGap: '20px', + }, +}); + +export const ValidationState = () => { + const styles = useStyles(); + return ( +
+ + + +
+ ); +}; + +ValidationState.parameters = { + docs: { + name: 'Validation State', + description: { + story: + 'The `validationState` prop can be used to indicate an `"error"` state (red), `"warning"` state (orange), ' + + 'or `"success"` state (green).', + }, + }, +}; diff --git a/packages/react-components/react-progress/src/stories/Progress/index.stories.tsx b/packages/react-components/react-progress/src/stories/Progress/index.stories.tsx index 8ec0cf39a5d19b..bf19fc27b00036 100644 --- a/packages/react-components/react-progress/src/stories/Progress/index.stories.tsx +++ b/packages/react-components/react-progress/src/stories/Progress/index.stories.tsx @@ -7,6 +7,7 @@ export { Default } from './ProgressDefault.stories'; export { Shape } from './ProgressShape.stories'; export { Thickness } from './ProgressBarThickness.stories'; export { Indeterminate } from './ProgressIndeterminate.stories'; +export { ValidationState } from './ProgressValidationState.stories'; export { Max } from './ProgressMax.stories'; export default {