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..aaccee2cfc2db6 100644 --- a/packages/react-components/react-field/etc/react-field.api.md +++ b/packages/react-components/react-field/etc/react-field.api.md @@ -4,41 +4,69 @@ ```ts +/// + import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { Input } from '@fluentui/react-input'; +import { Label } from '@fluentui/react-label'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; -import type { SlotClassNames } from '@fluentui/react-utilities'; +import { SlotClassNames } from '@fluentui/react-utilities'; +import type { SlotRenderFunction } from '@fluentui/react-utilities'; +import type { SlotShorthandValue } from '@fluentui/react-utilities'; + +// @public +export type FieldProps = ComponentProps>, 'control'> & { + orientation?: 'vertical' | 'horizontal'; + validationState?: 'error' | 'warning' | 'success'; +}; + +// @public +export type FieldSlots = { + root: NonNullable>; + control: SlotComponent; + label?: Slot; + validationMessage?: Slot<'span'>; + validationMessageIcon?: Slot<'span'>; + hint?: Slot<'span'>; +}; // @public -export const Field: ForwardRefComponent; +export type FieldState = ComponentState>> & Pick, 'orientation' | 'validationState'> & { + classNames: SlotClassNames>; +}; // @public (undocumented) -export const fieldClassName = "fui-Field"; +export const getFieldClassNames: (name: string) => SlotClassNames>; // @public (undocumented) -export const fieldClassNames: SlotClassNames; +export const InputField: ForwardRefComponent; -// @public -export type FieldProps = ComponentProps & {}; +// @public (undocumented) +export const inputFieldClassNames: SlotClassNames>; // @public (undocumented) -export type FieldSlots = { - root: Slot<'div'>; -}; +export type InputFieldProps = FieldProps; // @public -export type FieldState = ComponentState; +export const renderField_unstable: (state: FieldState) => JSX.Element; // @public -export const renderField_unstable: (state: FieldState) => JSX.Element; +export const useField_unstable: (params: UseFieldParams) => FieldState; -// @public -export const useField_unstable: (props: FieldProps, ref: React_2.Ref) => FieldState; +// @public (undocumented) +export type UseFieldParams = { + props: FieldProps & OptionalFieldComponentProps; + ref: React_2.Ref; + component: T; + classNames: SlotClassNames>; + labelConnection?: 'htmlFor' | 'aria-labelledby'; +}; // @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 3a0b6f8773d859..3e756c1fa68e34 100644 --- a/packages/react-components/react-field/package.json +++ b/packages/react-components/react-field/package.json @@ -32,6 +32,10 @@ "@fluentui/scripts": "^1.0.0" }, "dependencies": { + "@fluentui/react-context-selector": "^9.0.2", + "@fluentui/react-icons": "^2.0.175", + "@fluentui/react-input": "^9.0.4", + "@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/InputField.ts b/packages/react-components/react-field/src/InputField.ts new file mode 100644 index 00000000000000..a6548ce9f2f9dd --- /dev/null +++ b/packages/react-components/react-field/src/InputField.ts @@ -0,0 +1 @@ +export * from './components/InputField/index'; 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 deleted file mode 100644 index 7f09ee09b58c60..00000000000000 --- a/packages/react-components/react-field/src/components/Field/Field.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import { Field } from './Field'; -import { isConformant } from '../../common/isConformant'; - -describe('Field', () => { - isConformant({ - Component: Field, - displayName: 'Field', - }); - - // TODO add more tests here, and create visual regression tests in /apps/vr-tests - - it('renders a default state', () => { - const result = render(Default Field); - 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 deleted file mode 100644 index 46d8d9cfc96291..00000000000000 --- a/packages/react-components/react-field/src/components/Field/Field.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -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'; - -/** - * Field component - TODO: add more docs - */ -export const Field: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useField_unstable(props, ref); - - useFieldStyles_unstable(state); - return renderField_unstable(state); -}); - -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..00b40ba45174fe 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,101 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import * as React from 'react'; +import { Label } from '@fluentui/react-label'; +import type { ComponentProps, ComponentState, Slot, SlotClassNames } from '@fluentui/react-utilities'; +import type { SlotComponent } from './SlotComponent.types'; -export type FieldSlots = { - root: Slot<'div'>; +/** + * The minimum requirement for a component used by Field. + * + * Note: the use of VoidFunctionComponent means that component is not *required* to have a children prop, + * but it is still allowed to have a children prop. + */ +export type FieldComponent = React.VoidFunctionComponent< + Pick< + React.HTMLAttributes, + 'id' | 'className' | 'style' | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' | 'aria-errormessage' + > +>; + +/** + * Slots added by Field + */ +export type FieldSlots = { + root: NonNullable>; + + /** + * The underlying component wrapped by this field. + * + * This is the PRIMARY slot: all intrinsic HTML properties will be applied to this slot, + * except `className` and `style`, which remain on the root slot. + */ + control: SlotComponent; + + /** + * The label associated with the field. + */ + label?: Slot; + + /** + * A message about the validation state. The appearance of the `validationMessage` depends on `validationState`. + */ + validationMessage?: Slot<'span'>; + + /** + * The icon associated with the `validationMessage`. If the `validationState` prop is set, this will default to an + * icon corresponding to that state. + * + * This will only be displayed if `validationMessage` is set. + */ + validationMessageIcon?: Slot<'span'>; + + /** + * Additional hint text below the field. + */ + hint?: Slot<'span'>; }; /** * Field Props */ -export type FieldProps = ComponentProps & {}; +export type FieldProps = ComponentProps>, 'control'> & { + /** + * The orientation of the label relative to the field component. + * This only affects the label, and not the validationMessage or hint (which always appear below the field component). + * + * @default vertical + */ + orientation?: 'vertical' | 'horizontal'; + + /** + * The `validationState` affects the color of the `validationMessage`, the `validationMessageIcon`, and for some + * field components, an `validationState="error"` causes the border to become red. + * + * @default undefined + */ + validationState?: 'error' | 'warning' | 'success'; +}; + +/** + * Props that are supported by Field, but not required to be supported by the component that implements field. + */ +export type OptionalFieldComponentProps = { + /** + * Whether the field label should be marked as required. + */ + required?: boolean; + + /** + * Size of the field label. + * + * Number sizes will be ignored, but are allowed because the HTML `` element has a prop `size?: number`. + */ + size?: 'small' | 'medium' | 'large' | number; +}; /** * 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, 'orientation' | 'validationState'> & { + classNames: SlotClassNames>; + }; diff --git a/packages/react-components/react-field/src/components/Field/SlotComponent.types.ts b/packages/react-components/react-field/src/components/Field/SlotComponent.types.ts new file mode 100644 index 00000000000000..9b90a044a10a6d --- /dev/null +++ b/packages/react-components/react-field/src/components/Field/SlotComponent.types.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import type { SlotShorthandValue, SlotRenderFunction } from '@fluentui/react-utilities'; + +// +// TEMPORARY definition of the SlotComponent type, until it is available from react-utilities +// + +export type SlotComponent = WithSlotShorthandValue< + Type extends React.ComponentType + ? // If this is a VoidFunctionComponent that doesn't allow children, add { children?: never } + WithSlotRenderFunction + : never +>; + +// +// TEMPORARY copied versions of the non-exported helper types from react-utilities +// + +type WithSlotShorthandValue = + | Props + | Extract; + +type WithSlotRenderFunction = Props & { + children?: Props['children'] | SlotRenderFunction; +}; 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 deleted file mode 100644 index 7126776f33687d..00000000000000 --- a/packages/react-components/react-field/src/components/Field/__snapshots__/Field.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Field renders a default state 1`] = ` -
-
- Default Field -
-
-`; diff --git a/packages/react-components/react-field/src/components/Field/index.ts b/packages/react-components/react-field/src/components/Field/index.ts index 32e3d5f549d7c8..e2b74fd17a2853 100644 --- a/packages/react-components/react-field/src/components/Field/index.ts +++ b/packages/react-components/react-field/src/components/Field/index.ts @@ -1,4 +1,3 @@ -export * from './Field'; export * from './Field.types'; export * from './renderField'; export * from './useField'; 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..c3a5c6e2577ff4 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,25 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utilities'; -import type { FieldState, FieldSlots } from './Field.types'; +import type { FieldComponent, FieldSlots, FieldState } from './Field.types'; /** * Render the final JSX of Field */ -export const renderField_unstable = (state: FieldState) => { - const { slots, slotProps } = getSlots(state); +export const renderField_unstable = (state: FieldState) => { + const { slots, slotProps } = getSlots>(state as FieldState); - // TODO Add additional slots in the appropriate place - return ; + return ( + + {slots.label && } + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {slots.control && } + {slots.validationMessage && ( + + {slots.validationMessageIcon && } + {slotProps.validationMessage.children} + + )} + {slots.hint && } + + ); }; 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..3d56d18d5e45d4 --- /dev/null +++ b/packages/react-components/react-field/src/components/Field/useField.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import type { FieldComponent, FieldProps, FieldSlots, FieldState, OptionalFieldComponentProps } from './Field.types'; +import { CheckmarkCircle12Filled, ErrorCircle12Filled, Warning12Filled } from '@fluentui/react-icons'; +import { Label } from '@fluentui/react-label'; +import { getNativeElementProps, resolveShorthand, SlotClassNames, useId } from '@fluentui/react-utilities'; + +const validationMessageIcons = { + error: , + warning: , + success: , +} as const; + +/** + * Merge two possibly-undefined IDs for aria-describedby. If both IDs are defined, combines + * them into a string separated by a space. Otherwise, returns just the defined ID (if any). + */ +const mergeAriaDescribedBy = (a?: string, b?: string) => (a && b ? `${a} ${b}` : a || b); + +/** + * Partition the props used by the Field itself, from the props that are passed to the underlying field component. + */ +export const getPartitionedFieldProps = >(props: Props) => { + const { + className, + control, + hint, + label, + orientation, + root, + style, + validationMessage, + validationMessageIcon, + validationState, + ...restOfProps + } = props; + + const fieldProps = { + className, + control, + hint, + label, + orientation, + root, + style, + validationMessage, + validationMessageIcon, + validationState, + }; + + return [fieldProps, restOfProps] as const; +}; + +export type UseFieldParams = { + /** + * Props passed to this Field + */ + props: FieldProps & OptionalFieldComponentProps; + + /** + * Ref to be passed to the control slot (primary slot) + */ + ref: React.Ref; + + /** + * The underlying input component that this field is wrapping. + */ + component: T; + + /** + * Class names for this component, created by `getFieldClassNames`. + */ + classNames: SlotClassNames>; + + /** + * How the label be connected to the control. + * * htmlFor - Set the Label's htmlFor prop to the component's ID (and generate an ID if not provided). + * This is the preferred method for components that use the underlying tag. + * * aria-labelledby - Set the component's aria-labelledby prop to the Label's ID. Use this for components + * that are not directly elements (such as RadioGroup). + * + * @default htmlFor + */ + labelConnection?: 'htmlFor' | 'aria-labelledby'; +}; + +/** + * 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 params - Configuration parameters for this Field + */ +export const useField_unstable = (params: UseFieldParams): FieldState => { + const [props, controlProps] = getPartitionedFieldProps(params.props); + + const baseId = useId('field-'); + + const { orientation = 'vertical', validationState } = props; + + const root = resolveShorthand(props.root, { + required: true, + defaultProps: getNativeElementProps('div', props), + }); + + const label = resolveShorthand(props.label, { + defaultProps: { + id: baseId + '__label', + required: controlProps.required, + size: typeof controlProps.size === 'string' ? controlProps.size : undefined, + // htmlFor is set below + }, + }); + + const validationMessage = resolveShorthand(props.validationMessage, { + defaultProps: { + id: baseId + '__validationMessage', + }, + }); + + const hint = resolveShorthand(props.hint, { + defaultProps: { + id: baseId + '__hint', + }, + }); + + const validationMessageIcon = resolveShorthand(props.validationMessageIcon, { + required: !!validationState, + defaultProps: { + children: validationState ? validationMessageIcons[validationState] : undefined, + }, + }); + + const { labelConnection = 'htmlFor' } = params; + const hasError = validationState === 'error'; + + const control = resolveShorthand(props.control, { + required: true, + defaultProps: { + ref: params.ref, + // Add a default ID only if required for label's htmlFor prop + id: label && labelConnection === 'htmlFor' ? baseId + '__control' : undefined, + // Add aria-labelledby only if not using the label's htmlFor + 'aria-labelledby': labelConnection !== 'htmlFor' ? label?.id : undefined, + 'aria-describedby': hasError ? hint?.id : mergeAriaDescribedBy(validationMessage?.id, hint?.id), + 'aria-errormessage': hasError ? validationMessage?.id : undefined, + 'aria-invalid': hasError ? true : undefined, + ...controlProps, + }, + }); + + if (labelConnection === 'htmlFor' && label && !label.htmlFor) { + label.htmlFor = control.id; + } + + const state: FieldState = { + orientation, + validationState, + classNames: params.classNames, + components: { + root: 'div', + control: params.component, + label: Label, + validationMessage: 'span', + validationMessageIcon: 'span', + hint: 'span', + }, + root, + control, + label, + validationMessageIcon, + validationMessage, + hint, + }; + + return state as FieldState; +}; 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..e8261946109cfc 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,144 @@ import { makeStyles, mergeClasses } from '@griffel/react'; -import type { FieldSlots, FieldState } from './Field.types'; +import type { FieldComponent, FieldProps, 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__` -}; +export const getFieldClassNames = (name: string): SlotClassNames> => ({ + root: `fui-${name}`, + control: `fui-${name}__control`, + label: `fui-${name}__label`, + validationMessage: `fui-${name}__validationMessage`, + validationMessageIcon: `fui-${name}__validationMessageIcon`, + hint: `fui-${name}__hint`, +}); /** * 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', + }, + + horizontal: { + gridTemplateRows: 'auto auto auto auto', + gridTemplateColumns: '1fr 2fr', + }, + + secondColumn: { + gridColumnStart: '2', + }, +}); + +const useLabelStyles = makeStyles({ + base: { + marginTop: tokens.spacingVerticalXXS, + marginBottom: tokens.spacingVerticalXXS, + }, + + horizontal: { + gridRowStart: '1', + gridRowEnd: '-1', + marginRight: tokens.spacingHorizontalM, + alignSelf: 'start', + justifySelf: 'stretch', + }, +}); + +const useSecondaryTextStyles = makeStyles({ + base: { + marginTop: tokens.spacingVerticalXXS, + color: tokens.colorNeutralForeground3, + ...typographyStyles.caption1, }, - // TODO add additional classes for different states and/or slots + error: { + color: tokens.colorPaletteRedForeground1, + }, +}); + +const useValidationMessageIconStyles = 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 classNames = state.classNames; + const validationState: FieldProps['validationState'] = state.validationState; + const horizontal = state.orientation === 'horizontal'; + + const rootStyles = useRootStyles(); + state.root.className = mergeClasses( + classNames.root, + rootStyles.base, + horizontal && rootStyles.horizontal, + state.root.className, + ); + + if (state.control) { + state.control.className = mergeClasses( + classNames.control, + horizontal && rootStyles.secondColumn, + state.control.className, + ); + } + + const labelStyles = useLabelStyles(); + if (state.label) { + state.label.className = mergeClasses( + classNames.label, + labelStyles.base, + horizontal && labelStyles.horizontal, + state.label.className, + ); + } + + const validationMessageIconStyles = useValidationMessageIconStyles(); + if (state.validationMessageIcon) { + state.validationMessageIcon.className = mergeClasses( + classNames.validationMessageIcon, + validationMessageIconStyles.base, + !!validationState && validationMessageIconStyles[validationState], + state.validationMessageIcon.className, + ); + } - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + const secondaryTextStyles = useSecondaryTextStyles(); + if (state.validationMessage) { + state.validationMessage.className = mergeClasses( + classNames.validationMessage, + secondaryTextStyles.base, + horizontal && rootStyles.secondColumn, + validationState === 'error' && secondaryTextStyles.error, + state.validationMessage.className, + ); + } - return state; + if (state.hint) { + state.hint.className = mergeClasses( + classNames.hint, + secondaryTextStyles.base, + horizontal && rootStyles.secondColumn, + state.hint.className, + ); + } }; diff --git a/packages/react-components/react-field/src/components/InputField/InputField.test.tsx b/packages/react-components/react-field/src/components/InputField/InputField.test.tsx new file mode 100644 index 00000000000000..a72292a8eb1d3e --- /dev/null +++ b/packages/react-components/react-field/src/components/InputField/InputField.test.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { resetIdsForTests } from '@fluentui/react-utilities'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../common/isConformant'; +import { InputField } from './InputField'; + +describe('InputField', () => { + isConformant({ + Component: InputField, + displayName: 'InputField', + primarySlot: 'control', + testOptions: { + 'has-static-classnames': [ + { + props: { + label: 'label', + validationState: 'error', + validationMessage: 'validationMessage', + hint: 'hint', + }, + }, + ], + }, + }); + + beforeEach(resetIdsForTests); + + it('renders a default state', () => { + const result = render(); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-field/src/components/InputField/InputField.tsx b/packages/react-components/react-field/src/components/InputField/InputField.tsx new file mode 100644 index 00000000000000..a7fef70a4ff1b7 --- /dev/null +++ b/packages/react-components/react-field/src/components/InputField/InputField.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { Input } from '@fluentui/react-input'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { FieldProps } from '../../Field'; +import { getFieldClassNames, renderField_unstable, useFieldStyles_unstable, useField_unstable } from '../../Field'; + +export type InputFieldProps = FieldProps; + +export const inputFieldClassNames = getFieldClassNames('InputField'); + +export const InputField: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useField_unstable({ props, ref, component: Input, classNames: inputFieldClassNames }); + useFieldStyles_unstable(state); + return renderField_unstable(state); +}); + +InputField.displayName = 'InputField'; diff --git a/packages/react-components/react-field/src/components/InputField/__snapshots__/InputField.test.tsx.snap b/packages/react-components/react-field/src/components/InputField/__snapshots__/InputField.test.tsx.snap new file mode 100644 index 00000000000000..dcb353260d09db --- /dev/null +++ b/packages/react-components/react-field/src/components/InputField/__snapshots__/InputField.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InputField renders a default state 1`] = ` +
+
+ + + +
+
+`; diff --git a/packages/react-components/react-field/src/components/InputField/index.ts b/packages/react-components/react-field/src/components/InputField/index.ts new file mode 100644 index 00000000000000..9cd7022484cce5 --- /dev/null +++ b/packages/react-components/react-field/src/components/InputField/index.ts @@ -0,0 +1 @@ +export * from './InputField'; diff --git a/packages/react-components/react-field/src/index.ts b/packages/react-components/react-field/src/index.ts index f40e2c50f7b064..9c68f80cd9e54f 100644 --- a/packages/react-components/react-field/src/index.ts +++ b/packages/react-components/react-field/src/index.ts @@ -1,3 +1,5 @@ -// TODO: replace with real exports -export {}; -export * from './Field'; +export { getFieldClassNames, renderField_unstable, useFieldStyles_unstable, useField_unstable } from './Field'; +export type { FieldProps, FieldSlots, FieldState, UseFieldParams } from './Field'; + +export { InputField, inputFieldClassNames } from './InputField'; +export type { InputFieldProps } from './InputField'; 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 deleted file mode 100644 index b8a93a1dfb9f6a..00000000000000 --- a/packages/react-components/react-field/src/stories/Field/FieldDefault.stories.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import { Field, FieldProps } from '@fluentui/react-field'; - -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 deleted file mode 100644 index e69de29bb2d1d6..00000000000000 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 deleted file mode 100644 index 650828caa907f5..00000000000000 --- a/packages/react-components/react-field/src/stories/Field/index.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Field } from '@fluentui/react-field'; - -import descriptionMd from './FieldDescription.md'; -import bestPracticesMd from './FieldBestPractices.md'; - -export { Default } from './FieldDefault.stories'; - -export default { - title: 'Components/Field', - component: Field, - parameters: { - docs: { - description: { - component: [descriptionMd, bestPracticesMd].join('\n'), - }, - }, - }, -}; diff --git a/packages/react-components/react-field/src/stories/Field/FieldBestPractices.md b/packages/react-components/react-field/src/stories/InputField/InputFieldBestPractices.md similarity index 100% rename from packages/react-components/react-field/src/stories/Field/FieldBestPractices.md rename to packages/react-components/react-field/src/stories/InputField/InputFieldBestPractices.md diff --git a/packages/react-components/react-field/src/stories/InputField/InputFieldDefault.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldDefault.stories.tsx new file mode 100644 index 00000000000000..f1ccbda6e2f327 --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldDefault.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { InputField, InputFieldProps } from '@fluentui/react-field'; + +export const Default = (props: Partial) => ( + +); diff --git a/packages/react-components/react-field/src/stories/InputField/InputFieldDescription.md b/packages/react-components/react-field/src/stories/InputField/InputFieldDescription.md new file mode 100644 index 00000000000000..71f31c59b2dac3 --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldDescription.md @@ -0,0 +1,3 @@ +InputField is a combination of Label, an Input, and validation and hint text. + +InputField does not handle input validation, but it does allow a validation message to be displayed. diff --git a/packages/react-components/react-field/src/stories/InputField/InputFieldHint.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldHint.stories.tsx new file mode 100644 index 00000000000000..074c449d53f1bf --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldHint.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { InputField } from '@fluentui/react-field'; + +export const Hint = () => ; + +Hint.parameters = { + docs: { + description: { + story: 'Hint text provides additional descriptive information about the field', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/InputField/InputFieldHorizontal.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldHorizontal.stories.tsx new file mode 100644 index 00000000000000..fd6e9c66e1cbd7 --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldHorizontal.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { InputField } from '@fluentui/react-field'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '400px', + }, +}); + +export const Horizontal = () => { + const styles = useStyles(); + return ( +
+ + + + +
+ ); +}; + +Horizontal.storyName = 'Field orientation: horizontal'; +Horizontal.parameters = { + docs: { + description: { + story: + 'The field can have a horizontal orientation. 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/InputField/InputFieldLabel.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldLabel.stories.tsx new file mode 100644 index 00000000000000..e943bf7f39d5ee --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldLabel.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { InputField } 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/InputField/InputFieldRequired.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldRequired.stories.tsx new file mode 100644 index 00000000000000..2259f02bc7997a --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldRequired.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { InputField } 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/InputField/InputFieldSize.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldSize.stories.tsx new file mode 100644 index 00000000000000..7ad08b50c362d7 --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldSize.stories.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { InputField } 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/InputField/InputFieldValidationState.stories.tsx b/packages/react-components/react-field/src/stories/InputField/InputFieldValidationState.stories.tsx new file mode 100644 index 00000000000000..333666e2bed275 --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/InputFieldValidationState.stories.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { InputField } from '@fluentui/react-field'; +import { SparkleFilled } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + stack: { + display: 'inline-grid', + rowGap: tokens.spacingVerticalM, + width: '400px', + }, +}); + +export const ValidationState = () => { + const styles = useStyles(); + return ( +
+ + + + } + validationMessage="This validation message has a custom icon" + orientation="horizontal" + /> +
+ ); +}; + +ValidationState.parameters = { + docs: { + description: { + story: + 'The `validationState` property modifies the appearance of the validation message, and for some input types, ' + + 'an error validationState also applies visual indication such as a red border.' + + '
' + + 'Use the `validationMessage` property to display an associated message. ' + + 'You can optionally override the default icon with `validationMessageIcon`.', + }, + }, +}; diff --git a/packages/react-components/react-field/src/stories/InputField/index.stories.tsx b/packages/react-components/react-field/src/stories/InputField/index.stories.tsx new file mode 100644 index 00000000000000..046108475c631d --- /dev/null +++ b/packages/react-components/react-field/src/stories/InputField/index.stories.tsx @@ -0,0 +1,24 @@ +import { InputField } from '@fluentui/react-field'; + +import descriptionMd from './InputFieldDescription.md'; +import bestPracticesMd from './InputFieldBestPractices.md'; + +export { Default } from './InputFieldDefault.stories'; +export { Label } from './InputFieldLabel.stories'; +export { Horizontal } from './InputFieldHorizontal.stories'; +export { Required } from './InputFieldRequired.stories'; +export { ValidationState } from './InputFieldValidationState.stories'; +export { Size } from './InputFieldSize.stories'; +export { Hint } from './InputFieldHint.stories'; + +export default { + title: 'Components/Field/InputField', + component: InputField, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +};