diff --git a/change/@fluentui-react-checkbox-7491f15b-367b-482c-9bfc-aa897546d57f.json b/change/@fluentui-react-checkbox-7491f15b-367b-482c-9bfc-aa897546d57f.json new file mode 100644 index 00000000000000..9aac4fc865903d --- /dev/null +++ b/change/@fluentui-react-checkbox-7491f15b-367b-482c-9bfc-aa897546d57f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-checkbox", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-combobox-95bf9a08-b64f-4a05-81ac-4debdb1a6662.json b/change/@fluentui-react-combobox-95bf9a08-b64f-4a05-81ac-4debdb1a6662.json new file mode 100644 index 00000000000000..8920812a2c944c --- /dev/null +++ b/change/@fluentui-react-combobox-95bf9a08-b64f-4a05-81ac-4debdb1a6662.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-combobox", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-components-903a28f2-111a-4bce-bb47-396da6422862.json b/change/@fluentui-react-components-903a28f2-111a-4bce-bb47-396da6422862.json new file mode 100644 index 00000000000000..1a652bcec22593 --- /dev/null +++ b/change/@fluentui-react-components-903a28f2-111a-4bce-bb47-396da6422862.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Export hooks for FieldContext", + "packageName": "@fluentui/react-components", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-field-2c40a846-0df9-4d61-9472-ec59670b77a2.json b/change/@fluentui-react-field-2c40a846-0df9-4d61-9472-ec59670b77a2.json new file mode 100644 index 00000000000000..6d898a76e8b2b6 --- /dev/null +++ b/change/@fluentui-react-field-2c40a846-0df9-4d61-9472-ec59670b77a2.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: Add FieldContext to pass props to controls inside Field", + "packageName": "@fluentui/react-field", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-input-b5935497-9fbb-4d7f-ba38-e498bba5acd2.json b/change/@fluentui-react-input-b5935497-9fbb-4d7f-ba38-e498bba5acd2.json new file mode 100644 index 00000000000000..cad4d2623cdfae --- /dev/null +++ b/change/@fluentui-react-input-b5935497-9fbb-4d7f-ba38-e498bba5acd2.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-input", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-progress-97ad2a19-e138-4e89-b753-89b83e0b42d7.json b/change/@fluentui-react-progress-97ad2a19-e138-4e89-b753-89b83e0b42d7.json new file mode 100644 index 00000000000000..df99849bfabafc --- /dev/null +++ b/change/@fluentui-react-progress-97ad2a19-e138-4e89-b753-89b83e0b42d7.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-progress", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-radio-b2e01e06-ab25-451c-af2b-8f7a04175bde.json b/change/@fluentui-react-radio-b2e01e06-ab25-451c-af2b-8f7a04175bde.json new file mode 100644 index 00000000000000..488ff3537c7ea9 --- /dev/null +++ b/change/@fluentui-react-radio-b2e01e06-ab25-451c-af2b-8f7a04175bde.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-radio", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-select-1136d423-6f0b-422f-a1e1-c90accc1dccb.json b/change/@fluentui-react-select-1136d423-6f0b-422f-a1e1-c90accc1dccb.json new file mode 100644 index 00000000000000..cee38737a038e1 --- /dev/null +++ b/change/@fluentui-react-select-1136d423-6f0b-422f-a1e1-c90accc1dccb.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-select", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-slider-bc337120-f0bf-4b83-bbdd-93ce62b6216e.json b/change/@fluentui-react-slider-bc337120-f0bf-4b83-bbdd-93ce62b6216e.json new file mode 100644 index 00000000000000..abb481e8701fe3 --- /dev/null +++ b/change/@fluentui-react-slider-bc337120-f0bf-4b83-bbdd-93ce62b6216e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-slider", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-spinbutton-184fd4ef-de9a-4395-858e-45b5dda2e51a.json b/change/@fluentui-react-spinbutton-184fd4ef-de9a-4395-858e-45b5dda2e51a.json new file mode 100644 index 00000000000000..2af2d5b1397442 --- /dev/null +++ b/change/@fluentui-react-spinbutton-184fd4ef-de9a-4395-858e-45b5dda2e51a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-spinbutton", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-switch-3d5f6b94-a231-42e7-8dd0-72a9f4756362.json b/change/@fluentui-react-switch-3d5f6b94-a231-42e7-8dd0-72a9f4756362.json new file mode 100644 index 00000000000000..2c161d383efb8e --- /dev/null +++ b/change/@fluentui-react-switch-3d5f6b94-a231-42e7-8dd0-72a9f4756362.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-switch", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-textarea-6e6be83f-e5ec-4a39-86b5-296f450658c2.json b/change/@fluentui-react-textarea-6e6be83f-e5ec-4a39-86b5-296f450658c2.json new file mode 100644 index 00000000000000..863c7c24425092 --- /dev/null +++ b/change/@fluentui-react-textarea-6e6be83f-e5ec-4a39-86b5-296f450658c2.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Hook up FieldContext for use inside a Field", + "packageName": "@fluentui/react-textarea", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-checkbox/src/components/Checkbox/Checkbox.test.tsx b/packages/react-components/react-checkbox/src/components/Checkbox/Checkbox.test.tsx index 7c4c5b270a6a24..7622960c2508b5 100644 --- a/packages/react-components/react-checkbox/src/components/Checkbox/Checkbox.test.tsx +++ b/packages/react-components/react-checkbox/src/components/Checkbox/Checkbox.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { render, fireEvent } from '@testing-library/react'; +import { Field } from '@fluentui/react-field'; import { Checkbox } from './Checkbox'; import { isConformant } from '../../testing/isConformant'; import { resetIdsForTests } from '@fluentui/react-utilities'; @@ -165,6 +166,38 @@ describe('Checkbox', () => { expect(input.indeterminate).toEqual(true); }); + it('gets props from a surrounding Field', () => { + const renderedComponent = render( + + + , + ); + + const checkbox = renderedComponent.getByRole('checkbox') as HTMLInputElement; + const message = renderedComponent.getByText('Test error message'); + + expect(checkbox.getAttribute('aria-describedby')).toEqual(message.id); + expect(checkbox.getAttribute('aria-invalid')).toEqual('true'); + expect(checkbox.required).toBe(true); + }); + + it('is labelled by both the Field and Checkbox labels, if both are present', () => { + const renderedComponent = render( + + + , + ); + + const fieldLabel = renderedComponent.getByText('Field label') as HTMLLabelElement; + const checkboxLabel = renderedComponent.getByText('Checkbox label') as HTMLLabelElement; + const checkbox = renderedComponent.getByRole('checkbox') as HTMLInputElement; + + // The checkbox should be labelled by both labels using htmlFor and not aria-labelledby + expect(checkbox.id).toEqual(fieldLabel.htmlFor); + expect(checkbox.id).toEqual(checkboxLabel.htmlFor); + expect(checkbox.getAttribute('aria-labelledby')).toBeNull(); + }); + describe('Accessibility Tests', () => { it('renders the input slot (as input)', () => { const { container } = render(); diff --git a/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx b/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx index 5a0b3a488ddcdc..5124ad0a5fe0c6 100644 --- a/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx +++ b/packages/react-components/react-checkbox/src/components/Checkbox/useCheckbox.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useFieldControlProps_unstable } from '@fluentui/react-field'; import { getPartitionedNativeProps, resolveShorthand, @@ -29,6 +30,9 @@ import { useFocusWithin } from '@fluentui/react-tabster'; * @param ref - reference to `` element of Checkbox */ export const useCheckbox_unstable = (props: CheckboxProps, ref: React.Ref): CheckboxState => { + // Merge props from surrounding , if any + props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true }); + const { disabled = false, required, shape = 'square', size = 'medium', labelPosition = 'after', onChange } = props; const [checked, setChecked] = useControllableState({ diff --git a/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx b/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx index 0eece7c10057d6..78956a9e8b26a1 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx +++ b/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Field } from '@fluentui/react-field'; import { Combobox } from './Combobox'; import { Option } from '../Option/index'; import { isConformant } from '../../testing/isConformant'; @@ -848,4 +849,25 @@ describe('Combobox', () => { expect((getByRole('combobox') as HTMLInputElement).value).toEqual('foo'); }); + + it('gets props from a surrounding Field', () => { + const result = render( + + + + + + + , + ); + + const combobox = result.getByRole('combobox') as HTMLInputElement; + const label = result.getByText('Test label') as HTMLLabelElement; + const message = result.getByText('Test error message'); + + expect(combobox.id).toEqual(label.htmlFor); + expect(combobox.getAttribute('aria-describedby')).toEqual(message.id); + expect(combobox.getAttribute('aria-invalid')).toEqual('true'); + expect(combobox.required).toBe(true); + }); }); diff --git a/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx b/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx index a9c6761cce3d24..ff0ef098d580b6 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx +++ b/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useFieldControlProps_unstable } from '@fluentui/react-field'; import { ArrowLeft, ArrowRight } from '@fluentui/keyboard-keys'; import { ChevronDownRegular as ChevronDownIcon } from '@fluentui/react-icons'; import { @@ -29,6 +30,9 @@ import type { ComboboxProps, ComboboxState } from './Combobox.types'; * @param ref - reference to root HTMLElement of Combobox */ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref): ComboboxState => { + // Merge props from surrounding , if any + props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true, supportsSize: true }); + const baseState = useComboboxBaseState({ ...props, editable: true }); const { activeOption, diff --git a/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.test.tsx b/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.test.tsx index 6305364cfe2d6e..8a2651ecb963cf 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.test.tsx +++ b/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { fireEvent, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Field } from '@fluentui/react-field'; import { Dropdown } from './Dropdown'; import { Option } from '../Option/index'; import { isConformant } from '../../testing/isConformant'; @@ -652,4 +653,25 @@ describe('Dropdown', () => { expect(combobox.getAttribute('aria-activedescendant')).toEqual(getByText('Red').id); }); + + it('gets props from a surrounding Field', () => { + const result = render( + + + + + + + , + ); + + const combobox = result.getByRole('combobox') as HTMLInputElement; + const label = result.getByText('Test label') as HTMLLabelElement; + const message = result.getByText('Test error message'); + + expect(combobox.id).toEqual(label.htmlFor); + expect(combobox.getAttribute('aria-describedby')).toEqual(message.id); + expect(combobox.getAttribute('aria-invalid')).toEqual('true'); + expect(combobox.getAttribute('aria-required')).toEqual('true'); + }); }); diff --git a/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx b/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx index b524fae7d921e8..29b69dec00c8f1 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx +++ b/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useFieldControlProps_unstable } from '@fluentui/react-field'; import { ChevronDownRegular as ChevronDownIcon } from '@fluentui/react-icons'; import { getPartitionedNativeProps, mergeCallbacks, resolveShorthand, useTimeout } from '@fluentui/react-utilities'; import { getDropdownActionFromKey } from '../../utils/dropdownKeyActions'; @@ -21,6 +22,9 @@ import { useMergedRefs } from '@fluentui/react-utilities'; * @param ref - reference to root HTMLElement of Dropdown */ export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref): DropdownState => { + // Merge props from surrounding , if any + props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsSize: true }); + const baseState = useComboboxBaseState(props); const { activeOption, getIndexOfId, getOptionsMatchingText, open, setActiveOption, setFocusVisible, setOpen } = baseState; diff --git a/packages/react-components/react-components/etc/react-components.unstable.api.md b/packages/react-components/react-components/etc/react-components.unstable.api.md index 16f7745139fa90..105dadc34ad575 100644 --- a/packages/react-components/react-components/etc/react-components.unstable.api.md +++ b/packages/react-components/react-components/etc/react-components.unstable.api.md @@ -17,6 +17,11 @@ import { comboboxFieldClassNames } from '@fluentui/react-combobox'; import { ComboboxFieldProps_unstable as ComboboxFieldProps } from '@fluentui/react-combobox'; import { Field } from '@fluentui/react-field'; import { fieldClassNames } from '@fluentui/react-field'; +import { FieldContextProvider } from '@fluentui/react-field'; +import { FieldContextValue } from '@fluentui/react-field'; +import { FieldContextValues } from '@fluentui/react-field'; +import { FieldControlProps } from '@fluentui/react-field'; +import { FieldControlPropsOptions } from '@fluentui/react-field'; import { FieldProps } from '@fluentui/react-field'; import { FieldSlots } from '@fluentui/react-field'; import { FieldState } from '@fluentui/react-field'; @@ -116,6 +121,9 @@ import { TreeState } from '@fluentui/react-tree'; import { useAlert_unstable } from '@fluentui/react-alert'; import { useAlertStyles_unstable } from '@fluentui/react-alert'; import { useField_unstable } from '@fluentui/react-field'; +import { useFieldContext_unstable } from '@fluentui/react-field'; +import { useFieldContextValues_unstable } from '@fluentui/react-field'; +import { useFieldControlProps_unstable } from '@fluentui/react-field'; import { useFieldStyles_unstable } from '@fluentui/react-field'; import { useFlatTree_unstable } from '@fluentui/react-tree'; import { useInfoButton_unstable } from '@fluentui/react-infobutton'; @@ -181,6 +189,16 @@ export { Field } export { fieldClassNames } +export { FieldContextProvider } + +export { FieldContextValue } + +export { FieldContextValues } + +export { FieldControlProps } + +export { FieldControlPropsOptions } + export { FieldProps } export { FieldSlots } @@ -379,6 +397,12 @@ export { useAlertStyles_unstable } export { useField_unstable } +export { useFieldContext_unstable } + +export { useFieldContextValues_unstable } + +export { useFieldControlProps_unstable } + export { useFieldStyles_unstable } export { useFlatTree_unstable } diff --git a/packages/react-components/react-components/src/unstable/index.ts b/packages/react-components/react-components/src/unstable/index.ts index f6728de03d1b2e..b1b5306864ff6a 100644 --- a/packages/react-components/react-components/src/unstable/index.ts +++ b/packages/react-components/react-components/src/unstable/index.ts @@ -83,11 +83,23 @@ export type { TextareaFieldProps_unstable as TextareaFieldProps } from '@fluentu export { Field, fieldClassNames, + FieldContextProvider, renderField_unstable, + useFieldContext_unstable, + useFieldContextValues_unstable, + useFieldControlProps_unstable, useFieldStyles_unstable, useField_unstable, } from '@fluentui/react-field'; -export type { FieldProps, FieldSlots, FieldState } from '@fluentui/react-field'; +export type { + FieldContextValue, + FieldContextValues, + FieldControlProps, + FieldControlPropsOptions, + FieldProps, + FieldSlots, + FieldState, +} from '@fluentui/react-field'; export { Skeleton, 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 3aef86de247097..9bfb790dfa878d 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -26,9 +26,40 @@ export const Field: ForwardRefComponent; // @public (undocumented) export const fieldClassNames: SlotClassNames; +// @public (undocumented) +export const FieldContextProvider: React_2.Provider & { + labelFor?: string | undefined; + labelId?: string | undefined; + validationMessageId?: string | undefined; + hintId?: string | undefined; +}> | undefined>; + +// @public (undocumented) +export type FieldContextValue = Readonly & { + labelFor?: string; + labelId?: string; + validationMessageId?: string; + hintId?: string; +}>; + +// @public (undocumented) +export type FieldContextValues = { + field: FieldContextValue; +}; + +// @public +export type FieldControlProps = Pick, 'id' | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' | 'aria-required'>; + +// @public +export type FieldControlPropsOptions = { + supportsLabelFor?: boolean; + supportsRequired?: boolean; + supportsSize?: boolean; +}; + // @public export type FieldProps = Omit, 'children'> & { - children?: React_2.ReactElement | null | ((props: FieldChildProps) => React_2.ReactNode); + children?: React_2.ReactNode | ((props: FieldControlProps) => React_2.ReactNode); orientation?: 'vertical' | 'horizontal'; validationState?: 'error' | 'warning' | 'success' | 'none'; required?: boolean; @@ -45,7 +76,9 @@ export type FieldSlots = { }; // @public -export type FieldState = ComponentState> & Required>; +export type FieldState = ComponentState> & Required> & Pick & { + generatedControlId: string; +}; // @internal @deprecated (undocumented) export const getDeprecatedFieldClassNames: (controlRootClassName: string) => { @@ -64,11 +97,28 @@ export function makeDeprecatedField(Control: React_2.ComponentType }): ForwardRefComponent>; // @public -export const renderField_unstable: (state: FieldState) => JSX.Element; +export const renderField_unstable: (state: FieldState, contextValues?: FieldContextValues | undefined) => JSX.Element; // @public export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; +// @public (undocumented) +export const useFieldContext_unstable: () => Readonly & { + labelFor?: string | undefined; + labelId?: string | undefined; + validationMessageId?: string | undefined; + hintId?: string | undefined; +}> | undefined; + +// @public +export const useFieldContextValues_unstable: (state: FieldState) => FieldContextValues; + +// @public +export function useFieldControlProps_unstable(): FieldControlProps | undefined; + +// @public +export function useFieldControlProps_unstable(props: Props, options?: FieldControlPropsOptions): Props; + // @public export const useFieldStyles_unstable: (state: FieldState) => void; diff --git a/packages/react-components/react-field/src/components/Field/Field.test.tsx b/packages/react-components/react-field/src/components/Field/Field.test.tsx index 175093bb25f35d..27b35e6e764d8c 100644 --- a/packages/react-components/react-field/src/components/Field/Field.test.tsx +++ b/packages/react-components/react-field/src/components/Field/Field.test.tsx @@ -1,8 +1,21 @@ import * as React from 'react'; + +import { Tooltip } from '@fluentui/react-tooltip'; import { render } from '@testing-library/react'; +import { useFieldControlProps_unstable } from '../../contexts/useFieldControlProps'; import { isConformant } from '../../testing/isConformant'; import { Field } from './index'; +const TestInput = React.forwardRef>((props, ref) => { + props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true }); + return ; +}); + +const TestGroup = React.forwardRef>((props, ref) => { + props = useFieldControlProps_unstable(props); + return
; +}); + describe('Field', () => { isConformant({ Component: Field, @@ -21,64 +34,104 @@ describe('Field', () => { }, }); - it("sets the label's htmlFor to the child's id if it has one", () => { + it('generates an id for the control and uses it as the label.htmlFor', () => { const result = render( - + , ); const input = result.getByRole('textbox'); const label = result.getByText('Test label') as HTMLLabelElement; - expect(input.id).toBe('test-id'); - expect(label.htmlFor).toBe('test-id'); + expect(input.id).toBeTruthy(); + expect(label.htmlFor).toBe(input.id); + expect(input.getAttribute('aria-labelledby')).toBeFalsy(); }); - it('generates an id for the child if it does not have one,', () => { + it('does not set aria-labelledby if label.htmlFor matches the control id', () => { const result = render( - - + + , ); const input = result.getByRole('textbox'); const label = result.getByText('Test label') as HTMLLabelElement; - expect(input.id).toBeTruthy(); - expect(label.htmlFor).toBe(input.id); + expect(label.htmlFor).toBe('test-label-for'); + expect(input.id).toBe('test-label-for'); + expect(input.getAttribute('aria-labelledby')).toBeFalsy(); }); - it('sets aria-labelledby on the control', () => { + it('falls back to aria-labelledby if the control has an id that does not match the label.htmlFor', () => { const result = render( - + {/* Does not match label's generated htmlFor */} , ); + const input = result.getByRole('textbox'); const label = result.getByText('Test label') as HTMLLabelElement; expect(label.id).toBeTruthy(); expect(input.getAttribute('aria-labelledby')).toBe(label.id); + expect(input.id).toBe('test-id'); }); - it('adds a required asterisk * to the label, and aria-required on the control when required is set', () => { + it('sets aria-labelledby on a control that does not support label.htmlFor', () => { const result = render( - - + + {/* Groups do not support label.htmlFor */} , ); + const group = result.getByRole('group'); + const label = result.getByText('Test label') as HTMLLabelElement; - const input = result.getByRole('textbox'); + expect(label.id).toBeTruthy(); + expect(group.getAttribute('aria-labelledby')).toBe(label.id); + }); + + it('adds a required asterisk * to the label when required is set', () => { + const result = render( + + + , + ); expect(result.getByText('*')).toBeTruthy(); - expect(input.getAttribute('aria-required')).toBe('true'); + }); + + it('sets `required` on a control that supports the `required` prop', () => { + const result = render( + + + , + ); + + const input = result.getByRole('textbox') as HTMLInputElement; + + expect(input.required).toBe(true); + expect(input.getAttribute('aria-required')).toBeNull(); + }); + + it('sets `aria-required` on a control that does not support the `required` prop', () => { + const result = render( + + {/* Groups do not support the required prop */} + , + ); + + const group = result.getByRole('group'); + + expect(group.getAttribute('aria-required')).toBe('true'); + expect(group.getAttribute('required')).toBeNull(); }); it('sets aria-describedby to the hint', () => { const result = render( - + , ); const input = result.getByRole('textbox'); @@ -91,7 +144,7 @@ describe('Field', () => { it('sets aria-describedby to the validationMessage', () => { const result = render( - + , ); const input = result.getByRole('textbox'); @@ -104,7 +157,7 @@ describe('Field', () => { it('sets aria-describedby to the validationMessage + hint', () => { const result = render( - + , ); const input = result.getByRole('textbox'); @@ -117,7 +170,7 @@ describe('Field', () => { it('sets aria-describedby to the validationMessage + hint + user aria-describedby', () => { const result = render( - + , ); const input = result.getByRole('textbox'); @@ -130,7 +183,7 @@ describe('Field', () => { it('sets aria-invalid if an error', () => { const result = render( - + , ); const input = result.getByRole('textbox'); @@ -138,18 +191,61 @@ describe('Field', () => { expect(input.getAttribute('aria-invalid')).toBeTruthy(); }); - it('does not override user aria props, EXCEPT aria-describedby', () => { + it('does not override user props (other than aria-describedby)', () => { const result = render( - - + + , ); const input = result.getByRole('textbox'); + expect(input.id).toBe('test-id'); expect(input.getAttribute('aria-labelledby')).toBe('test-labelledby'); expect(input.getAttribute('aria-errormessage')).toBe('test-errormessage'); expect(input.getAttribute('aria-invalid')).toBe('false'); + expect(input.getAttribute('aria-required')).toBe('false'); + // aria-describedby gets merged with the hint and validationMessage; that is tested above + }); + + it('passes props through other component(s) using context', () => { + const result = render( + +
+ ... + +
+
, + ); + + const input = result.getByRole('textbox'); + const label = result.getByText('Test label') as HTMLLabelElement; + const hint = result.getByText('test hint'); + + expect(label.htmlFor).toBe(input.id); + expect(input.getAttribute('aria-describedby')).toBe(hint.id); + }); + + it('merges Field describedby with Tooltip describedby', () => { + const result = render( + + + + + , + ); + + const input = result.getByRole('textbox'); + const hint = result.getByText('Test hint'); + const tooltip = result.getByText('Test tooltip'); + + expect(input.getAttribute('aria-describedby')).toBe(`${hint.id} ${tooltip.id}`); }); it.each([ @@ -161,7 +257,7 @@ describe('Field', () => { ] as const)('if validationState is %s, sets role to %s on the validationMessage', (validationState, role) => { const result = render( - + , ); const validationMessage = result.getByText('test validation message'); diff --git a/packages/react-components/react-field/src/components/Field/Field.tsx b/packages/react-components/react-field/src/components/Field/Field.tsx index d498bebccb35f2..49133a8d0ddb2a 100644 --- a/packages/react-components/react-field/src/components/Field/Field.tsx +++ b/packages/react-components/react-field/src/components/Field/Field.tsx @@ -4,11 +4,13 @@ import type { FieldProps } from './Field.types'; import { renderField_unstable } from './renderField'; import { useField_unstable } from './useField'; import { useFieldStyles_unstable } from './useFieldStyles'; +import { useFieldContextValues_unstable } from '../../contexts/index'; export const Field: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useField_unstable(props, ref); useFieldStyles_unstable(state); - return renderField_unstable(state); + const context = useFieldContextValues_unstable(state); + return renderField_unstable(state, context); }); Field.displayName = 'Field'; 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 1f355046bf53fc..887fdc81a297c3 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 @@ -3,9 +3,9 @@ import { Label } from '@fluentui/react-label'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; /** - * The props added to the Field's child element. + * The props added to the control inside the Field. */ -export type FieldChildProps = Pick< +export type FieldControlProps = Pick< React.HTMLAttributes, 'id' | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' | 'aria-required' >; @@ -52,13 +52,15 @@ export type FieldProps = Omit, 'children'> & { * The Field's child can be a single form control, or a render function that takes the props that should be spread on * a form control. * - * All form controls in this library can be used directly as children (such as `` or ``), as well - * as intrinsic form controls like `` or `