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 4833ea08b380e..dfc30e5b686c2 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,63 @@ 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<{ + id?: string; + }>; + labelPosition?: 'above' | 'before'; + required?: boolean; + size?: 'small' | 'medium' | 'large'; + status?: 'error' | 'warning' | 'success'; + htmlFor?: string; +}; + +// @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> & Pick & Required> & { + generatedChildId: string | undefined; + labelId: string | undefined; +}; + +// @public (undocumented) +export const filterFieldSize: (size: FieldProps['size'], supportedSizes: SupportedSizes[]) => SupportedSizes | 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; +export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; + +// @public (undocumented) +export const useFieldContext: (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 3a0b6f8773d85..c6e239b6900b4 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.4", "@fluentui/react-theme": "^9.0.0", "@fluentui/react-utilities": "^9.0.2", "@griffel/react": "^1.3.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 7f09ee09b58c6..954f0e646a003 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,44 @@ 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: , + }, + 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 46d8d9cfc9629..f6086ac44c7c8 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 949bf781eb4ff..6b49a3d4395c2 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,112 @@ +import { Label } from '@fluentui/react-label'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import * as React from 'react'; export type FieldSlots = { - root: Slot<'div'>; + 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'>; }; /** * Field Props */ -export type FieldProps = ComponentProps & {}; +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'; + + /** + * 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; +}; /** * 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> & + Pick & + Required> & { + /** + * The generated ID used as the label's htmlFor prop. + * + * This will be undefined if either (a) the Field's htmlFor was set, or (b) the child has an id property set. + */ + generatedChildId: 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; + }; + +export type FieldContextValue = Readonly< + Pick +>; + +export type FieldContextValues = { + field: FieldContextValue; +}; 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 7126776f33687..72f05814ab3c6 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 +
`; 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 858515b2b4fbb..687605d0ab339 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 f5f26387f6def..0000000000000 --- 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 0000000000000..ad29f22d83793 --- /dev/null +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -0,0 +1,108 @@ +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 } from '@fluentui/react-utilities'; + +/** + * 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 generatedChildId = useId('field__input-'); + const child = React.Children.only(props.children); + + const { + htmlFor = child?.props?.id || generatedChildId, + labelPosition = 'above', + required, + size = 'medium', + status, + } = props; + + const label = resolveShorthand(props.label, { + defaultProps: { + id: useId('field__label-'), + htmlFor, + required, + size, + }, + }); + + // TODO https://github.com/microsoft/fluentui/issues/24236: re-enable check once FieldContext is hooked up + // 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') { + defaultStatusIcon = ; + } else if (status === 'warning') { + defaultStatusIcon = ; + } else if (status === 'success') { + defaultStatusIcon = ; + } + + return { + // Only pass the generatedChildId if it is the one that was used for the htmlFor prop + generatedChildId: htmlFor === generatedChildId ? generatedChildId : undefined, + labelId: label?.id, + labelPosition, + required, + size, + status, + components: { + root: 'div', + label: Label, + statusText: 'span', + statusIcon: 'span', + helperText: 'span', + }, + root: getNativeElementProps('div', { + ...props, + ref, + }), + label, + statusIcon: resolveShorthand(props.statusIcon, { + required: !!defaultStatusIcon, + defaultProps: { + children: defaultStatusIcon, + }, + }), + statusText: resolveShorthand(props.statusText), + helperText: resolveShorthand(props.helperText), + }; +}; + +// 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; +// } + +// 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; +// }; 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 9efd955895889..0bb12776453e3 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,128 @@ 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', }; /** * Styles for the root slot */ -const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element +const useRootStyles = makeStyles({ + base: { + display: 'inline-grid', + gridAutoFlow: 'row', + justifyItems: 'start', }, - // TODO add additional classes for different states and/or slots + labelBefore: { + gridTemplateRows: 'repeat(4, auto)', + gridTemplateColumns: '1fr 2fr', + [`> :not(.${fieldClassNames.label})`]: { + gridColumnStart: '2', + }, + }, +}); + +const useLabelStyles = makeStyles({ + base: { + marginTop: tokens.spacingVerticalXXS, + marginBottom: tokens.spacingVerticalXXS, + }, + + before: { + gridRowStart: '1', + gridRowEnd: '-1', + marginRight: tokens.spacingHorizontalM, + alignSelf: 'start', + justifySelf: 'stretch', + }, +}); + +const useSecondaryTextStyles = makeStyles({ + base: { + marginTop: tokens.spacingVerticalXXS, + color: tokens.colorNeutralForeground3, + ...typographyStyles.caption1, + }, + + error: { + color: tokens.colorPaletteRedForeground1, + }, +}); + +const useStatusIconStyles = makeStyles({ + base: { + fontSize: '12px', + lineHeight: '12px', + verticalAlign: 'middle', + marginRight: tokens.spacingHorizontalXS, + }, + + error: { + color: tokens.colorPaletteRedForeground1, + }, + warning: { + color: tokens.colorPaletteDarkOrangeForeground1, + }, + success: { + color: tokens.colorPaletteGreenForeground1, + }, }); /** * 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, + state.labelPosition === 'before' && rootStyles.labelBefore, + state.root.className, + ); + + const labelStyles = useLabelStyles(); + if (state.label) { + state.label.className = mergeClasses( + fieldClassNames.label, + labelStyles.base, + state.labelPosition === 'before' && labelStyles.before, + state.label.className, + ); + } + + const statusIconStyles = useStatusIconStyles(); + if (state.statusIcon) { + state.statusIcon.className = mergeClasses( + fieldClassNames.statusIcon, + statusIconStyles.base, + !!state.status && statusIconStyles[state.status], + state.statusIcon.className, + ); + } - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + const secondaryTextStyles = useSecondaryTextStyles(); + if (state.statusText) { + state.statusText.className = mergeClasses( + fieldClassNames.statusText, + secondaryTextStyles.base, + state.status === 'error' && secondaryTextStyles.error, + state.statusText.className, + ); + } - return state; + if (state.helperText) { + state.helperText.className = mergeClasses( + fieldClassNames.helperText, + secondaryTextStyles.base, + 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 0000000000000..e76e386ab09bb --- /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 = (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 0000000000000..c32893bd9b267 --- /dev/null +++ b/packages/react-components/react-field/src/contexts/index.ts @@ -0,0 +1,3 @@ +export * from './FieldContext'; +export * from './useFieldContextValues'; +// export * from './useFieldChildProps'; 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 0000000000000..27fe2d5cfd680 --- /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, generatedChildId } = state; + return { + field: { size, required, status, labelId, generatedChildId }, + }; +}; diff --git a/packages/react-components/react-field/src/index.ts b/packages/react-components/react-field/src/index.ts index f40e2c50f7b06..7c94ca46daa30 100644 --- a/packages/react-components/react-field/src/index.ts +++ b/packages/react-components/react-field/src/index.ts @@ -1,3 +1,4 @@ -// 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 } from './contexts/index'; +export { filterFieldSize } from './util/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 b8a93a1dfb9f6..810a477a60eac 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,15 @@ import * as React from 'react'; +import { Input } from '@fluentui/react-components'; import { Field, FieldProps } from '@fluentui/react-field'; -export const Default = (props: Partial) => ; +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 e69de29bb2d1d..28d53aa2c4c10 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/FieldExamples.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx new file mode 100644 index 0000000000000..8eaf58c296d24 --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldExamples.stories.tsx @@ -0,0 +1,73 @@ +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-combobox'; +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 new file mode 100644 index 0000000000000..08e9b7192cc4e --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldHelperText.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 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 0000000000000..a9b4ef16c77cd --- /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/FieldLabelBefore.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx new file mode 100644 index 0000000000000..302d96494995f --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldLabelBefore.stories.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; + +export const LabelBefore = () => ( + + + +); + +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 new file mode 100644 index 0000000000000..54a000986a6bb --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldRequired.stories.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-components'; +import { Field } from '@fluentui/react-field'; + +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/FieldSize.stories.tsx b/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx new file mode 100644 index 0000000000000..623642f7c998f --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldSize.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +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({ + 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 0000000000000..70074f7ffd598 --- /dev/null +++ b/packages/react-components/react-field/src/stories/Field/FieldStatus.stories.tsx @@ -0,0 +1,50 @@ +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 status message has a custom icon" + labelPosition="before" + > + + +
+ ); +}; + +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 650828caa907f..1b8bc70f37c10 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,13 @@ import descriptionMd from './FieldDescription.md'; import bestPracticesMd from './FieldBestPractices.md'; export { Default } from './FieldDefault.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', 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 0000000000000..96fa63278fafe --- /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 0000000000000..696e923e06c46 --- /dev/null +++ b/packages/react-components/react-field/src/util/index.ts @@ -0,0 +1 @@ +export * from './filterFieldSize';