Skip to content
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

Merged
merged 22 commits into from
Aug 21, 2024

Conversation

benlister-okta
Copy link
Contributor

@benlister-okta benlister-okta commented Jul 26, 2024

OKTA-731946

Summary

  • Adds boolean for read-only support in Checkbox, Radio, Switch, CheckboxGroup, RadioGroup
  • Includes aria-readonly attributes added for screen readers, unlike disabled this retains keyboard navigability via TAB, and arrow keys where applicable

Testing & Screenshots

  • I have confirmed this change with my designer and the Odyssey Design Team.

@benlister-okta benlister-okta requested a review from a team as a code owner July 26, 2024 23:26
/**
* Determines whether the Checkbox is read-only
*/
isReadOnly?: boolean;
Copy link
Contributor

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;
Copy link
Contributor

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;
Copy link
Contributor

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;
Copy link
Contributor

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);
Copy link
Contributor

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

Copy link
Contributor Author

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(
Copy link
Contributor

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

Copy link
Contributor Author

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
Copy link
Contributor

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
Copy link
Contributor

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
Copy link
Contributor

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (isReadOnly) return;
if (isReadOnly) {
event.preventDefault()
return
};

@@ -341,21 +369,27 @@ const Switch = ({
odysseyDesignTokens={odysseyDesignTokens}
type="checkbox"
value={value}
tabIndex={isReadOnly ? 0 : undefined}
Copy link
Contributor

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

Copy link
Contributor Author

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a typo here :)

isChecked:
(isControlled ? inputValues.value : internalValue) ===
child.props.value,
onReadOnlyClick: handleReadOnlyClick,
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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

Copy link
Contributor

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) {
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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) {
Copy link
Contributor

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

Copy link
Contributor Author

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>>(
Copy link
Contributor

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(),
Copy link
Contributor

@bryancunningham-okta bryancunningham-okta Aug 2, 2024

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,
})

Copy link
Contributor Author

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 = () => {
Copy link
Contributor

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;
Copy link
Contributor

@bryancunningham-okta bryancunningham-okta Aug 2, 2024

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

Copy link
Contributor Author

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

Comment on lines 161 to 169
sx={{
alignItems: "flex-start",
...(isReadOnly && {
cursor: "default", //area between the label and checkbox
"& .MuiTypography-root": {
cursor: "default", //for the label
},
}),
}}
Copy link
Contributor

@KevinGhadyani-Okta KevinGhadyani-Okta Aug 5, 2024

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.

Copy link
Contributor Author

@benlister-okta benlister-okta Aug 5, 2024

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...

Comment on lines +184 to +186
onClick={
onClick as unknown as React.MouseEventHandler<HTMLButtonElement>
}
Copy link
Contributor

@KevinGhadyani-Okta KevinGhadyani-Okta Aug 5, 2024

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"]?

Copy link
Contributor Author

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],
  );

Comment on lines 188 to 192
inputProps={{
"data-se": testId,
"aria-readonly": isReadOnly,
readOnly: isReadOnly,
}}
Copy link
Contributor

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.

Copy link
Contributor Author

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?

Comment on lines 99 to 105
(event, checked) => {
if (isReadOnly) {
event.preventDefault();
return;
}
onChangeProp?.(event, checked);
},
Copy link
Contributor

@KevinGhadyani-Okta KevinGhadyani-Okta Aug 5, 2024

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:

Suggested change
(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.

Comment on lines 132 to 134
"aria-disabled": isReadOnly,
readOnly: isReadOnly,
tabIndex: isReadOnly ? 0 : undefined,
Copy link
Contributor

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?

Suggested change
"aria-disabled": isReadOnly,
readOnly: isReadOnly,
tabIndex: isReadOnly ? 0 : undefined,
"aria-disabled": isDisabled || isReadOnly,
readOnly: isReadOnly,
tabIndex: isReadOnly ? 0 : undefined,

Copy link
Contributor Author

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.

Comment on lines +148 to +155
sx={{
...(isReadOnly && {
cursor: "default",
"& .MuiTypography-root": {
cursor: "default",
},
}),
}}
Copy link
Contributor

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?

Copy link
Contributor Author

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);
Copy link
Contributor

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();
Copy link
Contributor

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

@oktapp-aperture-okta oktapp-aperture-okta bot merged commit f92a201 into main Aug 21, 2024
3 checks passed
@oktapp-aperture-okta oktapp-aperture-okta bot deleted the bl_read_only_forms_OKTA-731946 branch August 21, 2024 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants