diff --git a/.changeset/control-icons.md b/.changeset/control-icons.md new file mode 100644 index 000000000000..db56c8bf68b1 --- /dev/null +++ b/.changeset/control-icons.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Add themeable control icon renderers for stateful checkbox and radio visuals. + +@cixzhang diff --git a/.changeset/form-control-icon-glyphs.md b/.changeset/form-control-icon-glyphs.md new file mode 100644 index 000000000000..34f3898dcccf --- /dev/null +++ b/.changeset/form-control-icon-glyphs.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Route checkbox and radio internals through themeable control icon renderers and demonstrate custom control icons. + +@cixzhang diff --git a/apps/storybook/stories/CheckboxInput.stories.tsx b/apps/storybook/stories/CheckboxInput.stories.tsx index 3763cded3b10..1650efcf7cac 100644 --- a/apps/storybook/stories/CheckboxInput.stories.tsx +++ b/apps/storybook/stories/CheckboxInput.stories.tsx @@ -3,6 +3,7 @@ import {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {CheckboxInput} from '@astryxdesign/core/CheckboxInput'; +import {Theme, defineTheme} from '@astryxdesign/core/theme'; import { BellIcon, EnvelopeIcon, @@ -494,3 +495,38 @@ export const DisabledWithMessage: Story = { disabledMessage: 'Terms are managed by your administrator', }, }; + +const formControlIconTheme = defineTheme({ + name: 'form-control-icon-demo', + controlIcons: { + checkbox: ({state, size}) => ( + + {state === 'checked' ? '★' : state === 'indeterminate' ? '–' : ''} + + ), + }, +}); + +export const ThemedStateGlyphs: Story = { + render: () => ( + +
+ + +
+
+ ), +}; diff --git a/packages/core/src/CheckboxInput/CheckboxInput.test.tsx b/packages/core/src/CheckboxInput/CheckboxInput.test.tsx index 9f511cf6110d..9937fd0ba74f 100644 --- a/packages/core/src/CheckboxInput/CheckboxInput.test.tsx +++ b/packages/core/src/CheckboxInput/CheckboxInput.test.tsx @@ -582,13 +582,13 @@ describe('CheckboxInput', () => { // compiled output includes the forced-colors rule; visual behavior needs // manual verification under Windows High Contrast. describe('forced colors (WCAG 1.4.11)', () => { - it('compiles a forced-colors fill so the indeterminate mark survives Windows High Contrast', () => { + it('compiles a forced-colors color so the indeterminate mark survives Windows High Contrast', () => { render( {}} />, ); - // The painted indeterminate bar would be stripped to Canvas (invisible); + // The indeterminate glyph would be stripped to Canvas (invisible); // CanvasText keeps it perceivable. - expect(getForcedColorsRules()).toContain('background-color: canvastext;'); + expect(getForcedColorsRules()).toContain('color: canvastext;'); }); it('compiles a forced-colors color so the checkmark survives Windows High Contrast', () => { diff --git a/packages/core/src/CheckboxInput/CheckboxInput.tsx b/packages/core/src/CheckboxInput/CheckboxInput.tsx index 696c0f686245..f964f66096d1 100644 --- a/packages/core/src/CheckboxInput/CheckboxInput.tsx +++ b/packages/core/src/CheckboxInput/CheckboxInput.tsx @@ -30,19 +30,15 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, spacingVars, - radiusVars, - durationVars, - easeVars, typographyVars, typeScaleVars, fontWeightVars, - borderVars, } from '../theme/tokens.stylex'; import type {BaseProps} from '../BaseProps'; import type {SizeValue} from '../utils/types'; import {FieldLabel} from '../Field/FieldLabel'; import {FieldStatus} from '../FieldStatus/FieldStatus'; -import type {IconType} from '../Icon'; +import {useControlIcon, type IconType} from '../Icon'; import type {InputStatus} from '../Field/types'; import {Spinner} from '../Spinner'; import {useTooltip} from '../Tooltip'; @@ -83,15 +79,10 @@ const styles = stylex.create({ display: 'flex', alignItems: 'center', justifyContent: 'center', - borderWidth: borderVars['--border-width'], - borderStyle: 'solid', - borderRadius: radiusVars['--radius-inner'], - transitionProperty: 'background-color, border-color', - transitionDuration: { - default: durationVars['--duration-fast'], - '@media (prefers-reduced-motion: reduce)': '0s', + color: { + default: colorVars['--color-on-accent'], + '@media (forced-colors: active)': 'CanvasText', }, - transitionTimingFunction: easeVars['--ease-standard'], }, checkboxFocus: { outline: { @@ -104,39 +95,16 @@ const styles = stylex.create({ [stylex.when.ancestor(':has(:focus-visible)', checkboxScope)]: '2px', }, }, - // State-dependent colors with ancestor hover behavior checkboxUnchecked: { // Foreground for the inherit-shade loading spinner (reads currentColor): // brand accent on the light surface fill. color: colorVars['--color-accent'], - borderColor: { - default: colorVars['--color-border-emphasized'], - [stylex.when.ancestor(':hover', checkboxScope)]: { - '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-border-emphasized']}, ${colorVars['--color-tint-hover']} 20%)`, - }, - }, - backgroundColor: { - default: colorVars['--color-background-surface'], - [stylex.when.ancestor(':hover', checkboxScope)]: { - '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-background-surface']}, ${colorVars['--color-tint-hover']} 5%)`, - }, - }, }, checkboxChecked: { - // Foreground for the inherit-shade loading spinner (reads currentColor): - // on-accent color against the accent fill. - color: colorVars['--color-on-accent'], - borderColor: { - default: colorVars['--color-accent'], - [stylex.when.ancestor(':hover', checkboxScope)]: { - '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`, - }, - }, - backgroundColor: { - default: colorVars['--color-accent'], - [stylex.when.ancestor(':hover', checkboxScope)]: { - '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`, - }, + // Foreground for the inherit-shade loading spinner and registry glyphs. + color: { + default: colorVars['--color-on-accent'], + '@media (forced-colors: active)': 'CanvasText', }, }, checkboxDisabled: { @@ -148,44 +116,6 @@ const styles = stylex.create({ }, }, }, - checkboxDisabledUnchecked: { - backgroundColor: { - default: colorVars['--color-background-muted'], - [stylex.when.ancestor(':hover', checkboxScope)]: { - '@media (hover: hover)': colorVars['--color-background-muted'], - }, - }, - }, - checkmark: { - display: 'none', - color: { - default: colorVars['--color-on-accent'], - // Forced colors (Windows High Contrast) does not reliably force an SVG - // stroke painted with currentColor, so the check stays the same white as - // the flattened (Canvas) box fill — a white check on a white box. - // CanvasText keeps it perceivable on the Canvas box, matching the - // indeterminate mark (WCAG 1.4.11). - '@media (forced-colors: active)': 'CanvasText', - }, - }, - checkmarkVisible: { - display: 'block', - }, - indeterminateMark: { - display: 'none', - backgroundColor: { - default: colorVars['--color-on-accent'], - // Forced colors (Windows High Contrast) strips painted backgrounds, - // which would make the indeterminate bar invisible; CanvasText keeps it - // perceivable on the Canvas box fill (WCAG 1.4.11). The checkmark carries - // the matching CanvasText treatment on its own style. - '@media (forced-colors: active)': 'CanvasText', - }, - borderRadius: radiusVars['--radius-full'], - }, - indeterminateMarkVisible: { - display: 'block', - }, labelWrapper: { display: 'flex', flexDirection: 'column', @@ -222,28 +152,6 @@ const checkboxSizeStyles = stylex.create({ }, }); -const checkmarkSizeStyles = stylex.create({ - sm: { - width: 12, - height: 12, - }, - md: { - width: 14, - height: 14, - }, -}); - -const indeterminateSizeStyles = stylex.create({ - sm: { - width: 10, - height: 2, - }, - md: { - width: 12, - height: 2, - }, -}); - export type CheckboxInputSize = keyof typeof wrapperSizeStyles; export interface CheckboxInputProps extends Omit { @@ -443,6 +351,8 @@ export function CheckboxInput({ isEnabled: showsDisabledMessage, }); + const CheckboxControlIcon = useControlIcon('checkbox'); + const isIndeterminate = optimisticValue === 'indeterminate'; const isChecked = optimisticValue === true; const isCheckedOrIndeterminate = isChecked || isIndeterminate; @@ -567,39 +477,22 @@ export function CheckboxInput({ ? styles.checkboxChecked : styles.checkboxUnchecked, isDisabled && styles.checkboxDisabled, - isDisabled && - !isCheckedOrIndeterminate && - styles.checkboxDisabledUnchecked, ), )}> {isBusy ? ( ) : ( - <> - - - -
- + )}
diff --git a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs index cd2456726008..0664dd0f7810 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs +++ b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs @@ -26,12 +26,12 @@ export const docs = { {className: 'astryx-dropdown-menu'}, {className: 'astryx-dropdown-menu-item', visualProps: ['size']}, { - className: 'astryx-dropdown-menu-radio', + className: 'astryx-dropdown-menu-checkbox', visualProps: ['size'], states: ['checked', 'disabled'], }, { - className: 'astryx-dropdown-menu-radio-dot', + className: 'astryx-dropdown-menu-radio', visualProps: ['size'], states: ['checked', 'disabled'], }, diff --git a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx index fe24bbd57113..77a6808aa899 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx @@ -15,24 +15,17 @@ * Enter/Space activation come from the parent DropdownMenu's useListFocus + * activation path, which matches menuitemcheckbox alongside plain menuitem rows. * - * The checkbox visual composes the real CheckboxInput primitive so its checkmark - * matches CheckboxListItem and picks up the standard `checkbox` theming slots. - * It is purely decorative: the composed control is wrapped in an element that is - * both `aria-hidden` and `inert`, so it contributes nothing to the row's - * accessible name, and its native and sr-only label stay out of the tab - * order and the accessibility tree while pointer clicks fall through to the row - * — the same shim MultiSelector uses. The row owns the checked state and - * accessible name. The control size is derived from the menu's item size (a - * `sm` menu gets the compact control; `md`/`lg` get the standard one) and it - * swaps to the inline-end of the row on coarse-pointer (touch) devices via CSS - * `order`, so it lands where selection toggles are conventionally placed on - * mobile. + * The checkbox visual is decorative: the row owns the role, checked state, and + * accessible name. The glyph resolves through a component icon slot so themes can + * align menu selection marks with CheckboxInput or intentionally diverge. The + * control size is derived from the menu's item size and swaps to the inline-end + * of the row on coarse-pointer (touch) devices via CSS `order`, so it lands + * where selection toggles are conventionally placed on mobile. */ import {useCallback, type PointerEvent, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; -import {renderIconSlot, type IconType} from '../Icon'; -import {CheckboxInput} from '../CheckboxInput/CheckboxInput'; +import {renderIconSlot, useControlIcon, type IconType} from '../Icon'; import {Item} from '../Item'; import {useDropdownMenuContext} from './DropdownMenuContext'; import {focusMenuItemOnHover} from './menuItemHover'; @@ -63,6 +56,8 @@ const styles = stylex.create({ // which owns the role and activation. markerBox: { display: 'flex', + alignItems: 'center', + justifyContent: 'center', flexShrink: 0, pointerEvents: 'none', order: { @@ -76,6 +71,11 @@ const styles = stylex.create({ }, }); +const markerSizeStyles = stylex.create({ + sm: {width: 18, height: 18}, + md: {width: 22, height: 22}, +}); + export interface DropdownMenuCheckboxItemProps extends Omit< BaseProps, 'onChange' | 'role' | 'aria-checked' | 'tabIndex' @@ -157,12 +157,7 @@ export function DropdownMenuCheckboxItem({ const ctx = useDropdownMenuContext(); const menuSize = ctx?.menuSize ?? 'md'; const controlSize = menuSize === 'sm' ? 'sm' : 'md'; - - // The composed checkbox is decorative and inert, so its label never reaches - // the accessibility tree — the row's `label` prop provides the announced - // name. CheckboxInput still requires a string label, so pass one through when - // the row label is a plain string. - const checkboxLabel = typeof label === 'string' ? label : ''; + const CheckboxControlIcon = useControlIcon('checkbox'); const handleClick = useCallback(() => { if (isDisabled) { @@ -187,15 +182,22 @@ export function DropdownMenuCheckboxItem({ tabIndex={isDisabled ? undefined : -1} onPointerMove={handlePointerMove} marker={ - + } startContent={ icon diff --git a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx index ffd2ac2537a7..87f91417acba 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx @@ -23,17 +23,11 @@ import {useCallback, type PointerEvent, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; -import {renderIconSlot, type IconType} from '../Icon'; +import {renderIconSlot, useControlIcon, type IconType} from '../Icon'; import {Item} from '../Item'; import {useDropdownMenuContext} from './DropdownMenuContext'; import {focusMenuItemOnHover} from './menuItemHover'; -import { - colorVars, - spacingVars, - durationVars, - easeVars, - borderVars, -} from '../theme/tokens.stylex'; +import {colorVars, spacingVars} from '../theme/tokens.stylex'; import {mergeProps, themeProps} from '../utils'; import type {BaseProps} from '../BaseProps'; import {useDropdownMenuRadioGroupContext} from './DropdownMenuContext'; @@ -61,16 +55,6 @@ const styles = stylex.create({ alignItems: 'center', justifyContent: 'center', flexShrink: 0, - boxSizing: 'border-box', - borderWidth: borderVars['--border-width'], - borderStyle: 'solid', - borderRadius: '50%', - transitionProperty: 'background-color, border-color', - transitionDuration: { - default: durationVars['--duration-fast'], - '@media (prefers-reduced-motion: reduce)': '0s', - }, - transitionTimingFunction: easeVars['--ease-standard'], order: { default: 0, '@media (pointer: coarse)': 1, @@ -80,18 +64,6 @@ const styles = stylex.create({ '@media (pointer: coarse)': 'auto', }, }, - unchecked: { - borderColor: colorVars['--color-border-emphasized'], - backgroundColor: colorVars['--color-background-surface'], - }, - checked: { - borderColor: colorVars['--color-accent'], - backgroundColor: colorVars['--color-accent'], - }, - dot: { - borderRadius: '50%', - backgroundColor: colorVars['--color-on-accent'], - }, }); const circleSizeStyles = stylex.create({ @@ -99,11 +71,6 @@ const circleSizeStyles = stylex.create({ md: {width: 22, height: 22}, }); -const dotSizeStyles = stylex.create({ - sm: {width: 6, height: 6}, - md: {width: 8, height: 8}, -}); - export interface DropdownMenuRadioItemProps extends Omit< BaseProps, 'role' | 'aria-checked' | 'tabIndex' @@ -173,6 +140,7 @@ export function DropdownMenuRadioItem({ const menuSize = menuCtx?.menuSize ?? 'md'; const controlSize = menuSize === 'sm' ? 'sm' : 'md'; const isChecked = groupCtx.value === value; + const RadioControlIcon = useControlIcon('radio'); const handleClick = useCallback(() => { if (isDisabled) { @@ -205,24 +173,13 @@ export function DropdownMenuRadioItem({ checked: isChecked ? 'checked' : null, disabled: isDisabled ? 'disabled' : null, }), - stylex.props( - styles.circle, - circleSizeStyles[controlSize], - isChecked ? styles.checked : styles.unchecked, - ), + stylex.props(styles.circle, circleSizeStyles[controlSize]), )}> - {isChecked && ( - - )} + } startContent={ diff --git a/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx b/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx index 5f2df0473ad5..e6fc8d1764bb 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx @@ -78,7 +78,7 @@ describe('DropdownMenuCheckboxItem', () => { expect(onChangeSpy).toHaveBeenCalledWith(true); }); - it('keeps the composed checkbox decorative (row is the only announced control)', async () => { + it('keeps the checkbox glyph decorative (row is the only announced control)', async () => { const user = userEvent.setup(); render( @@ -92,21 +92,14 @@ describe('DropdownMenuCheckboxItem', () => { screen.getAllByRole('menuitemcheckbox', {hidden: true}), ).toHaveLength(1); - // The composed CheckboxInput is present in the DOM but sits inside an - // `aria-hidden` + `inert` subtree: it contributes nothing to the row's - // accessible name and its native is out of the tab order and the - // accessibility tree, so it is not a second announced/focusable control. - // (Browsers enforce inert; jsdom does not model its a11y removal, so this - // asserts the aria-hidden/inert boundary directly rather than via role.) const row = screen.getByRole('menuitemcheckbox', { name: /Show archived/, hidden: true, }); - const input = row.querySelector('input[type="checkbox"]'); - expect(input).not.toBeNull(); - const marker = input?.closest('[inert]'); - expect(marker).not.toBeNull(); + const marker = row.querySelector('.astryx-dropdown-menu-checkbox'); + expect(marker).toBeInTheDocument(); expect(marker).toHaveAttribute('aria-hidden', 'true'); + expect(row.querySelector('input[type="checkbox"]')).toBeNull(); }); it('does not toggle when disabled', async () => { @@ -177,19 +170,20 @@ describe('DropdownMenuRadioGroup / RadioItem', () => { name: 'Newest', hidden: true, }); - const dot = checked.querySelector('.astryx-dropdown-menu-radio-dot'); - expect(dot).toBeInTheDocument(); - // Mirrors the radio container's visual props/states for consistent theming. - expect(dot).toHaveAttribute('data-size', 'md'); - expect(dot).toHaveAttribute('data-checked', 'checked'); - // The unchecked radio has no dot, so no dot slot either. + const checkedControl = checked.querySelector('.astryx-dropdown-menu-radio'); + expect(checkedControl).toBeInTheDocument(); + expect(checkedControl).toHaveAttribute('data-size', 'md'); + expect(checkedControl).toHaveAttribute('data-checked', 'checked'); + const unchecked = screen.getByRole('menuitemradio', { name: 'Oldest', hidden: true, }); - expect( - unchecked.querySelector('.astryx-dropdown-menu-radio-dot'), - ).not.toBeInTheDocument(); + const uncheckedControl = unchecked.querySelector( + '.astryx-dropdown-menu-radio', + ); + expect(uncheckedControl).toBeInTheDocument(); + expect(uncheckedControl).not.toHaveAttribute('data-checked'); }); it('calls onChange with the selected value', async () => { diff --git a/packages/core/src/Icon/controlIconRegistry.ts b/packages/core/src/Icon/controlIconRegistry.ts new file mode 100644 index 000000000000..684992194c62 --- /dev/null +++ b/packages/core/src/Icon/controlIconRegistry.ts @@ -0,0 +1,33 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file controlIconRegistry.ts + * @input Theme source and control icon name + * @output Exports getControlIcon/useControlIcon backing helpers + * @position Server-safe control icon resolver for stateful checkbox/radio visuals + */ + +import type {DefinedTheme} from '../theme/defineTheme'; +import {getRegisteredTheme} from '../theme/themeRegistry'; +import { + defaultControlIcons, + type ControlIconName, + type ControlIconRenderer, +} from './controlIcons'; + +export type ControlIconRegistrySource = + DefinedTheme | string | null | undefined; + +function getTheme(source: ControlIconRegistrySource): DefinedTheme | null { + if (source == null) { + return null; + } + return typeof source === 'string' ? getRegisteredTheme(source) : source; +} + +export function getControlIcon( + name: ControlIconName, + source?: ControlIconRegistrySource, +): ControlIconRenderer { + return getTheme(source)?.controlIcons?.[name] ?? defaultControlIcons[name]; +} diff --git a/packages/core/src/Icon/controlIcons.tsx b/packages/core/src/Icon/controlIcons.tsx new file mode 100644 index 000000000000..e7a2eb0558ed --- /dev/null +++ b/packages/core/src/Icon/controlIcons.tsx @@ -0,0 +1,205 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file controlIcons.tsx + * @input Control icon state props + * @output Default checkbox/radio control icon renderers and control icon types + * @position Stateful control-icon layer used for checkbox/radio visuals + */ + +import type {ReactNode} from 'react'; + +export interface ControlIconRenderArgs { + state: 'unchecked' | 'checked' | 'indeterminate'; + size?: 'sm' | 'md' | (string & {}); + isDisabled?: boolean; + isHovered?: boolean; + isPressed?: boolean; +} + +export type ControlIconRenderer = (props: ControlIconRenderArgs) => ReactNode; + +export interface ControlIconMap { + checkbox: true; + radio: true; +} + +export type ControlIconName = keyof ControlIconMap & string; +export type ControlIconRegistry = Partial< + Record +>; + +const controlSize = (size: ControlIconRenderArgs['size']): number => + size === 'sm' ? 20 : 24; + +const boxStyle = { + boxSizing: 'border-box', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, +} as const; + +function checkboxColor( + state: ControlIconRenderArgs['state'], + isDisabled?: boolean, +) { + if (isDisabled && state === 'unchecked') { + return { + borderColor: 'var(--color-border)', + backgroundColor: 'var(--color-background-muted)', + color: 'var(--color-on-accent)', + opacity: 0.5, + }; + } + if (isDisabled) { + return { + borderColor: 'var(--color-border)', + backgroundColor: 'var(--color-accent)', + color: 'var(--color-on-accent)', + opacity: 0.5, + }; + } + if (state === 'unchecked') { + return { + borderColor: 'var(--color-border-emphasized)', + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-accent)', + opacity: 1, + }; + } + return { + borderColor: 'var(--color-accent)', + backgroundColor: 'var(--color-accent)', + color: 'var(--color-on-accent)', + opacity: 1, + }; +} + +export function defaultCheckboxControlIcon({ + state, + size = 'md', + isDisabled, +}: ControlIconRenderArgs): ReactNode { + const px = controlSize(size); + const colors = checkboxColor(state, isDisabled); + + return ( +