From 5b16e1af0d26209cf1f554fe0712c3dcd88654a5 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Mon, 1 Aug 2022 11:48:40 -0700 Subject: [PATCH 01/23] Initial field implementation --- .../react-field/etc/react-field.api.md | 53 ++++++- .../react-components/react-field/package.json | 3 + .../src/components/Field/Field.test.tsx | 30 +++- .../src/components/Field/Field.tsx | 8 +- .../src/components/Field/Field.types.ts | 36 ++++- .../src/components/Field/renderField.tsx | 22 ++- .../src/components/Field/useField.ts | 28 ---- .../src/components/Field/useField.tsx | 89 +++++++++++ .../src/components/Field/useFieldStyles.ts | 141 ++++++++++++++++-- .../react-field/src/contexts/FieldContext.ts | 10 ++ .../react-field/src/contexts/index.ts | 2 + .../src/contexts/useFieldChildProps.ts | 40 +++++ .../src/contexts/useFieldContextValues.ts | 8 + .../react-components/react-field/src/index.ts | 6 +- .../stories/Field/FieldDefault.stories.tsx | 114 +++++++++++++- .../src/stories/Field/index.stories.tsx | 5 + 16 files changed, 530 insertions(+), 65 deletions(-) delete mode 100644 packages/react-components/react-field/src/components/Field/useField.ts create mode 100644 packages/react-components/react-field/src/components/Field/useField.tsx create mode 100644 packages/react-components/react-field/src/contexts/FieldContext.ts create mode 100644 packages/react-components/react-field/src/contexts/index.ts create mode 100644 packages/react-components/react-field/src/contexts/useFieldChildProps.ts create mode 100644 packages/react-components/react-field/src/contexts/useFieldContextValues.ts 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 4833ea08b380e1..2b80337395a2d1 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -4,9 +4,16 @@ ```ts +/// + import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import { ContextSelector } from '@fluentui/react-context-selector'; +import { FC } from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { Label } from '@fluentui/react-label'; +import { Provider } from 'react'; +import { ProviderProps } from 'react'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -15,30 +22,62 @@ import type { SlotClassNames } from '@fluentui/react-utilities'; export const Field: ForwardRefComponent; // @public (undocumented) -export const fieldClassName = "fui-Field"; +export const fieldClassNames: SlotClassNames; // @public (undocumented) -export const fieldClassNames: SlotClassNames; +export type FieldContextValue = Readonly>; + +// @public (undocumented) +export type FieldContextValues = { + field: FieldContextValue; +}; // @public -export type FieldProps = ComponentProps & {}; +export type FieldProps = Omit, 'children'> & { + children: React_2.ReactElement; + size?: 'small' | 'medium' | 'large'; + labelPosition?: 'above' | 'before'; + status?: 'error' | 'warning' | 'success'; + inputId?: string; + required?: boolean; +}; + +// @public (undocumented) +export const FieldProvider: Provider> | undefined> & FC> | undefined>>; // @public (undocumented) export type FieldSlots = { - root: Slot<'div'>; + root: NonNullable>; + label?: Slot; + statusText?: Slot<'span'>; + statusIcon?: Slot<'span'>; + helperText?: Slot<'span'>; }; // @public -export type FieldState = ComponentState; +export type FieldState = ComponentState & { + size: NonNullable; + labelPosition: NonNullable; + status: FieldProps['status']; + required: FieldProps['required']; + inputId: NonNullable; + labelId: string | undefined; +}; // @public -export const renderField_unstable: (state: FieldState) => JSX.Element; +export const renderField_unstable: (state: FieldState, contextValues: FieldContextValues) => JSX.Element; // @public export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; +// @public (undocumented) +export const useFieldContext_unstable: (selector: ContextSelector> | undefined, T>) => T; + +// @public (undocumented) +export const useFieldContextValues: (state: FieldState) => FieldContextValues; + // @public -export const useFieldStyles_unstable: (state: FieldState) => FieldState; +export const useFieldStyles_unstable: (state: FieldState) => void; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-field/package.json b/packages/react-components/react-field/package.json index db0cb2b8989688..5581978be02142 100644 --- a/packages/react-components/react-field/package.json +++ b/packages/react-components/react-field/package.json @@ -32,6 +32,9 @@ "@fluentui/scripts": "^1.0.0" }, "dependencies": { + "@fluentui/react-context-selector": "^9.0.2", + "@fluentui/react-icons": "^2.0.175", + "@fluentui/react-label": "^9.0.3", "@fluentui/react-theme": "^9.0.0", "@fluentui/react-utilities": "^9.0.2", "@griffel/react": "^1.2.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 7f09ee09b58c60..fd5ca8cc0df312 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 @@ -2,17 +2,45 @@ import * as React from 'react'; import { render } from '@testing-library/react'; import { Field } from './Field'; import { isConformant } from '../../common/isConformant'; +import { fieldClassNames } from './useFieldStyles'; describe('Field', () => { isConformant({ Component: Field, displayName: 'Field', + requiredProps: { + children: , + }, + disabledTests: ['component-has-static-classname-exported'], // TODO remove this + testOptions: { + 'has-static-classnames': [ + { + props: { + label: 'Label', + status: 'error', + statusText: 'Status text', + helperText: 'Helper text', + }, + expectedClassNames: { + root: fieldClassNames.root, + label: fieldClassNames.label, + statusText: fieldClassNames.statusText, + statusIcon: fieldClassNames.statusIcon, + helperText: fieldClassNames.helperText, + }, + }, + ], + }, }); // TODO add more tests here, and create visual regression tests in /apps/vr-tests it('renders a default state', () => { - const result = render(Default Field); + const result = render( + + + , + ); expect(result.container).toMatchSnapshot(); }); }); 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 46d8d9cfc96291..f6086ac44c7c8c 100644 --- a/packages/react-components/react-field/src/components/Field/Field.tsx +++ b/packages/react-components/react-field/src/components/Field/Field.tsx @@ -1,18 +1,20 @@ import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { FieldProps } from './Field.types'; import { useField_unstable } from './useField'; import { renderField_unstable } from './renderField'; import { useFieldStyles_unstable } from './useFieldStyles'; -import type { FieldProps } from './Field.types'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useFieldContextValues } from '../../contexts/useFieldContextValues'; /** * Field component - TODO: add more docs */ export const Field: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useField_unstable(props, ref); + const contextValues = useFieldContextValues(state); useFieldStyles_unstable(state); - return renderField_unstable(state); + return renderField_unstable(state, contextValues); }); 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 949bf781eb4ff9..8e588f6e6fb827 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,17 +1,43 @@ +import * as React from 'react'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { Label } from '@fluentui/react-label'; export type FieldSlots = { - root: Slot<'div'>; + root: NonNullable>; + label?: Slot; + statusText?: Slot<'span'>; + statusIcon?: Slot<'span'>; + helperText?: Slot<'span'>; }; +export type FieldChildProps = Pick, 'id' | 'required' | 'aria-labelledby'>; + /** * Field Props */ -export type FieldProps = ComponentProps & {}; +export type FieldProps = Omit, 'children'> & { + children: React.ReactElement; + size?: 'small' | 'medium' | 'large'; + labelPosition?: 'above' | 'before'; + status?: 'error' | 'warning' | 'success'; + inputId?: string; + required?: boolean; +}; /** * State used in rendering Field */ -export type FieldState = ComponentState; -// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from FieldProps. -// & Required> +export type FieldState = ComponentState & { + size: NonNullable; + labelPosition: NonNullable; + status: FieldProps['status']; + required: FieldProps['required']; + inputId: NonNullable; + labelId: string | undefined; +}; + +export type FieldContextValue = Readonly>; + +export type FieldContextValues = { + field: FieldContextValue; +}; 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 858515b2b4fbb5..687605d0ab339c 100644 --- a/packages/react-components/react-field/src/components/Field/renderField.tsx +++ b/packages/react-components/react-field/src/components/Field/renderField.tsx @@ -1,13 +1,27 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utilities'; -import type { FieldState, FieldSlots } from './Field.types'; +import type { FieldState, FieldSlots, FieldContextValues } from './Field.types'; +import { FieldContext } from '../../contexts/FieldContext'; /** * Render the final JSX of Field */ -export const renderField_unstable = (state: FieldState) => { +export const renderField_unstable = (state: FieldState, contextValues: FieldContextValues) => { const { slots, slotProps } = getSlots(state); - // TODO Add additional slots in the appropriate place - return ; + return ( + + + {slots.label && } + {slotProps.root.children} + {slots.statusText && ( + + {slots.statusIcon && } + {slotProps.statusText.children} + + )} + {slots.helperText && } + + + ); }; diff --git a/packages/react-components/react-field/src/components/Field/useField.ts b/packages/react-components/react-field/src/components/Field/useField.ts deleted file mode 100644 index f5f26387f6defb..00000000000000 --- a/packages/react-components/react-field/src/components/Field/useField.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { FieldProps, FieldState } from './Field.types'; - -/** - * Create the state required to render Field. - * - * The returned state can be modified with hooks such as useFieldStyles_unstable, - * before being passed to renderField_unstable. - * - * @param props - props from this instance of Field - * @param ref - reference to root HTMLElement of Field - */ -export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { - return { - // TODO add appropriate props/defaults - components: { - // TODO add each slot's element type or component - root: 'div', - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: getNativeElementProps('div', { - ref, - ...props, - }), - }; -}; diff --git a/packages/react-components/react-field/src/components/Field/useField.tsx b/packages/react-components/react-field/src/components/Field/useField.tsx new file mode 100644 index 00000000000000..48bb013d38939f --- /dev/null +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { CheckmarkCircle12Filled, ErrorCircle12Filled, Warning12Filled } from '@fluentui/react-icons'; +import { getNativeElementProps, resolveShorthand, useId } from '@fluentui/react-utilities'; +import type { FieldProps, FieldState } from './Field.types'; +import { Label } from '@fluentui/react-label'; + +/** + * Create the state required to render Field. + * + * The returned state can be modified with hooks such as useFieldStyles_unstable, + * before being passed to renderField_unstable. + * + * @param props - props from this instance of Field + * @param ref - reference to root HTMLElement of Field + */ +export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { + const baseId = useId('field-'); + + let childInput = React.Children.only(props.children); + if (!React.isValidElement(childInput) || childInput.type === React.Fragment) { + throw new Error( + 'The child must be a single input element for this component. ' + + "Please ensure that you're not using React Fragments.", + ); + } + + const { + size = 'medium', + labelPosition = 'above', + status, + inputId = childInput.props.id || baseId + '__input', + required = childInput.props.required, + } = props; + + const label = resolveShorthand(props.label, { + defaultProps: { + id: baseId + '__label', + htmlFor: inputId, + required, + }, + }); + + // Apply the props to the child input + childInput = React.cloneElement(childInput, { + id: inputId, + 'aria-labelledby': label?.id, + required, + ...childInput.props, + }); + + let defaultStatusIcon; + if (status === 'error') { + defaultStatusIcon = ; + } else if (status === 'warning') { + defaultStatusIcon = ; + } else if (status === 'success') { + defaultStatusIcon = ; + } + + return { + labelPosition, + status, + size, + required, + labelId: label?.id, + inputId, + components: { + root: 'div', + label: Label, + statusText: 'span', + statusIcon: 'span', + helperText: 'span', + }, + root: getNativeElementProps('div', { + ...props, + ref, + children: childInput, + }), + label, + statusIcon: resolveShorthand(props.statusIcon, { + required: !!status, + defaultProps: { + children: defaultStatusIcon, + }, + }), + statusText: resolveShorthand(props.statusText), + helperText: resolveShorthand(props.helperText), + }; +}; diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index 9efd955895889e..1980c12c9179a6 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -1,34 +1,149 @@ import { makeStyles, mergeClasses } from '@griffel/react'; import type { FieldSlots, FieldState } from './Field.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; -export const fieldClassName = 'fui-Field'; export const fieldClassNames: SlotClassNames = { root: 'fui-Field', - // TODO: add class names for all slots on FieldSlots. - // Should be of the form `: 'fui-Field__` + label: 'fui-Field__label', + statusText: 'fui-Field__statusText', + statusIcon: 'fui-Field__statusIcon', + helperText: 'fui-Field__helperText', }; +// TODO replace with shorthands.gridArea when it is available +// https://github.com/microsoft/griffel/issues/120 +const gridArea = (area: string) => ({ + gridRowStart: area, + gridRowEnd: area, + gridColumnStart: area, + gridColumnEnd: area, +}); + /** * Styles for the root slot */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element +const useRootStyles = makeStyles({ + base: { + display: 'inline-grid', + }, + + // labelPosition="above" + above: { + gridTemplateAreas: ` + "label" + "input" + "statusText" + "helperText" + `, + }, + + // labelPosition="before" + before: { + gridTemplateAreas: ` + "label input" + "label statusText" + "label helperText" + `, + }, +}); + +const useLabelStyles = makeStyles({ + base: { + ...gridArea('label'), + }, + + above: { + marginBottom: tokens.spacingVerticalXXS, }, - // TODO add additional classes for different states and/or slots + before: { + marginRight: tokens.spacingHorizontalM, + }, +}); + +const useExtraTextStyles = makeStyles({ + base: { + display: 'inline-flex', + marginTop: tokens.spacingVerticalXXS, + ...typographyStyles.caption1, + }, + + statusText: { + ...gridArea('statusText'), + }, + helperText: { + ...gridArea('helperText'), + }, + + error: { + color: tokens.colorPaletteRedForeground1, + }, + warning: { + color: tokens.colorPaletteDarkOrangeForeground1, + }, + success: { + color: tokens.colorPaletteGreenForeground1, + }, +}); + +const useStatusIconStyles = makeStyles({ + base: { + fontSize: '12px', + lineHeight: '12px', + alignSelf: 'center', + marginRight: tokens.spacingHorizontalXS, + }, }); /** * Apply styling to the Field slots based on the state */ -export const useFieldStyles_unstable = (state: FieldState): FieldState => { - const styles = useStyles(); - state.root.className = mergeClasses(fieldClassName, styles.root, state.root.className); +export const useFieldStyles_unstable = (state: FieldState) => { + const rootStyles = useRootStyles(); + state.root.className = mergeClasses( + fieldClassNames.root, + rootStyles.base, + rootStyles[state.labelPosition], + state.root.className, + ); + + const labelStyles = useLabelStyles(); + if (state.label) { + state.label.className = mergeClasses( + fieldClassNames.label, + labelStyles.base, + labelStyles[state.labelPosition], + state.label.className, + ); + } + + const statusIconStyles = useStatusIconStyles(); + if (state.statusIcon) { + state.statusIcon.className = mergeClasses( + fieldClassNames.statusIcon, + statusIconStyles.base, + state.statusIcon.className, + ); + } - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + const extraTextStyles = useExtraTextStyles(); + if (state.statusText) { + state.statusText.className = mergeClasses( + fieldClassNames.statusText, + extraTextStyles.base, + extraTextStyles.statusText, + !!state.status && extraTextStyles[state.status], + state.statusText.className, + ); + } - return state; + if (state.helperText) { + state.helperText.className = mergeClasses( + fieldClassNames.helperText, + extraTextStyles.base, + extraTextStyles.helperText, + state.helperText.className, + ); + } }; diff --git a/packages/react-components/react-field/src/contexts/FieldContext.ts b/packages/react-components/react-field/src/contexts/FieldContext.ts new file mode 100644 index 00000000000000..cd127700736500 --- /dev/null +++ b/packages/react-components/react-field/src/contexts/FieldContext.ts @@ -0,0 +1,10 @@ +import { createContext, useContextSelector, ContextSelector } from '@fluentui/react-context-selector'; +import type { FieldContextValue } from '../Field'; + +export const FieldContext = createContext(undefined); + +export const FieldProvider = FieldContext.Provider; + +export const useFieldContext_unstable = (selector: ContextSelector): T => { + return useContextSelector(FieldContext, selector); +}; diff --git a/packages/react-components/react-field/src/contexts/index.ts b/packages/react-components/react-field/src/contexts/index.ts new file mode 100644 index 00000000000000..396547b7f47d14 --- /dev/null +++ b/packages/react-components/react-field/src/contexts/index.ts @@ -0,0 +1,2 @@ +export * from './FieldContext'; +export * from './useFieldContextValues'; diff --git a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts new file mode 100644 index 00000000000000..c0e9750f5b90f3 --- /dev/null +++ b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts @@ -0,0 +1,40 @@ +import { useFieldContext_unstable } from './FieldContext'; + +export type UseFieldChildPropsOptions = { + supportedSizes: SizeValues[]; +}; + +export type FieldChildProps = { + id?: string; + required?: boolean; + 'aria-labelledby'?: string; + size?: SizeValues; +}; + +export const useFieldChildProps = ( + options: UseFieldChildPropsOptions, +) => { + const props: FieldChildProps = {}; + + const inputId = useFieldContext_unstable(context => context?.inputId); + if (inputId) { + props.id = inputId; + } + + const labelId = useFieldContext_unstable(context => context?.labelId); + if (labelId) { + props['aria-labelledby'] = labelId; + } + + const required = useFieldContext_unstable(context => context?.required); + if (required) { + props.required = required; + } + + const size = useFieldContext_unstable(context => context?.size); + if (size && options.supportedSizes.includes(size as SizeValues)) { + props.size = size as SizeValues; + } + + return props; +}; diff --git a/packages/react-components/react-field/src/contexts/useFieldContextValues.ts b/packages/react-components/react-field/src/contexts/useFieldContextValues.ts new file mode 100644 index 00000000000000..fbe8b37bf7567b --- /dev/null +++ b/packages/react-components/react-field/src/contexts/useFieldContextValues.ts @@ -0,0 +1,8 @@ +import type { FieldContextValues, FieldState } from '../Field'; + +export const useFieldContextValues = (state: FieldState): FieldContextValues => { + const { size, required, status, labelId, inputId } = state; + return { + field: { size, required, status, labelId, inputId }, + }; +}; diff --git a/packages/react-components/react-field/src/index.ts b/packages/react-components/react-field/src/index.ts index f40e2c50f7b064..854adc82e3424b 100644 --- a/packages/react-components/react-field/src/index.ts +++ b/packages/react-components/react-field/src/index.ts @@ -1,3 +1,3 @@ -// TODO: replace with real exports -export {}; -export * from './Field'; +export { Field, fieldClassNames, renderField_unstable, useFieldStyles_unstable, useField_unstable } from './Field'; +export type { FieldContextValue, FieldContextValues, FieldProps, FieldSlots, FieldState } from './Field'; +export { FieldProvider, useFieldContextValues, useFieldContext_unstable } from './contexts/index'; diff --git a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx index b8a93a1dfb9f6a..b2f1c7122f4836 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx @@ -1,4 +1,116 @@ import * as React from 'react'; +import { makeStyles, tokens, Input, Slider, SpinButton, RadioGroup, Radio } from '@fluentui/react-components'; +import { Combobox, Option } from '@fluentui/react-components/unstable'; import { Field, FieldProps } from '@fluentui/react-field'; +import { SparkleFilled } from '@fluentui/react-icons'; -export const Default = (props: Partial) => ; +const useStyles = makeStyles({ + stack: { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'start', + rowGap: tokens.spacingVerticalM, + }, +}); + +export const Default = (props: Partial) => { + return ( + + + + ); +}; + +export const AllControls = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export const LabelBefore = () => { + return ( + } + helperText="This is some help text" + > + + + ); +}; + +export const Required = () => { + return ( + + + + ); +}; + +export const Status = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + + + + + + + + + + + } + statusText="This is a message for a custom status" + labelPosition="before" + > + + +
+ ); +}; + +export const HelperText = () => { + return ( + + + + ); +}; diff --git a/packages/react-components/react-field/src/stories/Field/index.stories.tsx b/packages/react-components/react-field/src/stories/Field/index.stories.tsx index 650828caa907f5..dbb549c286f5b6 100644 --- a/packages/react-components/react-field/src/stories/Field/index.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/index.stories.tsx @@ -4,6 +4,11 @@ import descriptionMd from './FieldDescription.md'; import bestPracticesMd from './FieldBestPractices.md'; export { Default } from './FieldDefault.stories'; +export { AllControls } from './FieldDefault.stories'; +export { LabelBefore } from './FieldDefault.stories'; +export { Required } from './FieldDefault.stories'; +export { Status } from './FieldDefault.stories'; +export { HelperText } from './FieldDefault.stories'; export default { title: 'Components/Field', From 5a28bfa00c9d19d444ce86b1e17377b983d876fd Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Mon, 1 Aug 2022 12:34:17 -0700 Subject: [PATCH 02/23] Add size stories --- .../src/components/Field/useField.tsx | 1 + .../src/stories/Field/FieldDefault.stories.tsx | 17 +++++++++++++++++ .../src/stories/Field/index.stories.tsx | 1 + 3 files changed, 19 insertions(+) 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 48bb013d38939f..b30897be6fe959 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -37,6 +37,7 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref id: baseId + '__label', htmlFor: inputId, required, + size, }, }); diff --git a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx index b2f1c7122f4836..02f134f5a6e15e 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx @@ -107,6 +107,23 @@ export const Status = () => { ); }; +export const Size = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + +
+ ); +}; + export const HelperText = () => { return ( diff --git a/packages/react-components/react-field/src/stories/Field/index.stories.tsx b/packages/react-components/react-field/src/stories/Field/index.stories.tsx index dbb549c286f5b6..f6090a612577e2 100644 --- a/packages/react-components/react-field/src/stories/Field/index.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/index.stories.tsx @@ -8,6 +8,7 @@ export { AllControls } from './FieldDefault.stories'; export { LabelBefore } from './FieldDefault.stories'; export { Required } from './FieldDefault.stories'; export { Status } from './FieldDefault.stories'; +export { Size } from './FieldDefault.stories'; export { HelperText } from './FieldDefault.stories'; export default { From d4bbcc333aeee469fc31e598820cfe339bb03c15 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Tue, 2 Aug 2022 13:38:38 -0700 Subject: [PATCH 03/23] Updates to field --- .../src/components/Field/Field.types.ts | 31 +++---- .../src/components/Field/renderField.tsx | 1 + .../src/components/Field/useField.tsx | 81 ++++++++++++------- .../src/components/Field/useFieldStyles.ts | 52 +++++++----- .../react-field/src/contexts/FieldContext.ts | 2 +- .../src/contexts/useFieldChildProps.ts | 14 ++-- .../react-components/react-field/src/index.ts | 2 +- .../stories/Field/FieldDefault.stories.tsx | 46 ++++++----- .../src/stories/Field/index.stories.tsx | 1 + 9 files changed, 138 insertions(+), 92 deletions(-) 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 8e588f6e6fb827..18c1d6303541fe 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,42 +1,37 @@ -import * as React from 'react'; -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; import { Label } from '@fluentui/react-label'; +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import * as React from 'react'; export type FieldSlots = { root: NonNullable>; + // wrapper: NonNullable>; label?: Slot; statusText?: Slot<'span'>; statusIcon?: Slot<'span'>; helperText?: Slot<'span'>; }; -export type FieldChildProps = Pick, 'id' | 'required' | 'aria-labelledby'>; - /** * Field Props */ -export type FieldProps = Omit, 'children'> & { - children: React.ReactElement; - size?: 'small' | 'medium' | 'large'; +export type FieldProps = Omit>, 'children'> & { + children: React.ReactElement<{ id?: string; required?: boolean }>; + labelFor?: string; + labelId?: string; labelPosition?: 'above' | 'before'; - status?: 'error' | 'warning' | 'success'; - inputId?: string; required?: boolean; + size?: 'small' | 'medium' | 'large'; + status?: 'error' | 'warning' | 'success'; }; /** * State used in rendering Field */ -export type FieldState = ComponentState & { - size: NonNullable; - labelPosition: NonNullable; - status: FieldProps['status']; - required: FieldProps['required']; - inputId: NonNullable; - labelId: string | undefined; -}; +export type FieldState = ComponentState> & + Pick & + Required>; -export type FieldContextValue = Readonly>; +export type FieldContextValue = Readonly>; export type FieldContextValues = { field: FieldContextValue; 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 687605d0ab339c..84d5bf532611e3 100644 --- a/packages/react-components/react-field/src/components/Field/renderField.tsx +++ b/packages/react-components/react-field/src/components/Field/renderField.tsx @@ -14,6 +14,7 @@ export const renderField_unstable = (state: FieldState, contextValues: FieldCont {slots.label && } {slotProps.root.children} + {/* */} {slots.statusText && ( {slots.statusIcon && } 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 b30897be6fe959..c4efe5dc53ec77 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { CheckmarkCircle12Filled, ErrorCircle12Filled, Warning12Filled } from '@fluentui/react-icons'; -import { getNativeElementProps, resolveShorthand, useId } from '@fluentui/react-utilities'; import type { FieldProps, FieldState } from './Field.types'; +import { CheckmarkCircle12Filled, ErrorCircle12Filled, Warning12Filled } from '@fluentui/react-icons'; import { Label } from '@fluentui/react-label'; +import { getNativeElementProps, resolveShorthand, useId, useMergedRefs } from '@fluentui/react-utilities'; /** * Create the state required to render Field. @@ -14,40 +14,33 @@ import { Label } from '@fluentui/react-label'; * @param ref - reference to root HTMLElement of Field */ export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { - const baseId = useId('field-'); + const child = React.Children.only(props.children); + const childProps = React.isValidElement(child) ? child.props : undefined; - let childInput = React.Children.only(props.children); - if (!React.isValidElement(childInput) || childInput.type === React.Fragment) { - throw new Error( - 'The child must be a single input element for this component. ' + - "Please ensure that you're not using React Fragments.", - ); - } + const baseId = useId('field-'); const { - size = 'medium', + labelFor = childProps?.id ?? baseId + '__input', + labelId = baseId + '__label', labelPosition = 'above', + required = childProps?.required, + size = 'medium', status, - inputId = childInput.props.id || baseId + '__input', - required = childInput.props.required, } = props; const label = resolveShorthand(props.label, { defaultProps: { - id: baseId + '__label', - htmlFor: inputId, + id: labelId, + htmlFor: labelFor, required, size, }, }); - // Apply the props to the child input - childInput = React.cloneElement(childInput, { - id: inputId, - 'aria-labelledby': label?.id, - required, - ...childInput.props, - }); + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + ref = useMergedRefs(ref, useLabelDebugCheck(label)); + } let defaultStatusIcon; if (status === 'error') { @@ -59,15 +52,16 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref } return { + labelFor: label?.htmlFor, + labelId: label?.id, labelPosition, - status, - size, required, - labelId: label?.id, - inputId, + size, + status, components: { root: 'div', label: Label, + // wrapper: 'div', statusText: 'span', statusIcon: 'span', helperText: 'span', @@ -75,11 +69,17 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref root: getNativeElementProps('div', { ...props, ref, - children: childInput, + // children: undefined, }), + // wrapper: resolveShorthand(props.wrapper, { + // required: true, + // defaultProps: { + // children: props.children, + // }, + // }), label, statusIcon: resolveShorthand(props.statusIcon, { - required: !!status, + required: !!defaultStatusIcon, defaultProps: { children: defaultStatusIcon, }, @@ -88,3 +88,28 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref helperText: resolveShorthand(props.helperText), }; }; + +const useLabelDebugCheck = (label: FieldState['label']) => { + const labelFor = label?.htmlFor; + const labelId = label?.id; + const labelText = label?.children; + const rootRef = React.useRef(null); + + React.useEffect(() => { + if (!rootRef.current || !labelFor || !labelId) { + return; + } + + if (!rootRef.current.querySelector(`[id='${labelFor}'], [aria-labelledby='${labelId}']`)) { + // eslint-disable-next-line no-console + console.error( + `Field with label "${labelText}" requires the label be associated with its input. Try one of these fixes:\n` + + '1. Use a component that gets its ID from FieldContext.labelFor (e.g. the form controls in this library).\n' + + "2. Or, set the Field's `labelFor` prop to the input's `id` prop.\n" + + "3. Or, set the Field's `labelId` to the input's `aria-labelledby` prop.\n", + ); + } + }, [labelFor, labelId, labelText]); + + return rootRef; +}; diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index 1980c12c9179a6..4c585bf357d63b 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -6,6 +6,7 @@ import { tokens, typographyStyles } from '@fluentui/react-theme'; export const fieldClassNames: SlotClassNames = { root: 'fui-Field', label: 'fui-Field__label', + // wrapper: 'fui-Field__wrapper', statusText: 'fui-Field__statusText', statusIcon: 'fui-Field__statusIcon', helperText: 'fui-Field__helperText', @@ -26,10 +27,14 @@ const gridArea = (area: string) => ({ const useRootStyles = makeStyles({ base: { display: 'inline-grid', + gridTemplateAreas: ` + "input" + "statusText" + "helperText" + `, }, - // labelPosition="above" - above: { + 'label-above': { gridTemplateAreas: ` "label" "input" @@ -38,14 +43,17 @@ const useRootStyles = makeStyles({ `, }, - // labelPosition="before" - before: { + 'label-before': { gridTemplateAreas: ` "label input" "label statusText" "label helperText" `, }, + + // wrapper: { + // ...gridArea('wrapper'), + // }, }); const useLabelStyles = makeStyles({ @@ -62,10 +70,11 @@ const useLabelStyles = makeStyles({ }, }); -const useExtraTextStyles = makeStyles({ +const useSecondaryTextStyles = makeStyles({ base: { display: 'inline-flex', marginTop: tokens.spacingVerticalXXS, + color: tokens.colorNeutralForeground3, ...typographyStyles.caption1, }, @@ -79,12 +88,6 @@ const useExtraTextStyles = makeStyles({ error: { color: tokens.colorPaletteRedForeground1, }, - warning: { - color: tokens.colorPaletteDarkOrangeForeground1, - }, - success: { - color: tokens.colorPaletteGreenForeground1, - }, }); const useStatusIconStyles = makeStyles({ @@ -94,6 +97,16 @@ const useStatusIconStyles = makeStyles({ alignSelf: 'center', marginRight: tokens.spacingHorizontalXS, }, + + error: { + color: tokens.colorPaletteRedForeground1, + }, + warning: { + color: tokens.colorPaletteDarkOrangeForeground1, + }, + success: { + color: tokens.colorPaletteGreenForeground1, + }, }); /** @@ -104,10 +117,12 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.root.className = mergeClasses( fieldClassNames.root, rootStyles.base, - rootStyles[state.labelPosition], + state.label ? rootStyles[`label-${state.labelPosition}`] : undefined, state.root.className, ); + // state.wrapper.className = mergeClasses(fieldClassNames.wrapper, rootStyles.wrapper, state.wrapper.className); + const labelStyles = useLabelStyles(); if (state.label) { state.label.className = mergeClasses( @@ -123,17 +138,18 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.statusIcon.className = mergeClasses( fieldClassNames.statusIcon, statusIconStyles.base, + !!state.status && statusIconStyles[state.status], state.statusIcon.className, ); } - const extraTextStyles = useExtraTextStyles(); + const secondaryTextStyles = useSecondaryTextStyles(); if (state.statusText) { state.statusText.className = mergeClasses( fieldClassNames.statusText, - extraTextStyles.base, - extraTextStyles.statusText, - !!state.status && extraTextStyles[state.status], + secondaryTextStyles.base, + secondaryTextStyles.statusText, + state.status === 'error' && secondaryTextStyles.error, state.statusText.className, ); } @@ -141,8 +157,8 @@ export const useFieldStyles_unstable = (state: FieldState) => { if (state.helperText) { state.helperText.className = mergeClasses( fieldClassNames.helperText, - extraTextStyles.base, - extraTextStyles.helperText, + secondaryTextStyles.base, + secondaryTextStyles.helperText, state.helperText.className, ); } diff --git a/packages/react-components/react-field/src/contexts/FieldContext.ts b/packages/react-components/react-field/src/contexts/FieldContext.ts index cd127700736500..e76e386ab09bbd 100644 --- a/packages/react-components/react-field/src/contexts/FieldContext.ts +++ b/packages/react-components/react-field/src/contexts/FieldContext.ts @@ -5,6 +5,6 @@ export const FieldContext = createContext(undefin export const FieldProvider = FieldContext.Provider; -export const useFieldContext_unstable = (selector: ContextSelector): T => { +export const useFieldContext = (selector: ContextSelector): T => { return useContextSelector(FieldContext, selector); }; diff --git a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts index c0e9750f5b90f3..48834c914e8d0c 100644 --- a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts +++ b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts @@ -1,4 +1,4 @@ -import { useFieldContext_unstable } from './FieldContext'; +import { useFieldContext } from './FieldContext'; export type UseFieldChildPropsOptions = { supportedSizes: SizeValues[]; @@ -16,22 +16,22 @@ export const useFieldChildProps = ( ) => { const props: FieldChildProps = {}; - const inputId = useFieldContext_unstable(context => context?.inputId); - if (inputId) { - props.id = inputId; + const labelFor = useFieldContext(ctx => ctx?.labelFor); + if (labelFor) { + props.id = labelFor; } - const labelId = useFieldContext_unstable(context => context?.labelId); + const labelId = useFieldContext(ctx => ctx?.labelId); if (labelId) { props['aria-labelledby'] = labelId; } - const required = useFieldContext_unstable(context => context?.required); + const required = useFieldContext(ctx => ctx?.required); if (required) { props.required = required; } - const size = useFieldContext_unstable(context => context?.size); + const size = useFieldContext(ctx => ctx?.size); if (size && options.supportedSizes.includes(size as SizeValues)) { props.size = size as SizeValues; } diff --git a/packages/react-components/react-field/src/index.ts b/packages/react-components/react-field/src/index.ts index 854adc82e3424b..f10abe4542311c 100644 --- a/packages/react-components/react-field/src/index.ts +++ b/packages/react-components/react-field/src/index.ts @@ -1,3 +1,3 @@ export { Field, fieldClassNames, renderField_unstable, useFieldStyles_unstable, useField_unstable } from './Field'; export type { FieldContextValue, FieldContextValues, FieldProps, FieldSlots, FieldState } from './Field'; -export { FieldProvider, useFieldContextValues, useFieldContext_unstable } from './contexts/index'; +export { FieldProvider, useFieldContextValues, useFieldContext } from './contexts/index'; diff --git a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx index 02f134f5a6e15e..85fbd839dbd585 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx @@ -67,7 +67,7 @@ export const LabelBefore = () => { export const Required = () => { return ( - + ); @@ -78,22 +78,13 @@ export const Status = () => { return (
- - - - - - - - - - + - + - + { ); }; +export const Validation = () => { + const [value, setValue] = React.useState('error'); + + const status = value === 'error' || value === 'warning' || value === 'success' ? value : undefined; + + return ( + + setValue(data.value)}> + + + + + + + ); +}; + export const Size = () => { const styles = useStyles(); return (
- - + + - - + + - - + +
); diff --git a/packages/react-components/react-field/src/stories/Field/index.stories.tsx b/packages/react-components/react-field/src/stories/Field/index.stories.tsx index f6090a612577e2..1895ae9c92b817 100644 --- a/packages/react-components/react-field/src/stories/Field/index.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/index.stories.tsx @@ -8,6 +8,7 @@ export { AllControls } from './FieldDefault.stories'; export { LabelBefore } from './FieldDefault.stories'; export { Required } from './FieldDefault.stories'; export { Status } from './FieldDefault.stories'; +export { Validation } from './FieldDefault.stories'; export { Size } from './FieldDefault.stories'; export { HelperText } from './FieldDefault.stories'; From 0832e338c6c35cffc5853421cc254b7af21051b7 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Tue, 2 Aug 2022 13:39:25 -0700 Subject: [PATCH 04/23] Remove commented wrapper --- .../react-field/src/components/Field/Field.types.ts | 1 - .../react-field/src/components/Field/renderField.tsx | 1 - .../react-field/src/components/Field/useField.tsx | 8 -------- .../react-field/src/components/Field/useFieldStyles.ts | 7 ------- 4 files changed, 17 deletions(-) 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 18c1d6303541fe..e1a7cf0db180dd 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 @@ -4,7 +4,6 @@ import * as React from 'react'; export type FieldSlots = { root: NonNullable>; - // wrapper: NonNullable>; label?: Slot; statusText?: Slot<'span'>; statusIcon?: Slot<'span'>; 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 84d5bf532611e3..687605d0ab339c 100644 --- a/packages/react-components/react-field/src/components/Field/renderField.tsx +++ b/packages/react-components/react-field/src/components/Field/renderField.tsx @@ -14,7 +14,6 @@ export const renderField_unstable = (state: FieldState, contextValues: FieldCont {slots.label && } {slotProps.root.children} - {/* */} {slots.statusText && ( {slots.statusIcon && } 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 c4efe5dc53ec77..d5668f6c537935 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -61,7 +61,6 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref components: { root: 'div', label: Label, - // wrapper: 'div', statusText: 'span', statusIcon: 'span', helperText: 'span', @@ -69,14 +68,7 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref root: getNativeElementProps('div', { ...props, ref, - // children: undefined, }), - // wrapper: resolveShorthand(props.wrapper, { - // required: true, - // defaultProps: { - // children: props.children, - // }, - // }), label, statusIcon: resolveShorthand(props.statusIcon, { required: !!defaultStatusIcon, diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index 4c585bf357d63b..9c9d5fd85a813c 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -6,7 +6,6 @@ import { tokens, typographyStyles } from '@fluentui/react-theme'; export const fieldClassNames: SlotClassNames = { root: 'fui-Field', label: 'fui-Field__label', - // wrapper: 'fui-Field__wrapper', statusText: 'fui-Field__statusText', statusIcon: 'fui-Field__statusIcon', helperText: 'fui-Field__helperText', @@ -50,10 +49,6 @@ const useRootStyles = makeStyles({ "label helperText" `, }, - - // wrapper: { - // ...gridArea('wrapper'), - // }, }); const useLabelStyles = makeStyles({ @@ -121,8 +116,6 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.root.className, ); - // state.wrapper.className = mergeClasses(fieldClassNames.wrapper, rootStyles.wrapper, state.wrapper.className); - const labelStyles = useLabelStyles(); if (state.label) { state.label.className = mergeClasses( From befddb04a3706690e3fad8246788f85049e794eb Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Tue, 2 Aug 2022 15:47:11 -0700 Subject: [PATCH 05/23] Remove gridTemplateAreas from root styles --- .../src/components/Field/useFieldStyles.ts | 52 +++---------------- 1 file changed, 8 insertions(+), 44 deletions(-) diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index 9c9d5fd85a813c..b368a1ebb26272 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -11,57 +11,31 @@ export const fieldClassNames: SlotClassNames = { helperText: 'fui-Field__helperText', }; -// TODO replace with shorthands.gridArea when it is available -// https://github.com/microsoft/griffel/issues/120 -const gridArea = (area: string) => ({ - gridRowStart: area, - gridRowEnd: area, - gridColumnStart: area, - gridColumnEnd: area, -}); - /** * Styles for the root slot */ const useRootStyles = makeStyles({ base: { display: 'inline-grid', - gridTemplateAreas: ` - "input" - "statusText" - "helperText" - `, + gridAutoFlow: 'row', + justifyItems: 'start', }, - 'label-above': { - gridTemplateAreas: ` - "label" - "input" - "statusText" - "helperText" - `, - }, - - 'label-before': { - gridTemplateAreas: ` - "label input" - "label statusText" - "label helperText" - `, + labelBefore: { + gridTemplateRows: 'repeat(4, auto)', + gridTemplateColumns: 'repeat(2, auto)', }, }); const useLabelStyles = makeStyles({ - base: { - ...gridArea('label'), - }, - above: { marginBottom: tokens.spacingVerticalXXS, }, before: { marginRight: tokens.spacingHorizontalM, + gridRowStart: '1', + gridRowEnd: '-1', }, }); @@ -73,13 +47,6 @@ const useSecondaryTextStyles = makeStyles({ ...typographyStyles.caption1, }, - statusText: { - ...gridArea('statusText'), - }, - helperText: { - ...gridArea('helperText'), - }, - error: { color: tokens.colorPaletteRedForeground1, }, @@ -112,7 +79,7 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.root.className = mergeClasses( fieldClassNames.root, rootStyles.base, - state.label ? rootStyles[`label-${state.labelPosition}`] : undefined, + state.label && state.labelPosition === 'before' && rootStyles.labelBefore, state.root.className, ); @@ -120,7 +87,6 @@ export const useFieldStyles_unstable = (state: FieldState) => { if (state.label) { state.label.className = mergeClasses( fieldClassNames.label, - labelStyles.base, labelStyles[state.labelPosition], state.label.className, ); @@ -141,7 +107,6 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.statusText.className = mergeClasses( fieldClassNames.statusText, secondaryTextStyles.base, - secondaryTextStyles.statusText, state.status === 'error' && secondaryTextStyles.error, state.statusText.className, ); @@ -151,7 +116,6 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.helperText.className = mergeClasses( fieldClassNames.helperText, secondaryTextStyles.base, - secondaryTextStyles.helperText, state.helperText.className, ); } From 784b89878415c6b08630cf21062e3e0454cc66b9 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Wed, 3 Aug 2022 15:15:23 -0700 Subject: [PATCH 06/23] Update field implementation --- .../react-field/etc/react-field.api.md | 31 ++++--- .../src/components/Field/Field.types.ts | 13 +-- .../src/components/Field/useField.tsx | 25 ++---- .../react-field/src/contexts/index.ts | 1 + .../src/contexts/useFieldChildProps.ts | 87 ++++++++++--------- .../src/contexts/useFieldContextValues.ts | 4 +- .../react-components/react-field/src/index.ts | 1 + .../react-field/src/util/filterFieldSize.ts | 8 ++ .../react-field/src/util/index.ts | 1 + 9 files changed, 92 insertions(+), 79 deletions(-) create mode 100644 packages/react-components/react-field/src/util/filterFieldSize.ts create mode 100644 packages/react-components/react-field/src/util/index.ts 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 2b80337395a2d1..9c30240d317cf1 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -25,7 +25,7 @@ export const Field: ForwardRefComponent; export const fieldClassNames: SlotClassNames; // @public (undocumented) -export type FieldContextValue = Readonly>; +export type FieldContextValue = Readonly>; // @public (undocumented) export type FieldContextValues = { @@ -33,17 +33,18 @@ export type FieldContextValues = { }; // @public -export type FieldProps = Omit, 'children'> & { - children: React_2.ReactElement; - size?: 'small' | 'medium' | 'large'; +export type FieldProps = Omit>, 'children'> & { + children: React_2.ReactElement<{ + id?: string; + }>; labelPosition?: 'above' | 'before'; - status?: 'error' | 'warning' | 'success'; - inputId?: string; required?: boolean; + size?: 'small' | 'medium' | 'large'; + status?: 'error' | 'warning' | 'success'; }; // @public (undocumented) -export const FieldProvider: Provider> | undefined> & FC> | undefined>>; +export const FieldProvider: Provider> | undefined> & FC> | undefined>>; // @public (undocumented) export type FieldSlots = { @@ -55,12 +56,8 @@ export type FieldSlots = { }; // @public -export type FieldState = ComponentState & { - size: NonNullable; - labelPosition: NonNullable; - status: FieldProps['status']; - required: FieldProps['required']; - inputId: NonNullable; +export type FieldState = ComponentState> & Pick & Required> & { + childId: string | undefined; labelId: string | undefined; }; @@ -71,7 +68,10 @@ export const renderField_unstable: (state: FieldState, contextValues: FieldConte export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; // @public (undocumented) -export const useFieldContext_unstable: (selector: ContextSelector> | undefined, T>) => T; +export const useFieldChildProps_unstable: (options: UseFieldChildPropsOptions>) => FieldChildProps | undefined; + +// @public (undocumented) +export const useFieldContext: (selector: ContextSelector> | undefined, T>) => T; // @public (undocumented) export const useFieldContextValues: (state: FieldState) => FieldContextValues; @@ -79,6 +79,9 @@ export const useFieldContextValues: (state: FieldState) => FieldContextValues; // @public export const useFieldStyles_unstable: (state: FieldState) => void; +// @public (undocumented) +export const useMergedFieldProps_unstable: >(props: Props, options: UseFieldChildPropsOptions) => Props; + // (No @packageDocumentation comment for this package) ``` 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 e1a7cf0db180dd..4254233c4958d2 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 @@ -14,9 +14,7 @@ export type FieldSlots = { * Field Props */ export type FieldProps = Omit>, 'children'> & { - children: React.ReactElement<{ id?: string; required?: boolean }>; - labelFor?: string; - labelId?: string; + children: React.ReactElement<{ id?: string }>; labelPosition?: 'above' | 'before'; required?: boolean; size?: 'small' | 'medium' | 'large'; @@ -27,10 +25,13 @@ export type FieldProps = Omit>, 'children'> & * State used in rendering Field */ export type FieldState = ComponentState> & - Pick & - Required>; + Pick & + Required> & { + childId: string | undefined; + labelId: string | undefined; + }; -export type FieldContextValue = Readonly>; +export type FieldContextValue = Readonly>; export type FieldContextValues = { field: FieldContextValue; 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 d5668f6c537935..3e3bdc8aeb3126 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -14,24 +14,15 @@ import { getNativeElementProps, resolveShorthand, useId, useMergedRefs } from '@ * @param ref - reference to root HTMLElement of Field */ export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { + const { labelPosition = 'above', required, size = 'medium', status } = props; + const child = React.Children.only(props.children); const childProps = React.isValidElement(child) ? child.props : undefined; - const baseId = useId('field-'); - - const { - labelFor = childProps?.id ?? baseId + '__input', - labelId = baseId + '__label', - labelPosition = 'above', - required = childProps?.required, - size = 'medium', - status, - } = props; - const label = resolveShorthand(props.label, { defaultProps: { - id: labelId, - htmlFor: labelFor, + id: useId('field__label-'), + htmlFor: useId('field__input-', childProps?.id), required, size, }, @@ -52,7 +43,7 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref } return { - labelFor: label?.htmlFor, + childId: label?.htmlFor, labelId: label?.id, labelPosition, required, @@ -96,9 +87,9 @@ const useLabelDebugCheck = (label: FieldState['label']) => { // eslint-disable-next-line no-console console.error( `Field with label "${labelText}" requires the label be associated with its input. Try one of these fixes:\n` + - '1. Use a component that gets its ID from FieldContext.labelFor (e.g. the form controls in this library).\n' + - "2. Or, set the Field's `labelFor` prop to the input's `id` prop.\n" + - "3. Or, set the Field's `labelId` to the input's `aria-labelledby` prop.\n", + '1. Use a component that gets its ID from FieldContext.childId (e.g. the form controls in this library).\n' + + '2. Or, set `label={{ htmlFor: ... }}` to the `id` prop of the field component.\n' + + '3. Or, set `label={{ id: ... }}` to the `aria-labelledby` prop of the field component.\n', ); } }, [labelFor, labelId, labelText]); diff --git a/packages/react-components/react-field/src/contexts/index.ts b/packages/react-components/react-field/src/contexts/index.ts index 396547b7f47d14..c32893bd9b2673 100644 --- a/packages/react-components/react-field/src/contexts/index.ts +++ b/packages/react-components/react-field/src/contexts/index.ts @@ -1,2 +1,3 @@ export * from './FieldContext'; export * from './useFieldContextValues'; +// export * from './useFieldChildProps'; diff --git a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts index 48834c914e8d0c..70c7b9844c617f 100644 --- a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts +++ b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts @@ -1,40 +1,47 @@ -import { useFieldContext } from './FieldContext'; - -export type UseFieldChildPropsOptions = { - supportedSizes: SizeValues[]; -}; - -export type FieldChildProps = { - id?: string; - required?: boolean; - 'aria-labelledby'?: string; - size?: SizeValues; -}; - -export const useFieldChildProps = ( - options: UseFieldChildPropsOptions, -) => { - const props: FieldChildProps = {}; - - const labelFor = useFieldContext(ctx => ctx?.labelFor); - if (labelFor) { - props.id = labelFor; - } - - const labelId = useFieldContext(ctx => ctx?.labelId); - if (labelId) { - props['aria-labelledby'] = labelId; - } - - const required = useFieldContext(ctx => ctx?.required); - if (required) { - props.required = required; - } - - const size = useFieldContext(ctx => ctx?.size); - if (size && options.supportedSizes.includes(size as SizeValues)) { - props.size = size as SizeValues; - } - - return props; -}; +export {}; +// import { FieldProps } from '../Field'; +// import { filterFieldSize } from '../util/filterFieldSize'; +// import { useFieldContext } from './FieldContext'; + +// export type UseFieldChildPropsOptions> = { +// supportedSizes: (NonNullable & NonNullable)[]; +// }; + +// export type FieldChildProps = { +// id?: string; +// required?: boolean; +// 'aria-labelledby'?: string; +// size?: SizeValues; +// }; + +// export const useFieldChildProps_unstable = ( +// options: UseFieldChildPropsOptions>, +// ) => { +// const props: FieldChildProps = { +// id: useFieldContext(ctx => ctx?.childId), +// 'aria-labelledby': useFieldContext(ctx => ctx?.labelId), +// required: useFieldContext(ctx => ctx?.required), +// size: useFieldContext(ctx => filterFieldSize(ctx?.size, options.supportedSizes)), +// }; + +// if (useFieldContext(ctx => ctx === undefined)) { +// return undefined; +// } + +// return props; +// }; + +// export const useMergedFieldProps_unstable = >( +// props: Props, +// options: UseFieldChildPropsOptions, +// ): Props => { +// const propsFromField = { +// id: useFieldContext(ctx => ctx?.childId), +// 'aria-labelledby': useFieldContext(ctx => ctx?.labelId), +// required: useFieldContext(ctx => ctx?.required), +// size: useFieldContext(ctx => filterFieldSize(ctx?.size, options.supportedSizes)), +// }; + +// const hasFieldContext = useFieldContext(ctx => ctx !== undefined); +// return hasFieldContext ? { ...propsFromField, ...props } : props; +// }; diff --git a/packages/react-components/react-field/src/contexts/useFieldContextValues.ts b/packages/react-components/react-field/src/contexts/useFieldContextValues.ts index fbe8b37bf7567b..35037554f9b420 100644 --- a/packages/react-components/react-field/src/contexts/useFieldContextValues.ts +++ b/packages/react-components/react-field/src/contexts/useFieldContextValues.ts @@ -1,8 +1,8 @@ import type { FieldContextValues, FieldState } from '../Field'; export const useFieldContextValues = (state: FieldState): FieldContextValues => { - const { size, required, status, labelId, inputId } = state; + const { size, required, status, labelId, childId } = state; return { - field: { size, required, status, labelId, inputId }, + field: { size, required, status, labelId, childId }, }; }; diff --git a/packages/react-components/react-field/src/index.ts b/packages/react-components/react-field/src/index.ts index f10abe4542311c..7c94ca46daa306 100644 --- a/packages/react-components/react-field/src/index.ts +++ b/packages/react-components/react-field/src/index.ts @@ -1,3 +1,4 @@ export { Field, fieldClassNames, renderField_unstable, useFieldStyles_unstable, useField_unstable } from './Field'; export type { FieldContextValue, FieldContextValues, FieldProps, FieldSlots, FieldState } from './Field'; export { FieldProvider, useFieldContextValues, useFieldContext } from './contexts/index'; +export { filterFieldSize } from './util/index'; diff --git a/packages/react-components/react-field/src/util/filterFieldSize.ts b/packages/react-components/react-field/src/util/filterFieldSize.ts new file mode 100644 index 00000000000000..96fa63278fafe6 --- /dev/null +++ b/packages/react-components/react-field/src/util/filterFieldSize.ts @@ -0,0 +1,8 @@ +import { FieldProps } from '../Field'; + +export const filterFieldSize = >( + size: FieldProps['size'], + supportedSizes: SupportedSizes[], +): SupportedSizes | undefined => { + return size && supportedSizes.includes(size as SupportedSizes) ? (size as SupportedSizes) : undefined; +}; diff --git a/packages/react-components/react-field/src/util/index.ts b/packages/react-components/react-field/src/util/index.ts new file mode 100644 index 00000000000000..696e923e06c466 --- /dev/null +++ b/packages/react-components/react-field/src/util/index.ts @@ -0,0 +1 @@ +export * from './filterFieldSize'; From e1942ba779bb1283d91646fcbae85b5d5ca4cf56 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Wed, 3 Aug 2022 16:51:43 -0700 Subject: [PATCH 07/23] Fix styling for labelBefore --- .../react-field/src/components/Field/useFieldStyles.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index b368a1ebb26272..a5a9991c366b14 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -18,12 +18,11 @@ const useRootStyles = makeStyles({ base: { display: 'inline-grid', gridAutoFlow: 'row', - justifyItems: 'start', }, labelBefore: { gridTemplateRows: 'repeat(4, auto)', - gridTemplateColumns: 'repeat(2, auto)', + gridTemplateColumns: '1fr 2fr', }, }); @@ -33,9 +32,11 @@ const useLabelStyles = makeStyles({ }, before: { - marginRight: tokens.spacingHorizontalM, gridRowStart: '1', gridRowEnd: '-1', + marginRight: tokens.spacingHorizontalM, + marginTop: tokens.spacingVerticalXXS, + marginBottom: tokens.spacingVerticalXXS, }, }); From c5febb049805ab96bd1f88299957782aa3354018 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Wed, 3 Aug 2022 16:52:10 -0700 Subject: [PATCH 08/23] Documentation updates --- .../src/components/Field/Field.types.ts | 61 +++++++++++++++++++ .../src/components/Field/useField.tsx | 16 ++--- 2 files changed, 70 insertions(+), 7 deletions(-) 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 4254233c4958d2..3f42508baf08f9 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 @@ -4,9 +4,27 @@ import * as React from 'react'; export type FieldSlots = { root: NonNullable>; + + /** + * The label associated with the field. + */ label?: Slot; + + /** + * A status or validation message. The appearance of the statusText depends on the value of the `status` prop. + */ statusText?: Slot<'span'>; + + /** + * The icon associated with the status. If the `status` prop is set, this will default to a corresponding icon. + * + * This will only be displayed if `statusText` is set. + */ statusIcon?: Slot<'span'>; + + /** + * Additional text below the field. + */ helperText?: Slot<'span'>; }; @@ -14,10 +32,42 @@ export type FieldSlots = { * Field Props */ export type FieldProps = Omit>, 'children'> & { + /** + * Field must have exactly one child that is a form component. + */ children: React.ReactElement<{ id?: string }>; + + /** + * The position of the label relative to the field. This only affects the label, and not the statusText or helperText + * (which always appear below the field). + * + * @default above + */ labelPosition?: 'above' | 'before'; + + /** + * Marks the field as required, and adds required styling to the label (red asterisk). + * + * @default false + */ required?: boolean; + + /** + * Size of the field and label. + * + * NOTE: Not all components support all available sizes. Check the documentation of the component to see what values + * it supports for its `size` prop. + * + * @default medium + */ size?: 'small' | 'medium' | 'large'; + + /** + * The status affects the color of the statusText, the statusIcon, and for some field components, an error status + * causes the border to become red. + * + * @default undefined + */ status?: 'error' | 'warning' | 'success'; }; @@ -27,7 +77,18 @@ export type FieldProps = Omit>, 'children'> & export type FieldState = ComponentState> & Pick & Required> & { + /** + * The ID that the child component should use to be sure that it is properly associated with the label. + * + * This will default to the `id` prop of the child, or use a generated ID if the child has no `id` prop. + */ childId: string | undefined; + + /** + * The (generated) ID of the field's label component. + * + * This can be used as the `aria-labelledby` prop when the label's htmlFor doesn't work (such as RadioGroup). + */ labelId: string | undefined; }; 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 3e3bdc8aeb3126..c18ba192e66827 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -13,16 +13,17 @@ import { getNativeElementProps, resolveShorthand, useId, useMergedRefs } from '@ * @param props - props from this instance of Field * @param ref - reference to root HTMLElement of Field */ -export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { +export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { const { labelPosition = 'above', required, size = 'medium', status } = props; const child = React.Children.only(props.children); const childProps = React.isValidElement(child) ? child.props : undefined; + const childId = useId('field__input-', childProps?.id); const label = resolveShorthand(props.label, { defaultProps: { id: useId('field__label-'), - htmlFor: useId('field__input-', childProps?.id), + htmlFor: childId, required, size, }, @@ -43,7 +44,7 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref } return { - childId: label?.htmlFor, + childId, labelId: label?.id, labelPosition, required, @@ -76,10 +77,10 @@ const useLabelDebugCheck = (label: FieldState['label']) => { const labelFor = label?.htmlFor; const labelId = label?.id; const labelText = label?.children; - const rootRef = React.useRef(null); + const rootRef = React.useRef(null); React.useEffect(() => { - if (!rootRef.current || !labelFor || !labelId) { + if (!rootRef.current || !labelText || !labelFor || !labelId) { return; } @@ -88,8 +89,9 @@ const useLabelDebugCheck = (label: FieldState['label']) => { console.error( `Field with label "${labelText}" requires the label be associated with its input. Try one of these fixes:\n` + '1. Use a component that gets its ID from FieldContext.childId (e.g. the form controls in this library).\n' + - '2. Or, set `label={{ htmlFor: ... }}` to the `id` prop of the field component.\n' + - '3. Or, set `label={{ id: ... }}` to the `aria-labelledby` prop of the field component.\n', + '2. Or, set the `id` prop of the child of field.\n' + + '3. Or, set `label={{ htmlFor: ... }}` to the ID used by the field component.\n' + + '4. Or, set `label={{ id: ... }}` to the `aria-labelledby` prop of the field component.\n', ); } }, [labelFor, labelId, labelText]); From c947a916f748a8f1a70fce93f968b0d964774abc Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 00:28:14 -0700 Subject: [PATCH 09/23] Fix styles when labelPosition="before" but there is no label --- .../react-field/src/components/Field/useFieldStyles.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index a5a9991c366b14..17e59dbfae93ee 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -23,6 +23,9 @@ const useRootStyles = makeStyles({ labelBefore: { gridTemplateRows: 'repeat(4, auto)', gridTemplateColumns: '1fr 2fr', + [`> :not(.${fieldClassNames.label})`]: { + gridColumnStart: '2', + }, }, }); @@ -80,7 +83,7 @@ export const useFieldStyles_unstable = (state: FieldState) => { state.root.className = mergeClasses( fieldClassNames.root, rootStyles.base, - state.label && state.labelPosition === 'before' && rootStyles.labelBefore, + state.labelPosition === 'before' && rootStyles.labelBefore, state.root.className, ); From 1a394843d38af9641f39bd19e2fad8233323e44c Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 01:43:04 -0700 Subject: [PATCH 10/23] Update field layout styles --- .../src/components/Field/useFieldStyles.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index 17e59dbfae93ee..922fcc098896cf 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -18,6 +18,7 @@ const useRootStyles = makeStyles({ base: { display: 'inline-grid', gridAutoFlow: 'row', + justifyItems: 'start', }, labelBefore: { @@ -30,7 +31,8 @@ const useRootStyles = makeStyles({ }); const useLabelStyles = makeStyles({ - above: { + base: { + marginTop: tokens.spacingVerticalXXS, marginBottom: tokens.spacingVerticalXXS, }, @@ -38,14 +40,11 @@ const useLabelStyles = makeStyles({ gridRowStart: '1', gridRowEnd: '-1', marginRight: tokens.spacingHorizontalM, - marginTop: tokens.spacingVerticalXXS, - marginBottom: tokens.spacingVerticalXXS, }, }); const useSecondaryTextStyles = makeStyles({ base: { - display: 'inline-flex', marginTop: tokens.spacingVerticalXXS, color: tokens.colorNeutralForeground3, ...typographyStyles.caption1, @@ -60,7 +59,7 @@ const useStatusIconStyles = makeStyles({ base: { fontSize: '12px', lineHeight: '12px', - alignSelf: 'center', + verticalAlign: 'middle', marginRight: tokens.spacingHorizontalXS, }, @@ -91,7 +90,8 @@ export const useFieldStyles_unstable = (state: FieldState) => { if (state.label) { state.label.className = mergeClasses( fieldClassNames.label, - labelStyles[state.labelPosition], + labelStyles.base, + state.labelPosition === 'before' && labelStyles.before, state.label.className, ); } From 972619ae50ecd20da444e6e99e9136cf2d7f36ea Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 01:53:36 -0700 Subject: [PATCH 11/23] Add documentation --- .../stories/Field/FieldDefault.stories.tsx | 150 ++---------------- .../src/stories/Field/FieldDescription.md | 3 + .../stories/Field/FieldHelperText.stories.tsx | 9 ++ .../stories/Field/FieldLabelAbove.stories.tsx | 59 +++++++ .../Field/FieldLabelBefore.stories.tsx | 52 ++++++ .../stories/Field/FieldRequired.stories.tsx | 9 ++ .../src/stories/Field/FieldSize.stories.tsx | 28 ++++ .../src/stories/Field/FieldStatus.stories.tsx | 37 +++++ .../src/stories/Field/index.stories.tsx | 13 +- 9 files changed, 215 insertions(+), 145 deletions(-) create mode 100644 packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx create mode 100644 packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx create mode 100644 packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx create mode 100644 packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx create mode 100644 packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx create mode 100644 packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx diff --git a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx index 85fbd839dbd585..6959203ffc786e 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx @@ -1,141 +1,15 @@ import * as React from 'react'; -import { makeStyles, tokens, Input, Slider, SpinButton, RadioGroup, Radio } from '@fluentui/react-components'; -import { Combobox, Option } from '@fluentui/react-components/unstable'; +import { Input } from '@fluentui/react-components'; import { Field, FieldProps } from '@fluentui/react-field'; -import { SparkleFilled } from '@fluentui/react-icons'; -const useStyles = makeStyles({ - stack: { - display: 'inline-flex', - flexDirection: 'column', - alignItems: 'start', - rowGap: tokens.spacingVerticalM, - }, -}); - -export const Default = (props: Partial) => { - return ( - - - - ); -}; - -export const AllControls = () => { - const styles = useStyles(); - return ( -
- - - - - - - - - - - - - - - - - - - - - - -
- ); -}; - -export const LabelBefore = () => { - return ( - } - helperText="This is some help text" - > - - - ); -}; - -export const Required = () => { - return ( - - - - ); -}; - -export const Status = () => { - const styles = useStyles(); - return ( -
- - - - - - - - - - } - statusText="This is a message for a custom status" - labelPosition="before" - > - - -
- ); -}; - -export const Validation = () => { - const [value, setValue] = React.useState('error'); - - const status = value === 'error' || value === 'warning' || value === 'success' ? value : undefined; - - return ( - - setValue(data.value)}> - - - - - - - ); -}; - -export const Size = () => { - const styles = useStyles(); - return ( -
- - - - - - - - - -
- ); -}; - -export const HelperText = () => { - return ( - - - - ); -}; +export const Default = (props: Partial) => ( + + + +); diff --git a/packages/react-components/react-field/src/stories/Field/FieldDescription.md b/packages/react-components/react-field/src/stories/Field/FieldDescription.md index e69de29bb2d1d6..28d53aa2c4c101 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldDescription.md +++ b/packages/react-components/react-field/src/stories/Field/FieldDescription.md @@ -0,0 +1,3 @@ +Field is a combination of Label, a form component such as Input or Combobox, as well as status and helper text. + +Field does not handle input validation, but it does allow a validation status message to be displayed. diff --git a/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx new file mode 100644 index 00000000000000..99417d5e45ee1b --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; + +export const HelperText = () => ( + + + +); diff --git a/packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx new file mode 100644 index 00000000000000..b4aa41ec61d2fd --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { + Checkbox, + Input, + makeStyles, + Radio, + RadioGroup, + Slider, + SpinButton, + Switch, + tokens, +} from '@fluentui/react-components'; +import { Combobox, Option } from '@fluentui/react-components/unstable'; +import { Field } from '@fluentui/react-field'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '400px', + }, +}); + +export const LabelAbove = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx new file mode 100644 index 00000000000000..7c10fe22aa1179 --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { Checkbox, Input, makeStyles, Radio, RadioGroup, SpinButton, Switch, tokens } from '@fluentui/react-components'; +import { Combobox, Option } from '@fluentui/react-components/unstable'; +import { Field } from '@fluentui/react-field'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '400px', + }, +}); + +export const LabelBefore = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx new file mode 100644 index 00000000000000..1a7104bc505ade --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; + +export const Required = () => ( + + + +); diff --git a/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx new file mode 100644 index 00000000000000..0fc136034b8dba --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Input, makeStyles, tokens } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '400px', + }, +}); + +export const Size = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + +
+ ); +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx new file mode 100644 index 00000000000000..f2014a54b79137 --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Input, makeStyles, tokens } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; +import { SparkleFilled } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '400px', + }, +}); + +export const Status = () => { + const styles = useStyles(); + return ( +
+ + + + + + + + + + } + statusText="This is a custom status message, with a custom icon" + labelPosition="before" + > + + +
+ ); +}; diff --git a/packages/react-components/react-field/src/stories/Field/index.stories.tsx b/packages/react-components/react-field/src/stories/Field/index.stories.tsx index 1895ae9c92b817..17ef3475fbdd63 100644 --- a/packages/react-components/react-field/src/stories/Field/index.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/index.stories.tsx @@ -4,13 +4,12 @@ import descriptionMd from './FieldDescription.md'; import bestPracticesMd from './FieldBestPractices.md'; export { Default } from './FieldDefault.stories'; -export { AllControls } from './FieldDefault.stories'; -export { LabelBefore } from './FieldDefault.stories'; -export { Required } from './FieldDefault.stories'; -export { Status } from './FieldDefault.stories'; -export { Validation } from './FieldDefault.stories'; -export { Size } from './FieldDefault.stories'; -export { HelperText } from './FieldDefault.stories'; +export { LabelAbove } from './FieldLabelAbove.stories'; +export { LabelBefore } from './FieldLabelBefore.stories'; +export { Required } from './FieldRequired.stories'; +export { Status } from './FieldStatus.stories'; +export { Size } from './FieldSize.stories'; +export { HelperText } from './FieldHelperText.stories'; export default { title: 'Components/Field', From 03008457368dcd0db3a5b3d95403feab25ccedbc Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 02:34:11 -0700 Subject: [PATCH 12/23] Add field stories --- .../stories/Field/FieldDefault.stories.tsx | 2 +- .../stories/Field/FieldExamples.stories.tsx | 81 +++++++++++++++++++ .../stories/Field/FieldHelperText.stories.tsx | 10 ++- .../src/stories/Field/FieldLabel.stories.tsx | 17 ++++ .../stories/Field/FieldLabelAbove.stories.tsx | 59 -------------- .../Field/FieldLabelBefore.stories.tsx | 68 +++++----------- .../stories/Field/FieldRequired.stories.tsx | 10 +++ .../src/stories/Field/FieldStatus.stories.tsx | 23 ++++-- .../src/stories/Field/index.stories.tsx | 3 +- 9 files changed, 159 insertions(+), 114 deletions(-) create mode 100644 packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx create mode 100644 packages/react-components/react-field/src/stories/Field/FieldLabel.stories.tsx delete mode 100644 packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx diff --git a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx index 6959203ffc786e..810a477a60eac5 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx @@ -7,7 +7,7 @@ export const Default = (props: Partial) => ( label="Example Field" status="success" statusText="This is a success message" - helperText="Helper text should be used sparingly" + helperText="Fields can have helper text, but it should be used sparingly" {...props} > diff --git a/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx new file mode 100644 index 00000000000000..42016055fc706e --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { + Checkbox, + Input, + makeStyles, + Radio, + RadioGroup, + Slider, + SpinButton, + Switch, + tokens, +} from '@fluentui/react-components'; +import { Combobox, Option } from '@fluentui/react-components/unstable'; +import { Field } from '@fluentui/react-field'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '480px', + }, +}); + +export const Examples = () => { + const styles = useStyles(); + const [radioValue, setRadioValue] = React.useState('one'); + return ( +
+ + + + + + + + + + + + + + + setRadioValue(data.value)}> + + + + + + + + + + + + + + +
+ ); +}; + +Examples.parameters = { + docs: { + description: { + story: 'Field can be used with any form input', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx index 99417d5e45ee1b..08e9b7192cc4e4 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldHelperText.stories.tsx @@ -4,6 +4,14 @@ import { Field } from '@fluentui/react-field'; export const HelperText = () => ( - + ); + +HelperText.parameters = { + docs: { + description: { + story: 'Helper text should be used sparingly.', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldLabel.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldLabel.stories.tsx new file mode 100644 index 00000000000000..a9b4ef16c77cd0 --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldLabel.stories.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; + +export const Label = () => ( + + + +); + +Label.parameters = { + docs: { + description: { + story: 'The Field label is placed above the field component by default.', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx deleted file mode 100644 index b4aa41ec61d2fd..00000000000000 --- a/packages/react-components/react-field/src/stories/Field/FieldLabelAbove.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from 'react'; -import { - Checkbox, - Input, - makeStyles, - Radio, - RadioGroup, - Slider, - SpinButton, - Switch, - tokens, -} from '@fluentui/react-components'; -import { Combobox, Option } from '@fluentui/react-components/unstable'; -import { Field } from '@fluentui/react-field'; - -const useStyles = makeStyles({ - stack: { - display: 'inline-grid', - rowGap: tokens.spacingVerticalM, - width: '400px', - }, -}); - -export const LabelAbove = () => { - const styles = useStyles(); - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx index 7c10fe22aa1179..302d96494995fe 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx @@ -1,52 +1,26 @@ import * as React from 'react'; -import { Checkbox, Input, makeStyles, Radio, RadioGroup, SpinButton, Switch, tokens } from '@fluentui/react-components'; -import { Combobox, Option } from '@fluentui/react-components/unstable'; +import { Input } from '@fluentui/react-components'; import { Field } from '@fluentui/react-field'; -const useStyles = makeStyles({ - stack: { - display: 'inline-grid', - rowGap: tokens.spacingVerticalM, - width: '400px', - }, -}); +export const LabelBefore = () => ( + + + +); -export const LabelBefore = () => { - const styles = useStyles(); - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); +LabelBefore.storyName = 'Label position: before'; +LabelBefore.parameters = { + docs: { + description: { + story: + 'The label can be placed before the input. If multiple fields are stacked together and all the same width, ' + + 'the inputs will be vertically aligned as well.', + }, + }, }; diff --git a/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx index 1a7104bc505ade..54a000986a6bb9 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx @@ -7,3 +7,13 @@ export const Required = () => (
); + +Required.parameters = { + docs: { + description: { + story: + 'When a field is marked as `required`, the label has a red asterisk, ' + + 'and the input gets the required property for accessiblity tools.', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx index f2014a54b79137..70074f7ffd598e 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx @@ -15,19 +15,19 @@ export const Status = () => { const styles = useStyles(); return (
- + - + - + } - statusText="This is a custom status message, with a custom icon" + statusText="This status message has a custom icon" labelPosition="before" > @@ -35,3 +35,16 @@ export const Status = () => {
); }; + +Status.parameters = { + docs: { + description: { + story: + 'The `status` property modifies the appearance of the status text, and for some input types, ' + + 'an error status also applies visual indication such as a red border.' + + '
' + + 'Use the `statusText` property to display an associated message. ' + + 'You can optionally override the default icon with `statusIcon`.', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/Field/index.stories.tsx b/packages/react-components/react-field/src/stories/Field/index.stories.tsx index 17ef3475fbdd63..1b8bc70f37c105 100644 --- a/packages/react-components/react-field/src/stories/Field/index.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/index.stories.tsx @@ -4,12 +4,13 @@ import descriptionMd from './FieldDescription.md'; import bestPracticesMd from './FieldBestPractices.md'; export { Default } from './FieldDefault.stories'; -export { LabelAbove } from './FieldLabelAbove.stories'; +export { Label } from './FieldLabel.stories'; export { LabelBefore } from './FieldLabelBefore.stories'; export { Required } from './FieldRequired.stories'; export { Status } from './FieldStatus.stories'; export { Size } from './FieldSize.stories'; export { HelperText } from './FieldHelperText.stories'; +export { Examples } from './FieldExamples.stories'; export default { title: 'Components/Field', From f2ea3ac81e083ac92fb33c1b4c04bfc4c554a6c5 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 02:34:38 -0700 Subject: [PATCH 13/23] Update label-before styles --- .../react-field/src/components/Field/useFieldStyles.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts index 922fcc098896cf..0bb12776453e37 100644 --- a/packages/react-components/react-field/src/components/Field/useFieldStyles.ts +++ b/packages/react-components/react-field/src/components/Field/useFieldStyles.ts @@ -40,6 +40,8 @@ const useLabelStyles = makeStyles({ gridRowStart: '1', gridRowEnd: '-1', marginRight: tokens.spacingHorizontalM, + alignSelf: 'start', + justifySelf: 'stretch', }, }); From dd626b24b8bb2f0dbe70ee49ca41c9245ae44a10 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 02:40:04 -0700 Subject: [PATCH 14/23] Fix conformance test, update snapshots and api.md --- .../react-field/etc/react-field.api.md | 11 ++++------- .../react-field/src/components/Field/Field.test.tsx | 2 +- .../Field/__snapshots__/Field.test.tsx.snap | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) 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 9c30240d317cf1..5557b5cdd24924 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -61,14 +61,14 @@ export type FieldState = ComponentState> & Pick(size: FieldProps['size'], supportedSizes: SupportedSizes[]) => SupportedSizes | undefined; + // @public export const renderField_unstable: (state: FieldState, contextValues: FieldContextValues) => JSX.Element; // @public -export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; - -// @public (undocumented) -export const useFieldChildProps_unstable: (options: UseFieldChildPropsOptions>) => FieldChildProps | undefined; +export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; // @public (undocumented) export const useFieldContext: (selector: ContextSelector> | undefined, T>) => T; @@ -79,9 +79,6 @@ export const useFieldContextValues: (state: FieldState) => FieldContextValues; // @public export const useFieldStyles_unstable: (state: FieldState) => void; -// @public (undocumented) -export const useMergedFieldProps_unstable: >(props: Props, options: UseFieldChildPropsOptions) => Props; - // (No @packageDocumentation comment for this package) ``` 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 fd5ca8cc0df312..057470148ae897 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 @@ -9,7 +9,7 @@ describe('Field', () => { Component: Field, displayName: 'Field', requiredProps: { - children: , + children: , }, disabledTests: ['component-has-static-classname-exported'], // TODO remove this testOptions: { diff --git a/packages/react-components/react-field/src/components/Field/__snapshots__/Field.test.tsx.snap b/packages/react-components/react-field/src/components/Field/__snapshots__/Field.test.tsx.snap index 7126776f33687d..72f05814ab3c68 100644 --- a/packages/react-components/react-field/src/components/Field/__snapshots__/Field.test.tsx.snap +++ b/packages/react-components/react-field/src/components/Field/__snapshots__/Field.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Field renders a default state 1`] = `
- Default Field +
`; From 7a8e8c62b97321c7fb8ed7a022ec60e7d78490f2 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 02:42:44 -0700 Subject: [PATCH 15/23] Remove unused conformance test --- .../react-field/src/components/Field/Field.test.tsx | 1 - 1 file changed, 1 deletion(-) 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 057470148ae897..954f0e646a003b 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 @@ -11,7 +11,6 @@ describe('Field', () => { requiredProps: { children: , }, - disabledTests: ['component-has-static-classname-exported'], // TODO remove this testOptions: { 'has-static-classnames': [ { From db6f1331acad501b6c22c48bc17c710692888e56 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 02:48:30 -0700 Subject: [PATCH 16/23] Delete unused useFieldChildProps.ts --- .../src/contexts/useFieldChildProps.ts | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 packages/react-components/react-field/src/contexts/useFieldChildProps.ts diff --git a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts b/packages/react-components/react-field/src/contexts/useFieldChildProps.ts deleted file mode 100644 index 70c7b9844c617f..00000000000000 --- a/packages/react-components/react-field/src/contexts/useFieldChildProps.ts +++ /dev/null @@ -1,47 +0,0 @@ -export {}; -// import { FieldProps } from '../Field'; -// import { filterFieldSize } from '../util/filterFieldSize'; -// import { useFieldContext } from './FieldContext'; - -// export type UseFieldChildPropsOptions> = { -// supportedSizes: (NonNullable & NonNullable)[]; -// }; - -// export type FieldChildProps = { -// id?: string; -// required?: boolean; -// 'aria-labelledby'?: string; -// size?: SizeValues; -// }; - -// export const useFieldChildProps_unstable = ( -// options: UseFieldChildPropsOptions>, -// ) => { -// const props: FieldChildProps = { -// id: useFieldContext(ctx => ctx?.childId), -// 'aria-labelledby': useFieldContext(ctx => ctx?.labelId), -// required: useFieldContext(ctx => ctx?.required), -// size: useFieldContext(ctx => filterFieldSize(ctx?.size, options.supportedSizes)), -// }; - -// if (useFieldContext(ctx => ctx === undefined)) { -// return undefined; -// } - -// return props; -// }; - -// export const useMergedFieldProps_unstable = >( -// props: Props, -// options: UseFieldChildPropsOptions, -// ): Props => { -// const propsFromField = { -// id: useFieldContext(ctx => ctx?.childId), -// 'aria-labelledby': useFieldContext(ctx => ctx?.labelId), -// required: useFieldContext(ctx => ctx?.required), -// size: useFieldContext(ctx => filterFieldSize(ctx?.size, options.supportedSizes)), -// }; - -// const hasFieldContext = useFieldContext(ctx => ctx !== undefined); -// return hasFieldContext ? { ...propsFromField, ...props } : props; -// }; From 90dae41638326c8c983ddc4d69f27a4468b23b86 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 02:54:21 -0700 Subject: [PATCH 17/23] syncpack --- packages/react-components/react-field/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-field/package.json b/packages/react-components/react-field/package.json index 2c8966819d6760..8b348dff65651e 100644 --- a/packages/react-components/react-field/package.json +++ b/packages/react-components/react-field/package.json @@ -34,7 +34,7 @@ "dependencies": { "@fluentui/react-context-selector": "^9.0.2", "@fluentui/react-icons": "^2.0.175", - "@fluentui/react-label": "^9.0.3", + "@fluentui/react-label": "^9.0.4", "@fluentui/react-theme": "^9.0.0", "@fluentui/react-utilities": "^9.0.2", "@griffel/react": "^1.2.3", From 06d68bdda2a28e999bfdbe626d7445430f66e3c2 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 03:24:09 -0700 Subject: [PATCH 18/23] Fix imports for stories --- .../react-field/src/stories/Field/FieldExamples.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx index 42016055fc706e..8a64eeacae09f3 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx @@ -10,7 +10,7 @@ import { Switch, tokens, } from '@fluentui/react-components'; -import { Combobox, Option } from '@fluentui/react-components/unstable'; +import { Combobox, Option } from '@fluentui/react-combobox'; import { Field } from '@fluentui/react-field'; const useStyles = makeStyles({ From d3e84234eccc87eb405e6045145c453254d8561e Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 12:43:44 -0700 Subject: [PATCH 19/23] Add htmlFor prop to Field --- .../src/components/Field/Field.types.ts | 21 ++++++++++++++---- .../src/components/Field/useField.tsx | 22 ++++++++++++------- .../src/contexts/useFieldContextValues.ts | 4 ++-- 3 files changed, 33 insertions(+), 14 deletions(-) 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 3f42508baf08f9..6b49a3d4395c2b 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 @@ -69,6 +69,17 @@ export type FieldProps = Omit>, 'children'> & * @default undefined */ status?: 'error' | 'warning' | 'success'; + + /** + * The ID of the form component in this Field (the child of the Field). + * + * `htmlFor` will default to the `id` property of the Field's child, if set. + * Otherwise, it will default to `generatedChildId` on the FieldContext. + * + * In most cases, it isn't necessary to set this property. It only needs to be set if the child component + * doesn't assign its ID via an `id` prop, and doesn't use FieldContext (such as a component from another library). + */ + htmlFor?: string; }; /** @@ -78,11 +89,11 @@ export type FieldState = ComponentState> & Pick & Required> & { /** - * The ID that the child component should use to be sure that it is properly associated with the label. + * The generated ID used as the label's htmlFor prop. * - * This will default to the `id` prop of the child, or use a generated ID if the child has no `id` prop. + * This will be undefined if either (a) the Field's htmlFor was set, or (b) the child has an id property set. */ - childId: string | undefined; + generatedChildId: string | undefined; /** * The (generated) ID of the field's label component. @@ -92,7 +103,9 @@ export type FieldState = ComponentState> & labelId: string | undefined; }; -export type FieldContextValue = Readonly>; +export type FieldContextValue = Readonly< + Pick +>; export type FieldContextValues = { field: FieldContextValue; 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 c18ba192e66827..3ba0d7cc2e61bc 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -14,16 +14,21 @@ import { getNativeElementProps, resolveShorthand, useId, useMergedRefs } from '@ * @param ref - reference to root HTMLElement of Field */ export const useField_unstable = (props: FieldProps, ref: React.Ref): FieldState => { - const { labelPosition = 'above', required, size = 'medium', status } = props; - + const generatedChildId = useId('field__input-'); const child = React.Children.only(props.children); - const childProps = React.isValidElement(child) ? child.props : undefined; - const childId = useId('field__input-', childProps?.id); + + const { + htmlFor = child?.props?.id || generatedChildId, + labelPosition = 'above', + required, + size = 'medium', + status, + } = props; const label = resolveShorthand(props.label, { defaultProps: { id: useId('field__label-'), - htmlFor: childId, + htmlFor, required, size, }, @@ -44,7 +49,8 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref { // eslint-disable-next-line no-console console.error( `Field with label "${labelText}" requires the label be associated with its input. Try one of these fixes:\n` + - '1. Use a component that gets its ID from FieldContext.childId (e.g. the form controls in this library).\n' + + '1. Use a control that sets its ID from FieldContext.generatedChildId (form controls in this library).\n' + '2. Or, set the `id` prop of the child of field.\n' + - '3. Or, set `label={{ htmlFor: ... }}` to the ID used by the field component.\n' + + '3. Or, set `htmlFor` to the ID used by the field component.\n' + '4. Or, set `label={{ id: ... }}` to the `aria-labelledby` prop of the field component.\n', ); } diff --git a/packages/react-components/react-field/src/contexts/useFieldContextValues.ts b/packages/react-components/react-field/src/contexts/useFieldContextValues.ts index 35037554f9b420..27fe2d5cfd680e 100644 --- a/packages/react-components/react-field/src/contexts/useFieldContextValues.ts +++ b/packages/react-components/react-field/src/contexts/useFieldContextValues.ts @@ -1,8 +1,8 @@ import type { FieldContextValues, FieldState } from '../Field'; export const useFieldContextValues = (state: FieldState): FieldContextValues => { - const { size, required, status, labelId, childId } = state; + const { size, required, status, labelId, generatedChildId } = state; return { - field: { size, required, status, labelId, childId }, + field: { size, required, status, labelId, generatedChildId }, }; }; From 3b44699b84827eb829f8506800d8fa20f7533831 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 12:44:39 -0700 Subject: [PATCH 20/23] Update stories --- .../stories/Field/FieldExamples.stories.tsx | 28 +++++++------------ .../src/stories/Field/FieldSize.stories.tsx | 22 ++++++++++++--- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx index 8a64eeacae09f3..8eaf58c296d24f 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx @@ -29,9 +29,6 @@ export const Examples = () => { - - - @@ -42,8 +39,8 @@ export const Examples = () => { setRadioValue(data.value)}> @@ -51,22 +48,17 @@ export const Examples = () => { - - + + - - + + - + + + + ); diff --git a/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx index 0fc136034b8dba..623642f7c998f3 100644 --- a/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx +++ b/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { Input, makeStyles, tokens } from '@fluentui/react-components'; +import { Input, makeStyles, Slider, tokens } from '@fluentui/react-components'; +import { Combobox, Option } from '@fluentui/react-combobox'; import { Field } from '@fluentui/react-field'; const useStyles = makeStyles({ @@ -14,15 +15,28 @@ export const Size = () => { const styles = useStyles(); return (
- + - + + + + - + + + + + + + + + + +
); }; From f5eca606f52582dcd6c0c3d3b578df0120993537 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 13:26:49 -0700 Subject: [PATCH 21/23] Update api.md --- .../react-components/react-field/etc/react-field.api.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 5557b5cdd24924..dfc30e5b686c2c 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -25,7 +25,7 @@ export const Field: ForwardRefComponent; export const fieldClassNames: SlotClassNames; // @public (undocumented) -export type FieldContextValue = Readonly>; +export type FieldContextValue = Readonly>; // @public (undocumented) export type FieldContextValues = { @@ -41,10 +41,11 @@ export type FieldProps = Omit>, 'children'> & required?: boolean; size?: 'small' | 'medium' | 'large'; status?: 'error' | 'warning' | 'success'; + htmlFor?: string; }; // @public (undocumented) -export const FieldProvider: Provider> | undefined> & FC> | undefined>>; +export const FieldProvider: Provider> | undefined> & FC> | undefined>>; // @public (undocumented) export type FieldSlots = { @@ -57,7 +58,7 @@ export type FieldSlots = { // @public export type FieldState = ComponentState> & Pick & Required> & { - childId: string | undefined; + generatedChildId: string | undefined; labelId: string | undefined; }; @@ -71,7 +72,7 @@ export const renderField_unstable: (state: FieldState, contextValues: FieldConte export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; // @public (undocumented) -export const useFieldContext: (selector: ContextSelector> | undefined, T>) => T; +export const useFieldContext: (selector: ContextSelector> | undefined, T>) => T; // @public (undocumented) export const useFieldContextValues: (state: FieldState) => FieldContextValues; From f155c49882bddc1e0a20ad0ac3dc84c563bd3bb9 Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Thu, 4 Aug 2022 17:14:54 -0700 Subject: [PATCH 22/23] Comment out debug check until #24236 is fixed --- .../src/components/Field/useField.tsx | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) 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 3ba0d7cc2e61bc..69f797f2d400ed 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -34,10 +34,10 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref { - const labelFor = label?.htmlFor; - const labelId = label?.id; - const labelText = label?.children; - const rootRef = React.useRef(null); +// TODO https://github.com/microsoft/fluentui/issues/24236: re-enable check once FieldContext is hooked up +// const useLabelDebugCheck = (label: FieldState['label']) => { +// const labelFor = label?.htmlFor; +// const labelId = label?.id; +// const labelText = label?.children; +// const rootRef = React.useRef(null); - React.useEffect(() => { - if (!rootRef.current || !labelText || !labelFor || !labelId) { - return; - } +// React.useEffect(() => { +// if (!rootRef.current || !labelText || !labelFor || !labelId) { +// return; +// } - if (!rootRef.current.querySelector(`[id='${labelFor}'], [aria-labelledby='${labelId}']`)) { - // eslint-disable-next-line no-console - console.error( - `Field with label "${labelText}" requires the label be associated with its input. Try one of these fixes:\n` + - '1. Use a control that sets its ID from FieldContext.generatedChildId (form controls in this library).\n' + - '2. Or, set the `id` prop of the child of field.\n' + - '3. Or, set `htmlFor` to the ID used by the field component.\n' + - '4. Or, set `label={{ id: ... }}` to the `aria-labelledby` prop of the field component.\n', - ); - } - }, [labelFor, labelId, labelText]); +// if (!rootRef.current.querySelector(`[id='${labelFor}'], [aria-labelledby='${labelId}']`)) { +// // eslint-disable-next-line no-console +// console.error( +// `Field with label "${labelText}" requires the label be associated with its input. Try one of these fixes:\n`+ +// '1. Use a control that sets its ID from FieldContext.generatedChildId (form controls in this library).\n' + +// '2. Or, set the `id` prop of the child of field.\n' + +// '3. Or, set `htmlFor` to the ID used by the field component.\n' + +// '4. Or, set `label={{ id: ... }}` to the `aria-labelledby` prop of the field component.\n', +// ); +// } +// }, [labelFor, labelId, labelText]); - return rootRef; -}; +// return rootRef; +// }; From 27b6a82ad56102e4ff22e2b4b7f6d5ed99ce448d Mon Sep 17 00:00:00 2001 From: Ben Howell Date: Fri, 5 Aug 2022 09:02:57 -0700 Subject: [PATCH 23/23] Fix build error --- .../react-field/src/components/Field/useField.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 69f797f2d400ed..ad29f22d837930 100644 --- a/packages/react-components/react-field/src/components/Field/useField.tsx +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import type { FieldProps, FieldState } from './Field.types'; import { CheckmarkCircle12Filled, ErrorCircle12Filled, Warning12Filled } from '@fluentui/react-icons'; import { Label } from '@fluentui/react-label'; -import { getNativeElementProps, resolveShorthand, useId, useMergedRefs } from '@fluentui/react-utilities'; +import { getNativeElementProps, resolveShorthand, useId } from '@fluentui/react-utilities'; /** * Create the state required to render Field. @@ -34,6 +34,7 @@ export const useField_unstable = (props: FieldProps, ref: React.Ref