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 `