Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/form-control-icon-glyphs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feature] Add checkbox and radio state glyphs to the icon registry and route form-control internals through component icon slots.

@cixzhang
27 changes: 27 additions & 0 deletions apps/storybook/stories/CheckboxInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -494,3 +495,29 @@ export const DisabledWithMessage: Story = {
disabledMessage: 'Terms are managed by your administrator',
},
};

const formControlIconTheme = defineTheme({
name: 'form-control-icon-demo',
icons: {
checkboxChecked: <span style={{fontSize: 12, lineHeight: 1}}>★</span>,
checkboxIndeterminate: <span style={{fontSize: 12, lineHeight: 1}}>–</span>,
},
componentIcons: {
'checkbox-checked': 'checkboxChecked',
'checkbox-indeterminate': 'checkboxIndeterminate',
},
});

export const ThemedStateGlyphs: Story = {
render: () => (
<Theme theme={formControlIconTheme} mode="light">
<div style={{display: 'grid', gap: 12}}>
<CheckboxInput label="Custom checked glyph" value={true} />
<CheckboxInput
label="Custom indeterminate glyph"
value="indeterminate"
/>
</div>
</Theme>
),
};
7 changes: 6 additions & 1 deletion packages/cli/assets/docs/icons.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export const docs = {
['chevronDown', 'Dropdown triggers, expand/collapse'],
['chevronLeft', 'Navigate back, previous'],
['chevronRight', 'Navigate forward, next'],
['check', 'Checkbox checked, confirm'],
['check', 'Checkmark / confirm / selected option'],
['checkboxUnchecked', 'Full unchecked checkbox control'],
['checkboxChecked', 'Full checked checkbox control with checkmark'],
['checkboxIndeterminate', 'Full indeterminate checkbox control with mixed-state bar'],
['radioUnchecked', 'Full unselected radio control'],
['radioChecked', 'Full selected radio control with center dot'],
['success', 'Success status indicator'],
['error', 'Error status indicator'],
['warning', 'Warning status indicator'],
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/CheckboxInput/CheckboxInput.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,23 @@ export const docs = {
{className: 'astryx-checkbox-input', visualProps: ['size']},
{className: 'astryx-checkbox', visualProps: ['size'], states: ['checked', 'disabled']},
],
icons: [
{
slot: 'checkbox-unchecked',
default: 'checkboxUnchecked',
description: 'Full control glyph shown when CheckboxInput is unchecked.',
},
{
slot: 'checkbox-checked',
default: 'checkboxChecked',
description: 'Full control glyph shown when CheckboxInput is checked.',
},
{
slot: 'checkbox-indeterminate',
default: 'checkboxIndeterminate',
description: 'Full control glyph shown when CheckboxInput is indeterminate.',
},
],
},
usage: {
description: 'CheckboxInput toggles a single on/off value. Use it for settings like "Enable notifications", terms acceptance, or opt-in choices. For multiple checkboxes in a group, use CheckboxList instead.',
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/CheckboxInput/CheckboxInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CheckboxInput label="All" value="indeterminate" onChange={() => {}} />,
);
// 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', () => {
Expand Down
144 changes: 18 additions & 126 deletions packages/core/src/CheckboxInput/CheckboxInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {useIcon, type IconType} from '../Icon';
import type {InputStatus} from '../Field/types';
import {Spinner} from '../Spinner';
import {useTooltip} from '../Tooltip';
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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',
Expand Down Expand Up @@ -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<BaseProps, 'onChange'> {
Expand Down Expand Up @@ -443,6 +351,13 @@ export function CheckboxInput({
isEnabled: showsDisabledMessage,
});

const uncheckedIcon = useIcon('checkbox-unchecked', 'checkboxUnchecked');
const checkedIcon = useIcon('checkbox-checked', 'checkboxChecked');
const indeterminateIcon = useIcon(
'checkbox-indeterminate',
'checkboxIndeterminate',
);

const isIndeterminate = optimisticValue === 'indeterminate';
const isChecked = optimisticValue === true;
const isCheckedOrIndeterminate = isChecked || isIndeterminate;
Expand Down Expand Up @@ -567,38 +482,15 @@ export function CheckboxInput({
? styles.checkboxChecked
: styles.checkboxUnchecked,
isDisabled && styles.checkboxDisabled,
isDisabled &&
!isCheckedOrIndeterminate &&
styles.checkboxDisabledUnchecked,
),
)}>
{isBusy ? (
<Spinner size="sm" shade="inherit" />
) : (
<>
<svg
viewBox="0 0 10 10"
{...stylex.props(
styles.checkmark,
checkmarkSizeStyles[size],
isChecked && styles.checkmarkVisible,
)}>
<path
d="M8.5 2.5L4 7.5L1.5 5"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div
{...stylex.props(
styles.indeterminateMark,
indeterminateSizeStyles[size],
isIndeterminate && styles.indeterminateMarkVisible,
)}
/>
{isChecked && checkedIcon}
{isIndeterminate && indeterminateIcon}
{!isCheckedOrIndeterminate && uncheckedIcon}
</>
)}
</div>
Expand Down
26 changes: 24 additions & 2 deletions packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,41 @@ 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'],
},
{className: 'astryx-dropdown-menu-section-heading'},
{className: 'astryx-dropdown-menu-divider'},
{className: 'astryx-dropdown-menu-indicator-icon'},
],
icons: [
{
slot: 'dropdown-menu-checkbox-unchecked',
default: 'checkboxUnchecked',
description: 'Full control glyph shown in unchecked DropdownMenuCheckboxItem rows.',
},
{
slot: 'dropdown-menu-checkbox-checked',
default: 'checkboxChecked',
description: 'Full control glyph shown in checked DropdownMenuCheckboxItem rows.',
},
{
slot: 'dropdown-menu-radio-unchecked',
default: 'radioUnchecked',
description: 'Full control glyph shown in unselected DropdownMenuRadioItem rows.',
},
{
slot: 'dropdown-menu-radio-checked',
default: 'radioChecked',
description: 'Full control glyph shown in selected DropdownMenuRadioItem rows.',
},
],
vars: [
{name: '--_dropdown-menu-radius', description: 'Border radius of the menu popup', default: 'var(--radius-element)', private: true},
{name: '--_dropdown-menu-padding', description: 'Inner padding of the menu popup', default: 'var(--spacing-1)', private: true},
Expand Down
Loading
Loading