Skip to content

Commit

Permalink
Make FormControl externally extensible (via hook) (#3632)
Browse files Browse the repository at this point in the history
* Make `FormControl` extensible via context + hook

* Create sweet-turtles-applaud.md

* Dont call hook in `InlineAutocomplete` since it gets called farther down the tree

* Don't call hook in `Autocomplete`

* Add comments on imports

* Remove old comment

* Remove `id` override from `AutocompleteInput`

* Change version bump to `minor`

* Revert "Remove `id` override from `AutocompleteInput`"

This reverts commit ebb0ba5.

* Suppress warning when passed ID == context ID

* Don't export `FormControlContext` directly

* Don't export `FormControlContext` type

* Revert component changes

* Move warning & cloning logic back into `FormControl` :(

* Allow calling `useForwardedProps` without passing a props object

* Expose `useFormControlForwardedProps` directly

* Add tests for `useFormControlForwardedProps`

* Add story for `useFormControlForwardedProps`

* Fix import

* Update snapshot

* Fix `InlineAutocomplete`
  • Loading branch information
iansan5653 authored Sep 5, 2023
1 parent 01fa457 commit 3a8b841
Show file tree
Hide file tree
Showing 13 changed files with 207 additions and 32 deletions.
7 changes: 7 additions & 0 deletions .changeset/sweet-turtles-applaud.md
Original file line number Diff line number Diff line change
@@ -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

<!-- Changed components: FormControl -->
24 changes: 4 additions & 20 deletions src/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ 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'
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
Expand All @@ -40,13 +40,6 @@ export type FormControlProps = {
layout?: 'horizontal' | 'vertical'
} & SxProp

export interface FormControlContext extends Pick<FormControlProps, 'disabled' | 'id' | 'required'> {
captionId?: string
validationMessageId?: string
}

export const FormControlContext = React.createContext<FormControlContext>({})

const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
({children, disabled: disabledProp, layout = 'vertical', id: idProp, required, sx}, ref) => {
const [slots, childrenWithoutSlots] = useSlots(children, {
Expand All @@ -55,16 +48,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
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)
Expand Down Expand Up @@ -130,7 +114,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
const isLabelHidden = slots.label?.props.visuallyHidden

return (
<FormControlContext.Provider
<FormControlContextProvider
value={{
captionId,
disabled,
Expand Down Expand Up @@ -222,7 +206,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
{slots.caption}
</Box>
)}
</FormControlContext.Provider>
</FormControlContextProvider>
)
},
)
Expand Down
4 changes: 2 additions & 2 deletions src/FormControl/_FormControlCaption.tsx
Original file line number Diff line number Diff line change
@@ -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<React.PropsWithChildren<{id?: string} & SxProp>> = ({children, sx, id}) => {
const {captionId, disabled} = React.useContext(FormControlContext)
const {captionId, disabled} = useFormControlContext()
return (
<InputCaption id={id || captionId || ''} disabled={disabled} sx={sx}>
{children}
Expand Down
51 changes: 51 additions & 0 deletions src/FormControl/_FormControlContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {createContext, useContext} from 'react'
import {FormValidationStatus} from '../utils/types/FormValidationStatus'
import {FormControlProps} from './FormControl'

interface FormControlContext extends Pick<FormControlProps, 'disabled' | 'id' | 'required'> {
captionId?: string
validationMessageId?: string
validationStatus?: FormValidationStatus
}

const FormControlContext = createContext<FormControlContext | null>(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<FormControlContext, 'captionId' | 'validationMessageId'> {
['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<P>(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,
}
}
4 changes: 2 additions & 2 deletions src/FormControl/_FormControlLabel.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
/**
Expand All @@ -14,7 +14,7 @@ export type Props = {
const FormControlLabel: React.FC<
React.PropsWithChildren<{htmlFor?: string} & React.ComponentProps<typeof InputLabel> & 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'
Expand Down
4 changes: 2 additions & 2 deletions src/FormControl/_FormControlLeadingVisual.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.PropsWithChildren<SxProp>> = ({children, sx}) => {
const {disabled, captionId} = React.useContext(FormControlContext)
const {disabled, captionId} = useFormControlContext()
return (
<Box
color={disabled ? 'fg.muted' : 'fg.default'}
Expand Down
4 changes: 2 additions & 2 deletions src/FormControl/_FormControlValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import InputValidation from '../internal/components/InputValidation'
import {SxProp} from '../sx'
import {FormValidationStatus} from '../utils/types/FormValidationStatus'
import {FormControlContext} from './FormControl'
import {useFormControlContext} from './_FormControlContext'

export type FormControlValidationProps = {
variant: FormValidationStatus
Expand All @@ -15,7 +15,7 @@ const FormControlValidation: React.FC<React.PropsWithChildren<FormControlValidat
sx,
id,
}) => {
const {validationMessageId} = React.useContext(FormControlContext)
const {validationMessageId} = useFormControlContext()
return (
<InputValidation validationStatus={variant} id={id || validationMessageId || ''} sx={sx}>
{children}
Expand Down
1 change: 1 addition & 0 deletions src/FormControl/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export {useFormControlForwardedProps} from './_FormControlContext'
export {default} from './FormControl'
67 changes: 67 additions & 0 deletions src/FormControl/useFormControlForwardedProps.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ThemeProvider theme={theme}>
<BaseStyles>
<Story />
</BaseStyles>
</ThemeProvider>
)
},
],
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 <input {...props} />
}

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) => (
<FormControl disabled={disabled} required={required}>
<FormControl.Label>{label}</FormControl.Label>
<CustomInput type={type} />
{caption && <FormControl.Caption>{caption}</FormControl.Caption>}
</FormControl>
)
63 changes: 62 additions & 1 deletion src/__tests__/FormControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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}) => (
<FormControl id={id} disabled required>
<FormControl.Label>Label</FormControl.Label>
{children}
</FormControl>
),
})

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}) => (
<FormControl id="form-control-id" disabled>
<FormControl.Label>Label</FormControl.Label>
{children}
</FormControl>
),
})

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)
})
})
1 change: 1 addition & 0 deletions src/__tests__/__snapshots__/exports.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ exports[`@primer/react should not update exports without a semver change 1`] = `
"useDetails",
"useFocusTrap",
"useFocusZone",
"useFormControlForwardedProps",
"useOnEscapePress",
"useOnOutsideClick",
"useOpenAndCloseFocus",
Expand Down
8 changes: 5 additions & 3 deletions src/drafts/InlineAutocomplete/InlineAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<HTMLInputElement & HTMLTextAreaElement>(null)
useRefObjectAsForwardedRef(children.ref ?? noop, inputRef)

Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 3a8b841

Please sign in to comment.