-
Notifications
You must be signed in to change notification settings - Fork 32
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
Read-only support for Checkbox, Radio, Switch [OKTA-731946] #2305
Conversation
/** | ||
* Determines whether the Checkbox is read-only | ||
*/ | ||
isReadOnly?: boolean; |
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 prop already lives in FieldComponentProps
. We should just Pick
it from there probably
/** | ||
* If `true`, the CheckboxGroup is read-only | ||
*/ | ||
isReadOnly?: boolean; |
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.
Same comment about Pick
from FieldComponentProps
onBlur?: MuiFormControlLabelProps["onBlur"]; | ||
isReadOnly?: boolean; |
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.
Same comment about Pick
from FieldComponentProps
@@ -46,6 +53,7 @@ export type RadioGroupProps = { | |||
* The `value` on the selected Radio | |||
*/ | |||
value?: RadioProps["value"]; | |||
isReadOnly?: boolean; |
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.
Same comment about the Pick
label, | ||
name: nameOverride, | ||
onChange: onChangeProp, | ||
testId, | ||
translate, | ||
value, | ||
}: RadioGroupProps) => { | ||
const [selectedValue, setSelectedValue] = useState(value ?? defaultValue); |
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.
Might be missing something but not sure why we need to track the value internally for this component
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 refactored this a bit in the commit I just pushed, but the purpose of this is for the uncontrolled case since its not managed by the MUI component itself. Without it, the read-only logic won't work because it will just update when a new selection is made.
[onChangeProp, isReadOnly, isDisabled], | ||
); | ||
|
||
const handleMouseDown = useCallback( |
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 think this needs to be handled in an onClick
or onChange
. My suspicion is that a user can still update this value via keyboard in the readonly
state
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.
Good call, mouseDown triggers before onClick so I was using that after having problems with onChange allowing the value to be changed in readonly
, but it looks like onClick works, and is more conventional, plus supports keyboard and mouse.
: isChecked | ||
? odysseyDesignTokens.PaletteSuccessLight | ||
: odysseyDesignTokens.HueNeutral300, | ||
: isReadOnly |
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 think we should probably remove these styles from the ternary. It's getting a bit messy. Go with the adding them conditionally based on the prop.
@@ -127,7 +141,9 @@ const SwitchThumb = styled("span", { | |||
borderRadius: odysseyDesignTokens.BorderRadiusRound, | |||
backgroundColor: isDisabled | |||
? odysseyDesignTokens.HueNeutral50 | |||
: odysseyDesignTokens.HueNeutralWhite, | |||
: isReadOnly |
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 guess if we are moving away from the ternary approach in the styles, we might as well update these as well
? odysseyDesignTokens.HueNeutral50 | ||
: odysseyDesignTokens.HueNeutralWhite, | ||
fill: | ||
isDisabled || isReadOnly |
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.
Same comment about the ternary
@@ -292,12 +318,13 @@ const Switch = ({ | |||
|
|||
const handleOnChange = useCallback<ChangeEventHandler<HTMLInputElement>>( | |||
(event) => { | |||
if (isReadOnly) return; |
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.
if (isReadOnly) return; | |
if (isReadOnly) { | |
event.preventDefault() | |
return | |
}; |
@@ -341,21 +369,27 @@ const Switch = ({ | |||
odysseyDesignTokens={odysseyDesignTokens} | |||
type="checkbox" | |||
value={value} | |||
tabIndex={isReadOnly ? 0 : undefined} |
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.
0
is the default value. Feels like this isn't really doing anything. But, maybe I'm missing something
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.
Good catch, this was an artifact from something I was exploring, I just verified this is not needed.
backgroundColor: odysseyTokens.HueNeutral100, | ||
borderColor: odysseyTokens.HueNeutral300, | ||
|
||
//Override hoever styles |
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.
Have a typo here :)
4e4c306
to
f66a53a
Compare
isChecked: | ||
(isControlled ? inputValues.value : internalValue) === | ||
child.props.value, | ||
onReadOnlyClick: handleReadOnlyClick, |
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.
@benlister-okta This isn't a valid prop is it? My assumption is this doesn't do anything and probably passes this prop into the DOM.
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 isn't a valid prop in RadioGroup
, but the component uses it to pass isChecked
to the children Radio components in the group to determine which one to check.
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.
@benlister-okta I pulled your branch down, still confused as to what this does. I removed it entirely and everything seems to work fine. Also, before removing, I put a log in the handleReadOnlyClick
function and it never gets called
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.
Per our Slack convo: We do not need to pass down the onReadOnlyClick
prop. It wasn't firing at all and the onClick handler in Radio
already prevents default behavior in the isReadOnly
case.
Also, from what I can tell, we do not need to pass isChecked
either
(event, value) => { | ||
onChangeProp?.(event, value); | ||
(event, newValue) => { | ||
if (!isReadOnly) { |
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 don't think we need to check for read only in the onChange
handler. If we are handling the click correctly, the onChange
won't ever fire right?
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 is needed because it prevents changing the selection if isReadOnly
= true. It works that way because of the keyboard support for focusing, otherwise tabbing through the options of a Read-only radio would change the selection.
I tested the behavior without and it allows selection change when Read-only.
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.
Per our Slack convo: Don't need the check for isReadOnly
here as the change event is not fired due to the preventDefault
in the Radio
click handler
@@ -131,9 +133,24 @@ const Checkbox = ({ | |||
|
|||
const onChange = useCallback<NonNullable<MuiCheckboxProps["onChange"]>>( | |||
(event, checked) => { | |||
if (isReadOnly) { |
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.
Same comment here, I think this being handled in the onClick
is enough. If we prevent the change from happening in the first place, don't think it matters if this is readonly or not
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.
Good call, radio functions and has a different keyboard workflow, so I think this can be removed (still functions as expected when I tested).
[onChangeProp, isReadOnly], | ||
); | ||
|
||
const handleMouseDown = useCallback<React.MouseEventHandler<HTMLSpanElement>>( |
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 should probably be a click handler, rather than mousedown. Can still click with the keyboard
width: odysseyDesignTokens.Spacing4, | ||
transform: "translateY(-50%)", | ||
transition: `opacity ${odysseyDesignTokens.TransitionDurationMain}`, | ||
opacity: getOpacity(), |
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.
If we are going to make these functions, we should let them take the args they need and move them out of the styled component declaration probably.
const getOpacity = (isChecked) => isChecked ? 1 : 0;
Or, we conditionally add the styles like
opacity: 0,
...(isChecked && {
opacity: 1,
})
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.
good call, it might be an over optimization to use the functions, I'll switch it to conditionals
transition: `background-color ${odysseyDesignTokens.TransitionDurationMain}`, | ||
})); | ||
>(({ isChecked, isDisabled, isReadOnly, odysseyDesignTokens }) => { | ||
const getBackgroundColor = () => { |
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.
Same comment as below about moving these functions outside of the styled component declaration. Every time this renders it's going to recreate these functions. Also, we'll need to name this a little more specifically since we'll be seeing it out of context. Like getSwitchTrackBackgroundColor
or something like that
})); | ||
>(({ isChecked, isDisabled, isReadOnly, odysseyDesignTokens }) => { | ||
const getBackgroundColor = () => { | ||
if (isDisabled) return odysseyDesignTokens.HueNeutral200; |
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.
Can we do the traditional style if statement with brackets
if (isDisabled) {
return odysseyDesignTokens.HueNeutral200;
}
Same for the other instances of this pattern
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.
switched this to CSS conditionals for simplicity
sx={{ | ||
alignItems: "flex-start", | ||
...(isReadOnly && { | ||
cursor: "default", //area between the label and checkbox | ||
"& .MuiTypography-root": { | ||
cursor: "default", //for the label | ||
}, | ||
}), | ||
}} |
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.
Can we memoize these styles? They may already be memoized somewhat in Field
's render function. I'm not sure where we're at in the code from the PR on GitHub though.
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 think so, I'll update it to something like this:
const checkboxStyles = useMemo(
() => ({
alignItems: "flex-start",
...(isReadOnly && {
cursor: "default",
"& .MuiTypography-root": {
cursor: "default",
},
}),
}),
[isReadOnly],
);
return (
<FormControlLabel
sx={checkboxStyles}
...rest...
onClick={ | ||
onClick as unknown as React.MouseEventHandler<HTMLButtonElement> | ||
} |
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.
Weird. Any way around this syntax like setting our useCallback
to MuiCheckboxProps["onClick"]
?
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.
Thanks for catching this. I think this was part of something I was debugging that didn't get removed. I updated the onClick to match the existing onChange:
const onClick = useCallback<NonNullable<MuiCheckboxProps["onClick"]>>(
(event) => {
if (isReadOnly) {
event.preventDefault();
}
},
[isReadOnly],
);
inputProps={{ | ||
"data-se": testId, | ||
"aria-readonly": isReadOnly, | ||
readOnly: isReadOnly, | ||
}} |
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.
Can we memoize these inputProps
? I can't see the whole file, so it might not make sense, but it looks like it could be memoized. If it's in Field
's function, then it's already memoized. My bad.
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 could memoize them, but since aria-readonly
is being used on the native element via inputProps
, and isReadOnly
is already in memory, since it's used elsewhere, I wonder if there is much of a benefit of it. What do you think @KevinGhadyani-Okta?
(event, checked) => { | ||
if (isReadOnly) { | ||
event.preventDefault(); | ||
return; | ||
} | ||
onChangeProp?.(event, checked); | ||
}, |
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.
Instead of early-fail, I wonder if we can do if-else
instead:
(event, checked) => { | |
if (isReadOnly) { | |
event.preventDefault(); | |
return; | |
} | |
onChangeProp?.(event, checked); | |
}, | |
(event, checked) => { | |
if (isReadOnly) { | |
event.preventDefault(); | |
} | |
else { | |
onChangeProp?.(event, checked); | |
} | |
}, |
That might be more clear. The return
in that if
statement can disappear if you're not looking for it.
I've used a lot of early-fail, but I've avoided in more recent years to make it clear we're doing one or the other, and return
is always at the bottom.
"aria-disabled": isReadOnly, | ||
readOnly: isReadOnly, | ||
tabIndex: isReadOnly ? 0 : undefined, |
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 want this change?
"aria-disabled": isReadOnly, | |
readOnly: isReadOnly, | |
tabIndex: isReadOnly ? 0 : undefined, | |
"aria-disabled": isDisabled || isReadOnly, | |
readOnly: isReadOnly, | |
tabIndex: isReadOnly ? 0 : undefined, |
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.
good point. Makes sense to include on the disabled state as well. I found that it is required for read-only but I don't see a reason to not include it for disabled
as well.
sx={{ | ||
...(isReadOnly && { | ||
cursor: "default", | ||
"& .MuiTypography-root": { | ||
cursor: "default", | ||
}, | ||
}), | ||
}} |
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.
Usually, these styles are in components.tsx
so they apply to all inputs. Can we move them in there?
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 style only applies to the label, not the form elements. I wanted this to live in components.tsx
but found I was not able to target from the MuiCheckbox/MuiRadio style blocks, since its applied outside of the component. I believe we'd have to re-factor how the MuiFormControlLabel
works to accept the isReadOnly prop so I figure this is the easier and cleaner path since it only applies to checkboxes and radios.
label, | ||
name: nameOverride, | ||
onChange: onChangeProp, | ||
testId, | ||
translate, | ||
value, | ||
}: RadioGroupProps) => { | ||
const [internalValue, setInternalValue] = useState(value ?? defaultValue); |
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.
Per Slack convo: With these other change being made, we no longer need any internal value as far as I can tell
[onChangeProp], | ||
(event, checked) => { | ||
if (isReadOnly) { | ||
event.preventDefault(); |
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.
Can add event.stopPropagation();
here if we want. May not be necessary but also won't hurt anything
OKTA-731946
Summary
boolean
for read-only support inCheckbox
,Radio
,Switch
,CheckboxGroup
,RadioGroup
aria-readonly
attributes added for screen readers, unlike disabled this retains keyboard navigability viaTAB
, and arrow keys where applicableTesting & Screenshots