diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index bcc7bb595b..563fa26029 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -152,6 +152,8 @@ type: embed ### Checkbox +`readOnly` checkboxes are now focusable, in line with WCAG accessibility requirements. Previously, `readOnly` checkboxes were treated the same as `disabled` and were excluded from the tab order. Clicking a `readOnly` checkbox still has no effect — neither `onClick` nor `onChange` will fire. + #### Checkbox (simple variant) ```js diff --git a/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/index.tsx b/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/index.tsx index d69c23c183..b4a86a5fe1 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/index.tsx +++ b/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/index.tsx @@ -49,6 +49,8 @@ class CheckboxFacade extends Component { static allowedProps = allowedProps static defaultProps = { checked: false, + disabled: false, + readOnly: false, focused: false, hovered: false, size: 'medium', @@ -70,10 +72,24 @@ class CheckboxFacade extends Component { } renderIcon() { - if (this.props.indeterminate) { - return renderIconWithProps(MinusInstUIIcon, 'sm', 'inverseColor') - } else if (this.props.checked) { - return renderIconWithProps(CheckInstUIIcon, 'sm', 'inverseColor') + const { disabled, readOnly, indeterminate, checked } = this.props + + const getIconColor = () => { + if (disabled) { + return 'disabledBaseColor' + } + if (readOnly) { + return 'baseColor' + } + return 'inverseColor' + } + + const iconColor = getIconColor() + + if (indeterminate) { + return renderIconWithProps(MinusInstUIIcon, 'sm', iconColor) + } else if (checked) { + return renderIconWithProps(CheckInstUIIcon, 'sm', iconColor) } else { return null } diff --git a/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/props.ts b/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/props.ts index e7a15da813..2a7efda6d7 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/props.ts +++ b/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/props.ts @@ -28,6 +28,8 @@ import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' type CheckboxFacadeOwnProps = { children: React.ReactNode checked?: boolean + disabled?: boolean + readOnly?: boolean focused?: boolean hovered?: boolean size?: 'small' | 'medium' | 'large' @@ -52,10 +54,13 @@ type CheckboxFacadeStyle = ComponentStyle<'checkboxFacade' | 'facade' | 'label'> const allowedProps: AllowedPropKeys = [ 'children', 'checked', + 'disabled', + 'readOnly', 'focused', 'hovered', 'size', - 'indeterminate' + 'indeterminate', + 'invalid' ] export type { CheckboxFacadeProps, CheckboxFacadeStyle } diff --git a/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/styles.ts b/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/styles.ts index 1e5a14f5ca..54e4ae06a0 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/styles.ts +++ b/packages/ui-checkbox/src/Checkbox/v2/CheckboxFacade/styles.ts @@ -42,7 +42,16 @@ const generateStyle = ( props: CheckboxFacadeProps, sharedTokens: SharedTokens ): CheckboxFacadeStyle => { - const { size, checked, focused, hovered, indeterminate, invalid } = props + const { + size, + checked, + disabled, + readOnly, + focused, + hovered, + indeterminate, + invalid + } = props const isChecked = checked || indeterminate @@ -73,17 +82,26 @@ const generateStyle = ( } } } + const sizeVariant = + sizeVariants[size as keyof typeof sizeVariants] ?? sizeVariants.medium - return { - checkboxFacade: { - label: 'checkboxFacade', - display: 'flex', - alignItems: 'flex-start' - }, - facade: { - label: 'checkboxFacade__facade', - color: '#FFFFFF', - background: componentTheme.backgroundColor, + const getLabelColor = () => { + if (disabled) { + return componentTheme.labelDisabledColor + } + + if (readOnly) { + return componentTheme.labelReadonlyColor + } + + // DEFAULT state + return hovered + ? componentTheme.labelHoverColor + : componentTheme.labelBaseColor + } + + const getFacadeStyles = () => { + const baseStyles = { position: 'relative', display: 'flex', alignItems: 'center', @@ -91,42 +109,99 @@ const generateStyle = ( boxSizing: 'border-box', flexShrink: 0, transition: 'all 0.2s', - border: `${componentTheme.borderWidth} solid ${ - invalid ? componentTheme.errorBorderColor : componentTheme.borderColor - }`, borderRadius: componentTheme.borderRadius, marginInlineEnd: componentTheme.gap, marginInlineStart: '0', - ...sizeVariants[size!].facade, + ...sizeVariant.facade + } + + if (disabled) { + return { + ...baseStyles, + background: componentTheme.backgroundDisabledColor, + border: `${componentTheme.borderWidth} solid ${componentTheme.borderDisabledColor}` + } + } + + if (readOnly) { + return { + ...baseStyles, + background: componentTheme.backgroundReadonlyColor, + border: `${componentTheme.borderWidth} solid ${componentTheme.borderReadonlyColor}`, + pointerEvents: 'none' + } + } + + if (invalid) { + return { + ...baseStyles, + ...(isChecked && { + background: componentTheme.backgroundCheckedColor, + border: `${componentTheme.borderWidth} solid ${ + hovered + ? componentTheme.errorBorderHoverColor + : componentTheme.errorBorderColor + }` + }), + ...(!isChecked && { + background: hovered + ? componentTheme.backgroundHoverColor + : componentTheme.backgroundColor, + border: `${componentTheme.borderWidth} solid ${ + hovered + ? componentTheme.errorBorderHoverColor + : componentTheme.errorBorderColor + }` + }) + } + } + + if (isChecked) { + return { + ...baseStyles, + background: componentTheme.backgroundCheckedColor, + border: `${componentTheme.borderWidth} solid ${componentTheme.borderCheckedColor}` + } + } + + // DEFAULT (unchecked) state + return { + ...baseStyles, + background: hovered + ? componentTheme.backgroundHoverColor + : componentTheme.backgroundColor, + border: `${componentTheme.borderWidth} solid ${ + hovered ? componentTheme.borderHoverColor : componentTheme.borderColor + }` + } + } + + return { + checkboxFacade: { + label: 'checkboxFacade', + display: 'flex', + alignItems: 'flex-start', + cursor: disabled ? 'not-allowed' : readOnly ? 'default' : 'pointer' + }, + facade: { + label: 'checkboxFacade__facade', + ...getFacadeStyles(), ...(sharedTokens?.focusOutline ? calcFocusOutlineStyles(sharedTokens.focusOutline, { withFocusOutline: focused }) - : {}), - ...(isChecked && { - background: componentTheme.backgroundCheckedColor, - borderColor: componentTheme.borderCheckedColor - }), - ...(!isChecked && - hovered && { - background: componentTheme.backgroundHoverColor, - borderColor: componentTheme.borderHoverColor - }) + : {}) }, label: { label: 'checkboxFacade__label', flex: '1 1 auto', alignSelf: 'center', minWidth: '0.0625rem', - color: componentTheme.labelBaseColor, + color: getLabelColor(), fontFamily: componentTheme.fontFamily, fontWeight: componentTheme.fontWeight, lineHeight: componentTheme.lineHeight, - ...sizeVariants[size!].label, - - ...(isChecked && { - color: componentTheme.labelBaseColor - }) + ...sizeVariant.label } } } diff --git a/packages/ui-checkbox/src/Checkbox/v2/README.md b/packages/ui-checkbox/src/Checkbox/v2/README.md index f1ac89192e..386de0c2ab 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/README.md +++ b/packages/ui-checkbox/src/Checkbox/v2/README.md @@ -162,29 +162,6 @@ type: example /> ``` -### Error messages - -Checkboxes can display error messages using the `messages` prop. This works for both the default checkbox and the toggle variant. - -```js ---- -type: example ---- - - - - -``` - ### Guidelines ```js diff --git a/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/index.tsx b/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/index.tsx index b134198a11..7b01f5c12f 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/index.tsx +++ b/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/index.tsx @@ -50,6 +50,7 @@ class ToggleFacade extends Component { static defaultProps = { checked: false, focused: false, + hovered: false, size: 'medium', disabled: false, readOnly: false, @@ -71,12 +72,27 @@ class ToggleFacade extends Component { } renderIcon() { - const { checked } = this.props + const { disabled, readOnly, checked } = this.props + + const getIconColor = () => { + if (disabled) { + return 'disabledBaseColor' + } + if (readOnly) { + return 'mutedColor' + } + if (checked) { + return 'successColor' + } + return 'baseColor' + } + + const iconColor = getIconColor() if (checked) { - return renderIconWithProps(CheckInstUIIcon, 'sm', 'successColor') + return renderIconWithProps(CheckInstUIIcon, 'xs', iconColor) } else { - return renderIconWithProps(XInstUIIcon, 'sm', 'baseColor') + return renderIconWithProps(XInstUIIcon, 'xs', iconColor) } } diff --git a/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/props.ts b/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/props.ts index fc05ce39c6..281331aea8 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/props.ts +++ b/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/props.ts @@ -32,6 +32,7 @@ type ToggleFacadeOwnProps = { disabled?: boolean readOnly?: boolean focused?: boolean + hovered?: boolean size?: 'small' | 'medium' | 'large' labelPlacement?: 'top' | 'start' | 'end' /** @@ -56,8 +57,10 @@ const allowedProps: AllowedPropKeys = [ 'disabled', 'readOnly', 'focused', + 'hovered', 'size', - 'labelPlacement' + 'labelPlacement', + 'invalid' ] export type { ToggleFacadeProps, ToggleFacadeStyle } diff --git a/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/styles.ts b/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/styles.ts index 29f994b7ea..f284e7df9a 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/styles.ts +++ b/packages/ui-checkbox/src/Checkbox/v2/ToggleFacade/styles.ts @@ -23,6 +23,7 @@ */ import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' +import { boxShadowObjectsToCSSString } from '@instructure/ui-themes' import { calcFocusOutlineStyles } from '@instructure/emotion' import type { ToggleFacadeProps, ToggleFacadeStyle } from './props' @@ -42,7 +43,16 @@ const generateStyle = ( props: ToggleFacadeProps, sharedTokens: SharedTokens ): ToggleFacadeStyle => { - const { disabled, size, checked, focused, labelPlacement, invalid } = props + const { + disabled, + size, + checked, + readOnly, + focused, + hovered, + labelPlacement, + invalid + } = props const labelPlacementVariants = { start: { @@ -69,6 +79,10 @@ const generateStyle = ( } } } + const labelPlacementVariant = + labelPlacementVariants[ + labelPlacement as keyof typeof labelPlacementVariants + ] ?? labelPlacementVariants.end const labelSizeVariants = { small: { @@ -84,47 +98,142 @@ const generateStyle = ( lineHeight: componentTheme.labelLineHeightLg } } + const sizeVariant = + labelSizeVariants[size as keyof typeof labelSizeVariants] ?? + labelSizeVariants.medium + const getIconBorderColor = () => { + if (disabled) { + return checked + ? componentTheme.checkedIconBorderDisabledColor + : componentTheme.uncheckedIconBorderDisabledColor + } + + if (readOnly) { + return checked + ? componentTheme.checkedIconBorderReadonlyColor + : componentTheme.uncheckedIconBorderReadonlyColor + } + + if (invalid && !checked) { + return hovered + ? componentTheme.uncheckedIconBorderHoverColor + : componentTheme.uncheckedIconErrorBorderColor + } + + if (checked) { + return hovered + ? componentTheme.checkedIconBorderHoverColor + : componentTheme.checkedIconBorderColor + } + + // DEFAULT (unchecked) state + return hovered + ? componentTheme.uncheckedIconBorderHoverColor + : componentTheme.uncheckedIconBorderColor + } + + const getFacadeStyles = () => { + const baseStyles = { + display: 'inline-block', + userSelect: 'none', + position: 'relative', + borderRadius: componentTheme.borderRadius, + verticalAlign: 'middle', + height: componentTheme.toggleSize, + width: componentTheme.toggleWidth, + ...labelPlacementVariant.facade + } + + if (disabled) { + return { + ...baseStyles, + background: checked + ? componentTheme.checkedBackgroundDisabledColor + : componentTheme.backgroundDisabledColor, + boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${ + checked + ? componentTheme.checkedBorderDisabledColor + : componentTheme.borderDisabledColor + }`, + opacity: componentTheme.disabledOpacity + } + } + + if (readOnly) { + return { + ...baseStyles, + background: checked + ? componentTheme.checkedBackgroundReadonlyColor + : componentTheme.backgroundReadonlyColor, + boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${ + checked + ? componentTheme.checkedBorderReadonlyColor + : componentTheme.borderReadonlyColor + }`, + pointerEvents: 'none' + } + } + + if (invalid) { + return { + ...baseStyles, + ...(checked && { + background: hovered + ? componentTheme.checkedBackgroundHoverColor + : componentTheme.checkedBackgroundColor, + boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${componentTheme.errorBorderColor}` + }), + ...(!checked && { + background: hovered + ? componentTheme.errorBackgroundHoverColor + : componentTheme.errorBackgroundColor, + boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${componentTheme.errorBorderColor}` + }) + } + } + + if (checked) { + return { + ...baseStyles, + background: hovered + ? componentTheme.checkedBackgroundHoverColor + : componentTheme.checkedBackgroundColor, + boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${ + hovered + ? componentTheme.checkedBorderHoverColor + : componentTheme.checkedBorderColor + }` + } + } + + // DEFAULT (unchecked) state + return { + ...baseStyles, + background: hovered + ? componentTheme.backgroundHoverColor + : componentTheme.backgroundColor, + boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${ + hovered ? componentTheme.borderHoverColor : componentTheme.borderColor + }` + } + } return { toggleFacade: { label: 'toggleFacade', display: 'flex', alignItems: 'center', + cursor: disabled ? 'not-allowed' : readOnly ? 'default' : 'pointer', ...(labelPlacement === 'top' && { display: 'block' }) }, facade: { label: 'toggleFacade__facade', - background: componentTheme.toggleBackground, - borderColor: componentTheme.borderColor, - cursor: 'pointer', - display: 'inline-block', - userSelect: 'none', - position: 'relative', - borderRadius: '3rem', - verticalAlign: 'middle', - boxShadow: `inset 0 0 0 ${componentTheme.borderWidth} ${ - invalid && !checked - ? componentTheme.errorBorderColor - : componentTheme.borderColor - }`, - height: componentTheme.toggleSize, - width: `calc(${componentTheme.toggleSize} * 1.5)`, - ...labelPlacementVariants[labelPlacement!].facade, + ...getFacadeStyles(), ...(sharedTokens?.focusOutline ? calcFocusOutlineStyles(sharedTokens.focusOutline, { withFocusOutline: focused }) - : {}), - - ...(checked && { - background: componentTheme.checkedBackgroundColor, - boxShadow: 'none' - }), - - ...(disabled && { - cursor: 'not-allowed', - pointerEvents: 'none' - }) + : {}) }, icon: { @@ -137,14 +246,13 @@ const generateStyle = ( insetInlineEnd: 'auto', transition: 'all 0.2s', transform: 'translate3d(0, 0, 0)', - fontSize: '0.875rem', height: componentTheme.toggleSize, width: componentTheme.toggleSize, ...(checked && { - transform: 'translate3d(50%, 0, 0)', + transform: `translateX(calc(${componentTheme.toggleWidth} - ${componentTheme.toggleSize}))`, '[dir="rtl"] &': { - transform: 'translate3d(-50%, 0, 0)' + transform: `translateX(calc(-1 * (${componentTheme.toggleWidth} - ${componentTheme.toggleSize})))` } }) }, @@ -164,13 +272,9 @@ const generateStyle = ( height: `calc(100% - (${componentTheme.borderWidth} * 6))`, width: `calc(100% - (${componentTheme.borderWidth} * 6))`, background: componentTheme.toggleBackground, - boxShadow: componentTheme.toggleShadow, + boxShadow: boxShadowObjectsToCSSString(componentTheme.toggleShadow), borderRadius: '100%', - border: `${componentTheme.borderWidth} solid ${ - checked - ? componentTheme.checkedIconBorderColor - : componentTheme.uncheckedIconBorderColor - }` + border: `${componentTheme.borderWidth} solid ${getIconBorderColor()}` }, '& [class*="lucideIcon"] svg': { @@ -183,11 +287,13 @@ const generateStyle = ( label: 'toggleFacade__label', flex: 1, minWidth: '0.0625rem', - color: componentTheme.labelColor, + color: disabled + ? componentTheme.labelDisabledColor + : componentTheme.labelColor, fontFamily: componentTheme.labelFontFamily, fontWeight: componentTheme.labelFontWeight, - ...labelSizeVariants[size!], - ...labelPlacementVariants[labelPlacement!].label + ...sizeVariant, + ...labelPlacementVariant.label } } } diff --git a/packages/ui-checkbox/src/Checkbox/v2/__tests__/Checkbox.test.tsx b/packages/ui-checkbox/src/Checkbox/v2/__tests__/Checkbox.test.tsx index 69a1148288..49eb1c7919 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/__tests__/Checkbox.test.tsx +++ b/packages/ui-checkbox/src/Checkbox/v2/__tests__/Checkbox.test.tsx @@ -154,9 +154,19 @@ describe('', () => { await waitFor(() => { expect(onClick).not.toHaveBeenCalled() expect(onChange).not.toHaveBeenCalled() + expect(checkboxElement).not.toBeDisabled() }) }) + it('when focused, readOnly checkbox is focusable', async () => { + renderCheckbox({ readOnly: true }) + const checkboxElement = screen.getByRole('checkbox') + + checkboxElement.focus() + + expect(document.activeElement).toBe(checkboxElement) + }) + it('calls onChange when enter key is pressed', async () => { const onChange = vi.fn() renderCheckbox({ onChange }) diff --git a/packages/ui-checkbox/src/Checkbox/v2/index.tsx b/packages/ui-checkbox/src/Checkbox/v2/index.tsx index 849bb9e1eb..2ddf2067c7 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/index.tsx +++ b/packages/ui-checkbox/src/Checkbox/v2/index.tsx @@ -226,6 +226,7 @@ class Checkbox extends Component { { } else { return ( } invalid={this.invalid} @@ -331,9 +334,17 @@ class Checkbox extends Component { type="checkbox" ref={this.handleInputRef} required={isRequired} - disabled={disabled || readOnly} + disabled={disabled} + readOnly={readOnly} + aria-readonly={readOnly ? true : undefined} aria-checked={indeterminate ? 'mixed' : undefined} css={styles?.input} + onClickCapture={(e) => { + if (readOnly) { + e.stopPropagation() + e.preventDefault() + } + }} onChange={this.handleChange} onKeyDown={createChainedFunction(onKeyDown, this.handleKeyDown)} onFocus={createChainedFunction(onFocus, this.handleFocus)} diff --git a/packages/ui-checkbox/src/Checkbox/v2/styles.ts b/packages/ui-checkbox/src/Checkbox/v2/styles.ts index 04e0ff7d02..ab0a787341 100644 --- a/packages/ui-checkbox/src/Checkbox/v2/styles.ts +++ b/packages/ui-checkbox/src/Checkbox/v2/styles.ts @@ -40,7 +40,7 @@ const generateStyle = ( componentTheme: NewComponentTypes['Checkbox'], props: CheckboxProps ): CheckboxStyle => { - const { inline, disabled, size, variant } = props + const { inline, size, variant } = props // toggleFullWidth calculation based on Toggle facade width (1.625rem * 1.5) and the marginInlineEnd (0.75rem) const toggleFullWidth = `calc(1.625rem * 1.5 + 0.75rem)` @@ -65,26 +65,23 @@ const generateStyle = ( : `calc(${componentTheme.gap} + ${componentTheme.controlSizeLg})` } } + const sizeVariant = + sizeVariants[size as keyof typeof sizeVariants] ?? sizeVariants.medium return { requiredInvalid: { color: componentTheme.asteriskColor }, indentedError: { - paddingLeft: sizeVariants[size!].paddingLeft + paddingLeft: sizeVariant.paddingLeft }, indentedToggleError: { - paddingLeft: sizeVariants[size!].paddingLeft + paddingLeft: sizeVariant.paddingLeft }, checkbox: { label: 'checkbox', position: 'relative', width: '100%', - ...(disabled && { - cursor: 'not-allowed', - pointerEvents: 'none', - opacity: 0.5 - }), ...(inline && { display: 'inline-block', verticalAlign: 'middle', diff --git a/packages/ui-checkbox/src/CheckboxGroup/v2/__tests__/CheckboxGroup.test.tsx b/packages/ui-checkbox/src/CheckboxGroup/v2/__tests__/CheckboxGroup.test.tsx index 89e6acafae..01f1febdf6 100644 --- a/packages/ui-checkbox/src/CheckboxGroup/v2/__tests__/CheckboxGroup.test.tsx +++ b/packages/ui-checkbox/src/CheckboxGroup/v2/__tests__/CheckboxGroup.test.tsx @@ -127,7 +127,7 @@ describe('', () => { await waitFor(() => { expect(onChange).not.toHaveBeenCalled() - expect(checkboxElement).toBeDisabled() + expect(checkboxElement).not.toBeDisabled() }) }) diff --git a/packages/ui-checkbox/src/CheckboxGroup/v2/props.ts b/packages/ui-checkbox/src/CheckboxGroup/v2/props.ts index 0a305f266d..ba3e7e9090 100644 --- a/packages/ui-checkbox/src/CheckboxGroup/v2/props.ts +++ b/packages/ui-checkbox/src/CheckboxGroup/v2/props.ts @@ -27,8 +27,8 @@ import type { FormMessage } from '@instructure/ui-form-field' import type { OtherHTMLAttributes } from '@instructure/shared-types' import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' -import { Checkbox } from '../../Checkbox/v1' -import type { CheckboxProps } from '../../Checkbox/v1/props' +import { Checkbox } from '../../Checkbox/v2' +import type { CheckboxProps } from '../../Checkbox/v2/props' type CheckboxChild = React.ComponentElement