-
Notifications
You must be signed in to change notification settings - Fork 535
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Make FormControl
externally extensible (via hook)
#3632
Conversation
🦋 Changeset detectedLatest commit: 51b5e83 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
size-limit report 📦
|
src/FormControl/FormControl.tsx
Outdated
} | ||
} | ||
const isChoiceInput = childrenWithoutSlots.some( | ||
el => React.isValidElement(el) && (el.type === Checkbox || el.type === Radio), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no good way to detect if the wrapped component is a Checkbox
or Radio
without referencing them directly. I don't like this since it means we still have to have this backwards data flow from children to parent, but I don't have an elegant solution to that problem.
We can't handle this inside the hook (ie, by adding an isChoiceInput
parameter) because the hook doesn't have access to the slots these checks refer to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the slot info something that could be passed along in the context value, potentially? Or does that not really work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just tried this and honestly I don't think it makes it any cleaner. We still have to look through the children to figure out if it's a choice input, so all that changes is that we pass the slots info down. I think since these checks don't reference props
, it makes sense to leave them here for now.
src/FormControl/FormControl.tsx
Outdated
<Box sx={{'> input': {marginLeft: 0, marginRight: 0}}}> | ||
{React.isValidElement(InputComponent) && | ||
React.cloneElement( | ||
InputComponent as React.ReactElement<{ | ||
id: string | ||
disabled: boolean | ||
['aria-describedby']: string | ||
}>, | ||
{ | ||
id, | ||
disabled, | ||
['aria-describedby']: captionId as string, | ||
}, | ||
)} | ||
{childrenWithoutSlots.filter( | ||
child => | ||
React.isValidElement(child) && | ||
![Checkbox, Radio].some(inputComponent => child.type === inputComponent), | ||
)} | ||
</Box> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change also fixes an apparently previously overlooked bug where horizontal
-layout FormControl
would not forward the validationStatus
or required
props
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was not a bug, this was intentional. You can't require an individual Radio
or Checkbox
, and they can't have their own validation status.
Instead, you handle those on the CheckboxGroup
or RadioGroup
level.
See the following documentation for more info:
|
||
return { | ||
disabled: context.disabled, | ||
id: context.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One downside of this is that if two components inside the same FormControl
call this hook, they could have the same ID. But that's an invalid structure anyway - FormControl
is singular so this shouldn't be a problem.
src/FormControl/FormControl.tsx
Outdated
@@ -232,4 +152,5 @@ export default Object.assign(FormControl, { | |||
Label: FormControlLabel, | |||
LeadingVisual: FormControlLeadingVisual, | |||
Validation: FormControlValidation, | |||
useForwardedProps: useFormControlForwardedProps, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's debatable whether this should even be publicly exposed with this approach:
On one hand, this is almost never necessary. Prior to this change, wrapping a Primer form input in another component (ie, if you want to make a NumericTextInput
component) would have broken the FormControl
wiring. But with context, this doesn't happen. As long as the underlying component is a Primer form input, it doesn't matter how deeply nested it is. If we don't expose this hook, we can push users to always build around Primer components and not HTML from scratch.
On the other hand, publicly exposing it allows consumers to build custom form inputs from plain HTML. Maybe that's not something we want to prevent. For example, they could build a new RangeInput
component, which Primer doesn't have yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel more strongly about exposing this after looking into the ongoing work around Filter
and QueryBuilder
- more complex inputs that definitely could benefit from being able to directly access this info.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm definitely a fan of making this public, depending on how stable this ends up being maybe it makes sense in the drafts/experimental entrypoint until it's further validated?
I think something that was surprising initially was seeing this on the component namespace. Could hooks be something that is just accessed from the entrypoint instead of tying it to a specific component? e.g. import { useFormControl } from '@primer/react';
or is the namespace helpful here in the same way that it helps with sub-components?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We definitely could expose the hook directly - I just figured the namespace was an obvious place to look for it. I am good with either one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I slightly prefer exposing the hook directly. Usually the properties on a component "object" are other components.
if (externalProps.id) { | ||
// eslint-disable-next-line no-console | ||
console.warn( | ||
`instead of passing the 'id' prop directly to the input component, it should be passed to the parent component, <FormControl>`, | ||
) | ||
} | ||
|
||
if (externalProps.disabled) { | ||
// eslint-disable-next-line no-console | ||
console.warn( | ||
`instead of passing the 'disabled' prop directly to the input component, it should be passed to the parent component, <FormControl>`, | ||
) | ||
} | ||
|
||
if (externalProps.required) { | ||
// eslint-disable-next-line no-console | ||
console.warn( | ||
`instead of passing the 'required' prop directly to the input component, it should be passed to the parent component, <FormControl>`, | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if these warnings should be suppressible (or just removed altogether). I kept them to be consistent with current behavior, but I do think there are valid extensibility cases where you might not want these warnings.
For example, a DatePicker
might have a TextInput
as its anchor. The DatePicker
may want to control exactly how these forwarded props are handled, since a datepicker is a much more complicated component than just a text input. So the DatePicker
would override the TextInput
forwarded props, causing the warning to occur because the TextInput
is still calling this hook.
One possible way to suppress this would be for the DatePicker
to wrap its TextInput
in a FormControlContext.Provider value={null}
. But then we'd have to expose FormControlContext
and I don't think we want to do that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I love the example that you brought up, I added a comment on our v37 release issue to try and capture this: https://github.com/github/primer/issues/1715#issuecomment-1682938944 as this is likely something that will stick around for v35 and v36.
Feel free to edit/add anything to the comment if you feel like there are other examples or situations that really demonstrate why this change is important 👀
* `FormControl` props, with external props taking priority. This is also used for validating the external props, | ||
* logging warnings to the console if there are conflicts. | ||
*/ | ||
export function useFormControlForwardedProps<P extends FormControlForwardedProps>(externalProps: P): P { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we like this naming? "Forwarded props" vs "external props"? It reminds me of "forwarded refs" which also come from the parent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this naming. It's clear and feels familiar.
@@ -79,7 +79,6 @@ const InlineAutocomplete = ({ | |||
sx, | |||
children, | |||
tabInsertsSuggestions = false, | |||
// Forward accessibility props so it works with FormControl | |||
...forwardProps |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Forwarding isn't necessary to make it work with FormControl
anymore since the underlying TextInput
or Textarea
will handle it, but I still forward the HTML props just for backwards compat
This reverts commit ebb0ba5.
bff217d
to
b32e2dc
Compare
I see a few hooks in the Storybook, but they are all visual. I have a hard time seeing how I might make a story for this hook since it does not have a visual effect. Do you have anything in mind? |
@iansan5653 - it's ok if there's no visual effect. I just think it would help Primer consumers discover that hook and see an example of how it could be used. I'd recommend adding something like this in the "Hooks" section of Storybook: export default {
title: 'Hooks/useFormControlForwardedProps',
decorators: [
Story => {
return (
<ThemeProvider>
<BaseStyles>{Story()}</BaseStyles>
</ThemeProvider>
)
},
],
argTypes: {
disabled: {
type: 'boolean',
defaultValue: false,
},
id: {
type: 'string',
defaultValue: 'some-id',
},
placeholder: {
type: 'string',
defaultValue: 'Placeholder',
},
required: {
type: 'boolean',
defaultValue: false,
},
validationStatus: {
defaultValue: undefined,
options: ['error', 'success', 'warning', undefined],
control: {type: 'radio'},
},
// whatever else you might want to pass to useFormControlForwardedProps
},
} as Meta
export const UseFormControlForwardedProps = args => {
const {
disabled,
id,
required,
validationStatus,
['aria-describedby']: ariaDescribedBy,
...externalProps
} = useFormControlForwardedProps(args)
return (
<>
<label htmlFor={id}>Label {required ? '*' : undefined}</label>
<input
id={id}
disabled={disabled}
required={required}
aria-invalid={validationStatus === 'invalid'}
aria-describedby={ariaDescribedBy}
{...externalProps}
/>
<div id={ariaDescribedBy}>Some caption</div>
</>
)
} or if you just want to demonstrate what gets returned by the forwarded props, you could just render the result into a table or something: export const UseFormControlForwardedProps = args => {
const forwardedProps = useFormControlForwardedProps(args)
return (
<table>
<thead>
<tr>
<th>Forwarded prop</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{Object.entries(forwardedProps).map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
)
} |
@mperrotti Done -- added a story, tests, and directly exposed the hook |
Followup from discussion in #3621
Motivation
FormControl
supports an 'auto-wiring' functionality where it automatically applies certain props to the wrapped input in order to make accessibility easier. For example, it automatically applies anid
prop (if none is provided) to link the wrapped input to theLabel
.At present, this is only supported for a limited subset of components, defined internally:
react/src/FormControl/FormControl.tsx
Lines 58 to 67 in 65b888c
Consumers of the library cannot add to this list, so it's impossible for consumers to create their own components that support integration with
FormControl
.As I work on moving the
InlineAutocomplete
component out of this repository, we either need to make this functionality externally extensible or we have to break theInlineAutocomplete
component and require it to be manually wired up whenever used. I think the first one is much more desirable as it's generally useful for all component authoring.Solution
I exposed a new hook on the
FormControl
API:FormControl.useForwardedProps
. This hook is given the externalprops
of the component and handles merging them together and building thearia-describedby
attribute.I honestly think this is much cleaner a far preferable solution vs the one proposed in #3621. There are few downsides, and it's much more idiomatic React. We can avoid using the
Children
API and the relationship betweenFormControl
and child feels much more natural and robust.A huge advantage of this approach is that we don't need to forward props down through children - since it's context-based, supported components can be deeply nested.
The only downsides I can think of are:
props
in a hook. This is pretty minor though and it's not something most users will have to care about at allid
from getting assigned to multiple components. We really want to error if two components in oneFormControl
are both calling the hook, but I can't think of a clean way to do that. However, this is a pretty rare edge case and I don't think most users will actually try rendering multiple components in a singleFormControl