diff --git a/.changeset/sweet-turtles-applaud.md b/.changeset/sweet-turtles-applaud.md new file mode 100644 index 00000000000..6581bd01cd4 --- /dev/null +++ b/.changeset/sweet-turtles-applaud.md @@ -0,0 +1,7 @@ +--- +"@primer/react": minor +--- + +Allow consumers to make components that are compatible with `FormControl` by reading forwarded props in from the `useFormControlForwardedProps` hook + + diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index 401029044ac..3bfe0cfaa22 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -10,7 +10,6 @@ import Textarea from '../Textarea' import {CheckboxOrRadioGroupContext} from '../internal/components/CheckboxOrRadioGroup' import ValidationAnimationContainer from '../internal/components/ValidationAnimationContainer' import {get} from '../constants' -import InlineAutocomplete from '../drafts/InlineAutocomplete' import {useSlots} from '../hooks/useSlots' import {SxProp} from '../sx' import {useId} from '../hooks/useId' @@ -18,6 +17,7 @@ import FormControlCaption from './_FormControlCaption' import FormControlLabel from './_FormControlLabel' import FormControlLeadingVisual from './_FormControlLeadingVisual' import FormControlValidation from './_FormControlValidation' +import {FormControlContextProvider} from './_FormControlContext' export type FormControlProps = { children?: React.ReactNode @@ -40,13 +40,6 @@ export type FormControlProps = { layout?: 'horizontal' | 'vertical' } & SxProp -export interface FormControlContext extends Pick { - captionId?: string - validationMessageId?: string -} - -export const FormControlContext = React.createContext({}) - const FormControl = React.forwardRef( ({children, disabled: disabledProp, layout = 'vertical', id: idProp, required, sx}, ref) => { const [slots, childrenWithoutSlots] = useSlots(children, { @@ -55,16 +48,7 @@ const FormControl = React.forwardRef( leadingVisual: FormControlLeadingVisual, validation: FormControlValidation, }) - const expectedInputComponents = [ - Autocomplete, - Checkbox, - Radio, - Select, - TextInput, - TextInputWithTokens, - Textarea, - InlineAutocomplete, - ] + const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea] const choiceGroupContext = useContext(CheckboxOrRadioGroupContext) const disabled = choiceGroupContext.disabled || disabledProp const id = useId(idProp) @@ -130,7 +114,7 @@ const FormControl = React.forwardRef( const isLabelHidden = slots.label?.props.visuallyHidden return ( - ( {slots.caption} )} - + ) }, ) diff --git a/src/FormControl/_FormControlCaption.tsx b/src/FormControl/_FormControlCaption.tsx index 8538f10d21e..61bcfbee61f 100644 --- a/src/FormControl/_FormControlCaption.tsx +++ b/src/FormControl/_FormControlCaption.tsx @@ -1,10 +1,10 @@ import React from 'react' import InputCaption from '../internal/components/InputCaption' import {SxProp} from '../sx' -import {FormControlContext} from './FormControl' +import {useFormControlContext} from './_FormControlContext' const FormControlCaption: React.FC> = ({children, sx, id}) => { - const {captionId, disabled} = React.useContext(FormControlContext) + const {captionId, disabled} = useFormControlContext() return ( {children} diff --git a/src/FormControl/_FormControlContext.tsx b/src/FormControl/_FormControlContext.tsx new file mode 100644 index 00000000000..efa9aa5531c --- /dev/null +++ b/src/FormControl/_FormControlContext.tsx @@ -0,0 +1,51 @@ +import {createContext, useContext} from 'react' +import {FormValidationStatus} from '../utils/types/FormValidationStatus' +import {FormControlProps} from './FormControl' + +interface FormControlContext extends Pick { + captionId?: string + validationMessageId?: string + validationStatus?: FormValidationStatus +} + +const FormControlContext = createContext(null) + +export const FormControlContextProvider = FormControlContext.Provider + +/** This is the private/internal interface for subcomponents of `FormControl`. */ +export function useFormControlContext(): FormControlContext { + return useContext(FormControlContext) ?? {} +} + +interface FormControlForwardedProps extends Omit { + ['aria-describedby']?: string +} + +/** + * Make any component compatible with `FormControl`'s automatic wiring up of accessibility attributes & validation by + * reading the props from this hook and merging them with the passed-in props. If used outside of `FormControl`, this + * hook has no effect. + * + * @param externalProps The external props passed to this component. If provided, these props will be merged with the + * `FormControl` props, with external props taking priority. + */ +export function useFormControlForwardedProps

(externalProps: P): P & FormControlForwardedProps +/** + * Make any component compatible with `FormControl`'s automatic wiring up of accessibility attributes & validation by + * reading the props from this hook and handling them / assigning them to the underlying form control. If used outside + * of `FormControl`, this hook has no effect. + */ +export function useFormControlForwardedProps(): FormControlForwardedProps +export function useFormControlForwardedProps(externalProps: FormControlForwardedProps = {}) { + const context = useContext(FormControlContext) + if (!context) return externalProps + + return { + disabled: context.disabled, + id: context.id, + required: context.required, + validationStatus: context.validationStatus, + ['aria-describedby']: [context.validationMessageId, context.captionId].filter(Boolean).join(' ') || undefined, + ...externalProps, + } +} diff --git a/src/FormControl/_FormControlLabel.tsx b/src/FormControl/_FormControlLabel.tsx index 7b864abac12..ff1ca4f5583 100644 --- a/src/FormControl/_FormControlLabel.tsx +++ b/src/FormControl/_FormControlLabel.tsx @@ -1,7 +1,7 @@ import React from 'react' import InputLabel from '../internal/components/InputLabel' import {SxProp} from '../sx' -import {FormControlContext} from './FormControl' +import {useFormControlContext} from './_FormControlContext' export type Props = { /** @@ -14,7 +14,7 @@ export type Props = { const FormControlLabel: React.FC< React.PropsWithChildren<{htmlFor?: string} & React.ComponentProps & Props> > = ({as, children, htmlFor, id, visuallyHidden, sx, ...props}) => { - const {disabled, id: formControlId, required} = React.useContext(FormControlContext) + const {disabled, id: formControlId, required} = useFormControlContext() /** * Ensure we can pass through props correctly, since legend/span accept no defined 'htmlFor' diff --git a/src/FormControl/_FormControlLeadingVisual.tsx b/src/FormControl/_FormControlLeadingVisual.tsx index 9e103164499..8dfc6d5c741 100644 --- a/src/FormControl/_FormControlLeadingVisual.tsx +++ b/src/FormControl/_FormControlLeadingVisual.tsx @@ -2,10 +2,10 @@ import React from 'react' import Box from '../Box' import {get} from '../constants' import {SxProp} from '../sx' -import {FormControlContext} from './FormControl' +import {useFormControlContext} from './_FormControlContext' const FormControlLeadingVisual: React.FC> = ({children, sx}) => { - const {disabled, captionId} = React.useContext(FormControlContext) + const {disabled, captionId} = useFormControlContext() return ( { - const {validationMessageId} = React.useContext(FormControlContext) + const {validationMessageId} = useFormControlContext() return ( {children} diff --git a/src/FormControl/index.ts b/src/FormControl/index.ts index 5850302e571..a79a15f5d3e 100644 --- a/src/FormControl/index.ts +++ b/src/FormControl/index.ts @@ -1 +1,2 @@ +export {useFormControlForwardedProps} from './_FormControlContext' export {default} from './FormControl' diff --git a/src/FormControl/useFormControlForwardedProps.stories.tsx b/src/FormControl/useFormControlForwardedProps.stories.tsx new file mode 100644 index 00000000000..5c0a8e13fd3 --- /dev/null +++ b/src/FormControl/useFormControlForwardedProps.stories.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {BaseStyles, FormControl, ThemeProvider, theme, useFormControlForwardedProps} from '..' + +export default { + title: 'Hooks/useFormControlForwardedProps', + decorators: [ + Story => { + return ( + + + + + + ) + }, + ], + argTypes: { + disabled: { + type: 'boolean', + }, + required: { + type: 'boolean', + }, + label: { + type: 'string', + }, + caption: { + type: 'string', + }, + type: { + control: { + type: 'select', + description: "Type of the input, showing how the `type` prop can be forwarded to the input's props", + }, + options: ['text', 'number', 'password', 'email', 'search', 'tel', 'url'], + }, + }, +} as Meta + +interface ArgTypes { + disabled: boolean + required: boolean + label: string + caption: string + type: string +} + +/** A custom input that is not a Primer `TextInput` but still supports autowiring with `FormControl`. */ +const CustomInput = (externalProps: {type: string}) => { + const props = useFormControlForwardedProps(externalProps) + return +} + +export const AutowiredCustomInput = ({ + label = 'Custom input', + caption = 'This is not a Primer input, but it still has `aria-describedby` and similar attributes applied automatically', + required = false, + disabled = false, + type = 'text', +}: ArgTypes) => ( + + {label} + + {caption && {caption}} + +) diff --git a/src/__tests__/FormControl.test.tsx b/src/__tests__/FormControl.test.tsx index 09a009c24d6..50346b0e9ba 100644 --- a/src/__tests__/FormControl.test.tsx +++ b/src/__tests__/FormControl.test.tsx @@ -1,7 +1,18 @@ import React from 'react' import {render} from '@testing-library/react' +import {renderHook} from '@testing-library/react-hooks' import {axe, toHaveNoViolations} from 'jest-axe' -import {Autocomplete, Checkbox, FormControl, Select, SSRProvider, Textarea, TextInput, TextInputWithTokens} from '..' +import { + Autocomplete, + Checkbox, + FormControl, + Select, + SSRProvider, + Textarea, + TextInput, + TextInputWithTokens, + useFormControlForwardedProps, +} from '..' import {MarkGithubIcon} from '@primer/octicons-react' expect.extend(toHaveNoViolations) @@ -446,3 +457,53 @@ describe('FormControl', () => { }) }) }) + +describe('useFormControlForwardedProps', () => { + describe('when used outside FormControl', () => { + test('returns empty object when no props object passed', () => { + const result = renderHook(() => useFormControlForwardedProps()) + expect(result.result.current).toEqual({}) + }) + + test('returns passed props object instance when passed', () => { + const props = {id: 'test-id'} + const result = renderHook(() => useFormControlForwardedProps(props)) + expect(result.result.current).toBe(props) + }) + }) + + test('provides context value when no props object is passed', () => { + const id = 'test-id' + + const {result} = renderHook(() => useFormControlForwardedProps(), { + wrapper: ({children}: {children: React.ReactNode}) => ( + + Label + {children} + + ), + }) + + expect(result.current.disabled).toBe(true) + expect(result.current.id).toBe(id) + expect(result.current.required).toBe(true) + }) + + test('merges with props object, overriding to prioritize props when conflicting', () => { + const props = {id: 'override-id', xyz: 'someValue'} + + const {result} = renderHook(() => useFormControlForwardedProps(props), { + wrapper: ({children}: {children: React.ReactNode}) => ( + + Label + {children} + + ), + }) + + expect(result.current.disabled).toBe(true) + expect(result.current.id).toBe(props.id) + expect(result.current.required).toBeFalsy() + expect(result.current.xyz).toBe(props.xyz) + }) +}) diff --git a/src/__tests__/__snapshots__/exports.test.ts.snap b/src/__tests__/__snapshots__/exports.test.ts.snap index fc308c494a7..eacc48ac9b0 100644 --- a/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/src/__tests__/__snapshots__/exports.test.ts.snap @@ -85,6 +85,7 @@ exports[`@primer/react should not update exports without a semver change 1`] = ` "useDetails", "useFocusTrap", "useFocusZone", + "useFormControlForwardedProps", "useOnEscapePress", "useOnOutsideClick", "useOpenAndCloseFocus", diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx index f9d797823ad..cb49434177b 100644 --- a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx +++ b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx @@ -17,6 +17,7 @@ import {augmentHandler, calculateSuggestionsQuery, getSuggestionValue, requireCh import {useRefObjectAsForwardedRef} from '../../hooks' import AutocompleteSuggestions from './_AutocompleteSuggestions' +import {useFormControlForwardedProps} from '../../FormControl' export type InlineAutocompleteProps = { /** Register the triggers that can cause suggestions to appear. */ @@ -99,9 +100,10 @@ const InlineAutocomplete = ({ children, tabInsertsSuggestions = false, suggestionsPlacement = 'below', - // Forward accessibility props so it works with FormControl - ...forwardProps + ...externalInputProps }: InlineAutocompleteProps & React.ComponentProps<'textarea' | 'input'>) => { + const inputProps = useFormControlForwardedProps(externalInputProps) + const inputRef = useRef(null) useRefObjectAsForwardedRef(children.ref ?? noop, inputRef) @@ -176,7 +178,7 @@ const InlineAutocomplete = ({ } const input = cloneElement(externalInput, { - ...forwardProps, + ...inputProps, onBlur: augmentHandler(externalInput.props.onBlur, onBlur), onKeyDown: augmentHandler(externalInput.props.onKeyDown, onKeyDown), onChange: augmentHandler(externalInput.props.onChange, onChange), diff --git a/src/index.ts b/src/index.ts index e0597c904a5..b3249bcded1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,7 @@ export type {FilterListProps, FilterListItemProps} from './FilterList' export {default as Flash} from './Flash' export type {FlashProps} from './Flash' export {default as FormControl} from './FormControl' +export {useFormControlForwardedProps} from './FormControl' export {default as Header} from './Header' export type {HeaderProps, HeaderItemProps, HeaderLinkProps} from './Header' export {default as Heading} from './Heading'