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 4f1875277be0f6..7cd96d235082f0 100644 --- a/apps/vr-tests-react-components/src/stories/Field.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Field.stories.tsx @@ -109,6 +109,44 @@ storiesOf('Field', module) )) + .addStory('infoButton', () => ( + + + + )) + .addStory('infoButton+required', () => ( + + + + )) + .addStory('infoButton+longLabel', () => ( + + + + )) + .addStory('infoButton+size:small', () => ( + + + + )) + .addStory('infoButton+size:large', () => ( + + + + )) + .addStory('infoButton+noLabel', () => ( + + + + )) + .addStory('infoButton+horizontal', () => ( + + + + )) .addStory('Checkbox:error', () => ( diff --git a/change/@fluentui-react-field-88b192d9-9525-4c3a-89ca-3761b2b9f1fe.json b/change/@fluentui-react-field-88b192d9-9525-4c3a-89ca-3761b2b9f1fe.json new file mode 100644 index 00000000000000..ade3cff3219802 --- /dev/null +++ b/change/@fluentui-react-field-88b192d9-9525-4c3a-89ca-3761b2b9f1fe.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: Add infoButton slot to Field", + "packageName": "@fluentui/react-field", + "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 3aef86de247097..6a3259f88806cd 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -9,6 +9,7 @@ import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import { ForwardRefComponent } from '@fluentui/react-utilities'; +import { InfoButton } from '@fluentui/react-infobutton'; import { Label } from '@fluentui/react-label'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; @@ -39,13 +40,15 @@ export type FieldProps = Omit, 'children'> & { export type FieldSlots = { root: NonNullable>; label?: Slot; + infoButton?: Slot; + labelWrapper?: Slot<'div'>; validationMessage?: Slot<'div'>; validationMessageIcon?: Slot<'span'>; hint?: Slot<'div'>; }; // @public -export type FieldState = ComponentState> & Required>; +export type FieldState = ComponentState> & Required>; // @internal @deprecated (undocumented) export const getDeprecatedFieldClassNames: (controlRootClassName: string) => { diff --git a/packages/react-components/react-field/package.json b/packages/react-components/react-field/package.json index ba48d7703b9f8e..74ad9d04fd3e00 100644 --- a/packages/react-components/react-field/package.json +++ b/packages/react-components/react-field/package.json @@ -34,6 +34,7 @@ "dependencies": { "@fluentui/react-context-selector": "^9.1.10", "@fluentui/react-icons": "^2.0.175", + "@fluentui/react-infobutton": "9.0.0-beta.17", "@fluentui/react-label": "^9.0.22", "@fluentui/react-theme": "^9.1.5", "@fluentui/react-utilities": "^9.6.0", 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..9c840f6ebde9c7 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 @@ -13,8 +13,8 @@ describe('Field', () => { props: { label: 'Test label', hint: 'Test hint', + infoButton: { content: 'Test info button' }, validationMessage: 'Test validation message', - validationState: 'error', }, }, ], @@ -189,4 +189,18 @@ describe('Field', () => { 'aria-required': true, }); }); + + it('sets infoButton aria-labelledby to the label and the button', () => { + const result = render( + + + , + ); + + const label = result.getByText('Test label'); + const infoButton = result.getByRole('button'); + + expect(label.id).toBeTruthy(); + expect(infoButton.getAttribute('aria-labelledby')).toBe(`${label.id} ${infoButton.id}`); + }); }); 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..9aceb8cca7082c 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 @@ -1,4 +1,5 @@ import * as React from 'react'; +import { InfoButton } from '@fluentui/react-infobutton'; import { Label } from '@fluentui/react-label'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; @@ -21,6 +22,21 @@ export type FieldSlots = { */ label?: Slot; + /** + * An InfoButton associated with the field. + * + * @example + * ``` + * + * ``` + */ + infoButton?: Slot; + + /** + * Wrapper around the label and infoButton. By default, this is only rendered when there is an infoButton. + */ + labelWrapper?: Slot<'div'>; + /** * A message about the validation state. By default, this is an error message, but it can be a success, warning, * or custom message by setting `validationState`. @@ -100,4 +116,4 @@ export type FieldProps = Omit, 'children'> & { * State used in rendering Field */ export type FieldState = ComponentState> & - Required>; + Required>; diff --git a/packages/react-components/react-field/src/components/Field/renderField.tsx b/packages/react-components/react-field/src/components/Field/renderField.tsx index 309226f59c71b9..084d7da5dbd196 100644 --- a/packages/react-components/react-field/src/components/Field/renderField.tsx +++ b/packages/react-components/react-field/src/components/Field/renderField.tsx @@ -10,7 +10,14 @@ export const renderField_unstable = (state: FieldState) => { return ( - {slots.label && } + {slots.labelWrapper ? ( + + {slots.label && } + {slots.infoButton && } + + ) : ( + slots.label && + )} {slotProps.root.children} {slots.validationMessage && ( 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 172b4bfd7f703d..77fc44b3352747 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { CheckmarkCircle12Filled, ErrorCircle12Filled, Warning12Filled } from '@fluentui/react-icons'; +import { InfoButton } from '@fluentui/react-infobutton'; import { Label } from '@fluentui/react-label'; import { getNativeElementProps, resolveShorthand, useId } from '@fluentui/react-utilities'; import type { FieldChildProps, FieldProps, FieldState } from './Field.types'; @@ -27,7 +28,7 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref = { root: `fui-Field`, label: `fui-Field__label`, + infoButton: `fui-Field__infoButton`, + labelWrapper: `fui-Field__labelWrapper`, validationMessage: `fui-Field__validationMessage`, validationMessageIcon: `fui-Field__validationMessageIcon`, hint: `fui-Field__hint`, @@ -37,7 +39,7 @@ const useRootStyles = makeStyles({ }, }); -const useLabelStyles = makeStyles({ +const useLabelWrapperStyles = makeStyles({ base: { paddingTop: tokens.spacingVerticalXXS, paddingBottom: tokens.spacingVerticalXXS, @@ -63,6 +65,25 @@ const useLabelStyles = makeStyles({ }, }); +const useLabelStyles = makeStyles({ + base: { + verticalAlign: 'top', + }, +}); + +const useInfoButtonStyles = makeStyles({ + base: { + verticalAlign: 'top', + marginTop: `calc(0px - ${tokens.spacingVerticalXXS})`, + marginBottom: `calc(0px - ${tokens.spacingVerticalXXS})`, + }, + + large: { + marginTop: '-1px', + marginBottom: '-1px', + }, +}); + const useSecondaryTextBaseClassName = makeResetStyles({ marginTop: tokens.spacingVerticalXXS, color: tokens.colorNeutralForeground3, @@ -120,19 +141,45 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.root.className, ); + const labelWrapperStyles = useLabelWrapperStyles(); + + // Class name applied to the either the labelWrapper if it is present, or the label itself otherwise. + const labelContainerClassName = mergeClasses( + labelWrapperStyles.base, + horizontal && labelWrapperStyles.horizontal, + !horizontal && labelWrapperStyles.vertical, + state.size === 'large' && labelWrapperStyles.large, + !horizontal && state.size === 'large' && labelWrapperStyles.verticalLarge, + ); + + if (state.labelWrapper) { + state.labelWrapper.className = mergeClasses( + fieldClassNames.labelWrapper, + labelContainerClassName, + state.labelWrapper.className, + ); + } + const labelStyles = useLabelStyles(); if (state.label) { state.label.className = mergeClasses( fieldClassNames.label, labelStyles.base, - horizontal && labelStyles.horizontal, - !horizontal && labelStyles.vertical, - state.label.size === 'large' && labelStyles.large, - !horizontal && state.label.size === 'large' && labelStyles.verticalLarge, + !state.labelWrapper && labelContainerClassName, state.label.className, ); } + const infoButtonStyles = useInfoButtonStyles(); + if (state.infoButton) { + state.infoButton.className = mergeClasses( + fieldClassNames.infoButton, + infoButtonStyles.base, + state.size === 'large' && infoButtonStyles.large, + state.infoButton.className, + ); + } + const validationMessageIconBaseClassName = useValidationMessageIconBaseClassName(); const validationMessageIconStyles = useValidationMessageIconStyles(); if (state.validationMessageIcon) { diff --git a/packages/react-components/react-field/src/util/makeDeprecatedField.tsx b/packages/react-components/react-field/src/util/makeDeprecatedField.tsx index 14c870e335de26..49d59eae507cef 100644 --- a/packages/react-components/react-field/src/util/makeDeprecatedField.tsx +++ b/packages/react-components/react-field/src/util/makeDeprecatedField.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ForwardRefComponent } from '@fluentui/react-utilities'; import type { FieldProps } from '../Field'; -import { Field, fieldClassNames } from '../Field'; +import { Field } from '../Field'; /** * @deprecated Only for use to make deprecated [Control]Field shim components. @@ -101,6 +101,10 @@ export function makeDeprecatedField( * @internal */ export const getDeprecatedFieldClassNames = (controlRootClassName: string) => ({ - ...fieldClassNames, control: controlRootClassName, + root: `fui-Field`, + label: `fui-Field__label`, + validationMessage: `fui-Field__validationMessage`, + validationMessageIcon: `fui-Field__validationMessageIcon`, + hint: `fui-Field__hint`, }); diff --git a/packages/react-components/react-field/stories/Field/FieldWithInfoButton.stories.tsx b/packages/react-components/react-field/stories/Field/FieldWithInfoButton.stories.tsx new file mode 100644 index 00000000000000..0688163198367c --- /dev/null +++ b/packages/react-components/react-field/stories/Field/FieldWithInfoButton.stories.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { Input } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-components/unstable'; + +export const WithInfoButton = () => ( + + + +); + +WithInfoButton.storyName = 'With InfoButton'; +WithInfoButton.parameters = { + docs: { + description: { + story: 'The `infoButton` slot allows the addition of an `` after the label.', + }, + }, +}; diff --git a/packages/react-components/react-field/stories/Field/index.stories.tsx b/packages/react-components/react-field/stories/Field/index.stories.tsx index dda5953c7d5fea..d0f3e1572f0a7d 100644 --- a/packages/react-components/react-field/stories/Field/index.stories.tsx +++ b/packages/react-components/react-field/stories/Field/index.stories.tsx @@ -8,6 +8,7 @@ export { Hint } from './FieldHint.stories'; export { Horizontal } from './FieldHorizontal.stories'; export { Required } from './FieldRequired.stories'; export { Size } from './FieldSize.stories'; +export { WithInfoButton } from './FieldWithInfoButton.stories'; export { ComponentExamples } from './FieldComponentExamples.stories'; export { RenderFunction } from './FieldRenderFunction.stories';