Introduce useCheckboxGroup & useCheckboxGroupState hooks#868
Conversation
5421adf to
8292e32
Compare
8292e32 to
1d8e40b
Compare
| 'aria-readonly': isReadOnly || undefined, | ||
| 'aria-required': isRequired || undefined, | ||
| 'aria-disabled': isDisabled || undefined, | ||
| 'aria-orientation': orientation, |
There was a problem hiding this comment.
Unfortunately, I don't believe the above properties are valid on a group aside from aria-disabled. See https://www.w3.org/TR/wai-aria-1.2/#group.
There was a problem hiding this comment.
@jnurthen do you know if there are any checkbox group patterns that would support this, similar to a radio group? Why does ARIA have one but not the other?
There was a problem hiding this comment.
Good catch, I forgot to recheck those. What should happen then? Should we maybe not provide those aria-* props here but pass those group props to the individual checkboxes? So one could disable the whole group instead of all checkboxes individually?
There was a problem hiding this comment.
Yeah I think that's probably the best option.
There was a problem hiding this comment.
Just want to reassure myself that we are on the same page - you are saying that the group should accept those props like isReadOnly, isRequired, isDisabled and "forward" them to individual checkbox, right?
I believe in such a case isRequired would have to be handled somehow differently? isRequired seems to mean that any of the checkboxes in a group has to be checked, not that all of them are checked. OTOH there are also other scenarios - like a list of agreements in which all of the checkboxes have to be checked, or a list of checkboxes with "select at least X". This would mean that this, in fact, up to the consumer to decide.
But as aria-invalid is not a valid attribute for a role="group" then there is no place to accept validationState right now as there is nothing that we could do automatically with it 🤔 Taking a look at useRadioGroup - validationState is part of the AriaRadioGroupProps, not RadioGroupState. So providing its value is always a responsibility of a consumer and this should be handled the same here.
Looking further at useRadioGroup - it accepts the very same props (isReadOnly, isRequired, isDisabled) but it just sets aria attributes based on them, it doesn't forward them anyhow to useRadio - even though it accepts them as well. So in the case of the radio group - the forwarding of those props has to be done by the consumer manually. This wiring happens within spectrum's RadioGroup & Radio components through context.
There was a problem hiding this comment.
So, thinking about this more, I think we should remove the isRequired and validationState props from CheckboxGroup. isRequired is just passed through as the required DOM prop on each checkbox, which makes them all required as you said, not if some are required. AFAIK there's no way of doing that in HTML. We don't implement any validation logic ourselves, so this would be up to the app. Similar reasoning for validationState. Marking every checkbox as invalid doesn't really make sense either.
As for isReadOnly and isDisabled, I think propagating them to each of the children makes sense, along with aria-disabled on the group itself. RadioGroup does this through context, but one could do so in other ways too (e.g. cloneElement or something). I think we should leave this up to consumers of React Aria and not be too prescriptive about it.
There was a problem hiding this comment.
I've removed most of those props from this interface - the only one that I have left is isDisabled as aria-disabled is allowed on a role="group".
isRequired was also left (but stays unused) because I extend LabelableProps and this interface supports isRequired:
https://github.com/adobe/react-spectrum/blob/8f7fbb33e34d228b3e2cb370634e4c2cc7ae9b0f/packages/%40react-types/shared/src/labelable.d.ts
although I'm not sure if this is not a mistake (I don't rly see it being used anywhere by LabelableProps consumers) and if it maybe shouldn't be moved to SpectrumLabelableProps. I've created a PR for such change if you agree with it: #877
|
Thanks for working on this! Does What if I think it would be harder for
Yeah we've done that so far, but I think testing with a dummy component like you started makes sense if the Spectrum part isn't implemented yet.
I think a new |
Sure - this would be possible. Is there any precedence for such an API within the codebase right now?
That's not entirely true const { inputProps } = checkboxGroupContext
? useGroupedCheckbox(props)
: useCheckbox(props)
I don't want to be purist - I certainly have done worse than this, but something like this always makes me consider alternatives first as I feel like this is a sign of a broken composition chain. Not the end of the world, but ideally this wouldn't happen if puzzles would fit perfectly.
K, gonna add more like this 👍
Gonna add this soon 👍 |
Not really.
😲 that makes sense. Let me talk with the team and get back to you. |
Sure thing, I understand that this is not the usual solution 😅 I also don't want to insist on this - just thinking about the whole API of this project and don't want to introduce new patterns too hastily (like the proposed |
|
So basically it's between these two options:
let {inputProps} = groupState
? useGroupedCheckbox(props, groupState)
: useCheckbox(props, useToggleState(props));
let state = groupState
? groupState.getCheckboxState(props.value)
: useToggleState(props);
let {inputProps} = useCheckbox(props, state);Really not too different in the end. I think if there were behavioral or ARIA differences then (1) would make sense. But since we're really just swapping out the state, then maybe (2) makes more sense in this case. |
105bcb3 to
500d35a
Compare
|
I've implemented more test and refactor There are 2 usability issues with this (IMHO:
I've also experimented with an alternative implementation for alternative useCheckboxGroupState implementation based on reducerLikeindex c5d40246..5da6c702 100644
--- a/packages/@react-stately/toggle/src/useCheckboxGroupState.ts
+++ b/packages/@react-stately/toggle/src/useCheckboxGroupState.ts
@@ -49,42 +49,49 @@ export function useCheckboxGroupState(props: CheckboxGroupProps = {}): CheckboxG
let name = useMemo(() => props.name || `checkbox-group-${instance}-${++i}`, [props.name]);
let [selectedValue, setValue] = useControlledState(props.value, props.defaultValue || [], props.onChange);
+ const reducerLike = (action: { type: 'add' | 'remove' | 'toggle', value: string }) => {
+ if (props.isReadOnly) {
+ return;
+ }
+ setValue(values => {
+ let {type, value} = action;
+ switch (type) {
+ case 'add':
+ if (values.includes(value)) {
+ return values;
+ }
+ break;
+ case 'remove':
+ if (!values.includes(value)) {
+ return values;
+ }
+ break;
+ case 'toggle':
+ type = values.includes(value) ? 'remove' : 'add';
+ break;
+ }
+
+ switch (type) {
+ case 'add':
+ return values.concat(value);
+ case 'remove':
+ return values.filter(existingValue => existingValue !== value);
+ }
+ });
+ }
+
const state: CheckboxGroupState = {
name,
value: selectedValue,
setValue,
addValue(value) {
- if (props.isReadOnly) {
- return;
- }
- setValue(values => {
- if (!values.includes(value)) {
- return values.concat(value);
- }
- return values;
- });
+ reducerLike({ type: 'add', value });
},
removeValue(value) {
- if (props.isReadOnly) {
- return;
- }
- setValue(values => {
- if (values.includes(value)) {
- return values.filter(existingValue => existingValue !== value);
- }
- return values;
- });
+ reducerLike({ type: 'remove', value });
},
toggleValue(value) {
- if (props.isReadOnly) {
- return;
- }
- setValue(values => {
- if (values.includes(value)) {
- return values.filter(existingValue => existingValue !== value);
- }
- return values.concat(value);
- });
+ reducerLike({ type: 'toggle', value });
},
getCheckboxState(props) {
const {value, onChange, isReadOnly} = props;
@@ -95,11 +102,7 @@ export function useCheckboxGroupState(props: CheckboxGroupProps = {}): CheckboxG
return;
}
- if (isSelected) {
- state.addValue(value);
- } else {
- state.removeValue(value);
- }
+ reducerLike({ type: isSelected ? 'add' : 'remove', value });
if (onChange) {
onChange(isSelected);Let me know what do you think about this one and if there is anything else I should do to get this merged in. |
3cf51e7 to
6306a28
Compare
devongovett
left a comment
There was a problem hiding this comment.
Looks great! One question about docs and I think we can merge this.
| keywords: [toggle, checkbox, switch, input, state] | ||
| --- | ||
|
|
||
| # useToggleState |
There was a problem hiding this comment.
Was this supposed to be docs for useCheckboxGroupState? I believe docs for useToggleState is already in @react-stately/toggle.
There was a problem hiding this comment.
Yeah, when creating @react-stately/checkbox directory I've just copied the other one as a template and haven't noticed that docs directory got added. I've tweaked the content so it points to the correct package right now.
|
In regards to your questions:
Given these concerns, do you still feel that Another option would be for function Checkbox(props) {
let groupState = useContext(CheckboxGroupContext);
if (groupState) {
let {checkboxProps} = useCheckboxGroupItem(props, groupState);
props = checkboxProps;
}
let state = useToggleState(props);
let {inputProps} = useCheckbox(props, state);
// ...
}Or via a wrapper component to Checkbox. WDYT? |
I kinda do.
This would be OK for me (still prefer To sum it up - I would personally revert it to the previous variant, just with a renamed name: I don't think there is much sense in debating this any further unless somebody else would like to pitch in (any other opinions in the Spectrum team?). Pick your poison and I will adjust the code accordingly 😉 |
I'm fine with that. |
|
@devongovett I've changed the implementation to the discussed |
Partially addresses #798
The goal of this PR is to only implement
@react-ariaand@react-statelyparts of this feature.Even though that at first glance this should be very similar to existing
useRadioGroup- it is not (not in some details).useRadiois always considered to be a part of a group so it acceptsRadioGroupState. HoweveruseCheckboxalready acceptsToggleStateand it didn't make much sense to me to implement support forCheckboxGroupStatethere - so I've decided to implementuseGroupedCheckboxwhich adaptsCheckboxGroupStateto theToggleStatefor a given checkbox and just pass it down touseCheckbox. This IMHO makes for a cleaner API and leverages composition nicely.I've researched the topic of checkbox groups a little bit. Unfortunately, there are no aria practices written down for it and there is no dedicated role for it (like in the case of radiogroup), even though checkbox group is not an esoteric use case. I've found out two resources:
In the first article, authors compare various possible implementations, but in the end, I don't feel like they have reached the perfect solution - the whole thing is also very SR-dependent. The second one (coming from older version of aria practices? I don't know) simplifies the whole thing (it doesn't try to hack around SR differences) and just mentions that some things are up to a particular SR, such as reading the label for a whole checkbox group. So I've gone with the latter, simpler, but also saner in my eyes, solution.
TODO:
@react-spectrumlevel, is it OK for you to implement more unit tests for the isolateduseCheckboxGroup?useGroupedCheckbox, I'm not sure if it's the best one - but I couldn't think of any betteruseCheckboxGroupStateshould live, right now it is included in@react-stately/togglebut this doesn't sound right, should maybe@react-stately/checkboxbe created?✅ Pull Request Checklist:
📝 Test Instructions:
yarn test