Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/olive-jars-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
114 changes: 59 additions & 55 deletions packages/ui/src/mosaic/components/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ const iconFadedNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative'
const iconFadedOnPrimary = `color-mix(in oklab, ${colorVars['--cl-color-primary-foreground']}, ${colorVars['--cl-color-primary']} 40%)`;
const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative-foreground']}, ${colorVars['--cl-color-negative']} 40%)`;

// Interactive states are gated on `:enabled`: the `disabled` attribute blocks activation but
// not matching, and the button stays hit-testable so `cursor: not-allowed` renders and a
// wrapping tooltip still gets the pointer. Disabled keeps its resting fill and only dims.
// Interactive states are gated on `:not([data-disabled])`: the button stays hit-testable while
// disabled so `cursor: not-allowed` renders and a wrapping tooltip still gets the pointer, which
// means the states have to be suppressed by selector. Disabled keeps its resting fill and only dims.
//
// The gate is the reflected attribute rather than `:enabled`, because `focusableWhenDisabled` drops
// the native `disabled` attribute to keep the button in the tab order. `data-disabled` is emitted
// for both, so one selector covers them.
//
// Hover also excludes `:active` explicitly — StyleX gives at-rules extra priority, so a
// `@media (hover: hover)` `:hover` would outrank a bare `:active` and win while pressing.
Expand Down Expand Up @@ -90,7 +94,7 @@ export const styles = stylex.create({
// `fast` — `:active` stops matching as the color heads back. Instant press, soft settle.
transitionDuration: {
default: durationVars['--cl-duration-fast'],
':enabled:active': durationVars['--cl-duration-instant'],
':not([data-disabled]):active': durationVars['--cl-duration-instant'],
},
transitionProperty: 'background-color, border-color, color, opacity',
// Linear, not `--cl-ease-default`: nothing here moves. An ease on already non-uniform
Expand Down Expand Up @@ -171,59 +175,59 @@ export const variants = stylex.create({
'filled-primary': {
'--_cl-icon-color': {
default: iconFadedOnPrimary,
':enabled[data-open]': colorVars['--cl-color-primary-foreground'],
':not([data-disabled])[data-open]': colorVars['--cl-color-primary-foreground'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-primary-foreground'],
':not([data-disabled]):hover': colorVars['--cl-color-primary-foreground'],
},
},
backgroundColor: {
default: colorVars['--cl-color-primary'],
':enabled:active': primaryActive,
':enabled[data-open]': primaryActive,
':not([data-disabled]):active': primaryActive,
':not([data-disabled])[data-open]': primaryActive,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': primaryHover,
':not([data-disabled]):hover:not(:active):not([data-open])': primaryHover,
},
},
color: colorVars['--cl-color-primary-foreground'],
},
'filled-neutral': {
'--_cl-icon-color': {
default: iconFadedNeutral,
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-foreground'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
},
},
backgroundColor: {
default: neutralStep0,
':enabled:active': neutralStep2,
':enabled[data-open]': neutralStep2,
':not([data-disabled]):active': neutralStep2,
':not([data-disabled])[data-open]': neutralStep2,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep1,
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep1,
},
},
color: colorVars['--cl-color-neutral-foreground'],
},
'filled-negative': {
'--_cl-icon-color': {
default: iconFadedOnNegative,
':enabled[data-open]': colorVars['--cl-color-negative-foreground'],
':not([data-disabled])[data-open]': colorVars['--cl-color-negative-foreground'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-negative-foreground'],
':not([data-disabled]):hover': colorVars['--cl-color-negative-foreground'],
},
},
backgroundColor: {
default: colorVars['--cl-color-negative'],
':enabled:active': negativeActive,
':enabled[data-open]': negativeActive,
':not([data-disabled]):active': negativeActive,
':not([data-disabled])[data-open]': negativeActive,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': negativeHover,
':not([data-disabled]):hover:not(:active):not([data-open])': negativeHover,
},
},
color: colorVars['--cl-color-negative-foreground'],
Expand All @@ -235,62 +239,62 @@ export const variants = stylex.create({
'outline-primary': {
'--_cl-icon-color': {
default: iconFadedNeutral,
':enabled[data-open]': colorVars['--cl-color-primary'],
':not([data-disabled])[data-open]': colorVars['--cl-color-primary'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-primary'],
':not([data-disabled]):hover': colorVars['--cl-color-primary'],
},
},
borderColor: colorVars['--cl-color-border'],
backgroundColor: {
default: 'transparent',
':enabled:active': neutralStep1,
':enabled[data-open]': neutralStep1,
':not([data-disabled]):active': neutralStep1,
':not([data-disabled])[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep0,
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
},
},
color: colorVars['--cl-color-primary'],
},
'outline-neutral': {
'--_cl-icon-color': {
default: iconFadedNeutral,
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-foreground'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
},
},
borderColor: colorVars['--cl-color-border'],
backgroundColor: {
default: 'transparent',
':enabled:active': neutralStep1,
':enabled[data-open]': neutralStep1,
':not([data-disabled]):active': neutralStep1,
':not([data-disabled])[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep0,
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
},
},
color: colorVars['--cl-color-neutral-foreground'],
},
'outline-negative': {
'--_cl-icon-color': {
default: iconFadedNegative,
':enabled[data-open]': colorVars['--cl-color-negative'],
':not([data-disabled])[data-open]': colorVars['--cl-color-negative'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-negative'],
':not([data-disabled]):hover': colorVars['--cl-color-negative'],
},
},
borderColor: colorVars['--cl-color-border'],
backgroundColor: {
default: 'transparent',
':enabled:active': neutralStep1,
':enabled[data-open]': neutralStep1,
':not([data-disabled]):active': neutralStep1,
':not([data-disabled])[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep0,
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
},
},
color: colorVars['--cl-color-negative'],
Expand All @@ -299,39 +303,39 @@ export const variants = stylex.create({
'ghost-primary': {
'--_cl-icon-color': {
default: iconFadedNeutral,
':enabled[data-open]': colorVars['--cl-color-primary'],
':not([data-disabled])[data-open]': colorVars['--cl-color-primary'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-primary'],
':not([data-disabled]):hover': colorVars['--cl-color-primary'],
},
},
backgroundColor: {
default: 'transparent',
':enabled:active': neutralStep1,
':enabled[data-open]': neutralStep1,
':not([data-disabled]):active': neutralStep1,
':not([data-disabled])[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep0,
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
},
},
color: colorVars['--cl-color-primary'],
},
'ghost-neutral': {
'--_cl-icon-color': {
default: iconFadedNeutral,
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-foreground'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
},
},
backgroundColor: {
default: 'transparent',
':enabled:active': neutralStep1,
':enabled[data-open]': neutralStep1,
':not([data-disabled]):active': neutralStep1,
':not([data-disabled])[data-open]': neutralStep1,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': neutralStep0,
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
},
},
color: colorVars['--cl-color-neutral-foreground'],
Expand All @@ -341,19 +345,19 @@ export const variants = stylex.create({
'ghost-negative': {
'--_cl-icon-color': {
default: iconFadedNegative,
':enabled[data-open]': colorVars['--cl-color-negative'],
':not([data-disabled])[data-open]': colorVars['--cl-color-negative'],
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-negative'],
':not([data-disabled]):hover': colorVars['--cl-color-negative'],
},
},
backgroundColor: {
default: 'transparent',
':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
':not([data-disabled]):active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
':not([data-disabled])[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
'@media (hover: hover)': {
default: null,
':enabled:hover:not(:active):not([data-open])': colorVars['--cl-color-negative-faded'],
':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-negative-faded'],
},
},
color: colorVars['--cl-color-negative'],
Expand All @@ -366,14 +370,14 @@ export const variants = stylex.create({
default: iconFadedNeutral,
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-primary'],
':not([data-disabled]):hover': colorVars['--cl-color-primary'],
},
},
backgroundColor: 'transparent',
color: colorVars['--cl-color-primary'],
paddingInlineEnd: 0,
paddingInlineStart: 0,
textDecorationLine: { default: 'none', ':enabled:hover': 'underline' },
textDecorationLine: { default: 'none', ':not([data-disabled]):hover': 'underline' },
textUnderlineOffset: '2px',
height: 'auto',
},
Expand All @@ -382,14 +386,14 @@ export const variants = stylex.create({
default: iconFadedNeutral,
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
},
},
backgroundColor: 'transparent',
color: colorVars['--cl-color-neutral-foreground'],
paddingInlineEnd: 0,
paddingInlineStart: 0,
textDecorationLine: { default: 'none', ':enabled:hover': 'underline' },
textDecorationLine: { default: 'none', ':not([data-disabled]):hover': 'underline' },
textUnderlineOffset: '2px',
height: 'auto',
},
Expand All @@ -398,14 +402,14 @@ export const variants = stylex.create({
default: iconFadedNegative,
'@media (hover: hover)': {
default: null,
':enabled:hover': colorVars['--cl-color-negative'],
':not([data-disabled]):hover': colorVars['--cl-color-negative'],
},
},
backgroundColor: 'transparent',
color: colorVars['--cl-color-negative'],
paddingInlineEnd: 0,
paddingInlineStart: 0,
textDecorationLine: { default: 'none', ':enabled:hover': 'underline' },
textDecorationLine: { default: 'none', ':not([data-disabled]):hover': 'underline' },
textUnderlineOffset: '2px',
height: 'auto',
},
Expand Down
61 changes: 61 additions & 0 deletions packages/ui/src/mosaic/components/button/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,67 @@ describe('Mosaic Button', () => {
expect(button).toHaveAttribute('data-disabled', '');
});

describe('focusableWhenDisabled', () => {
it('marks the button aria-disabled instead of disabled', () => {
render(
<Button
disabled
focusableWhenDisabled
>
Hi
</Button>,
);
const button = screen.getByRole('button');
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
// The styles gate every interactive state on `data-disabled`, so it has to be
// reflected identically whether or not the native attribute is present.
expect(button).toHaveAttribute('data-disabled', '');
});

it('stays reachable by keyboard', async () => {
render(
<Button
disabled
focusableWhenDisabled
>
Hi
</Button>,
);
await userEvent.tab();
expect(screen.getByRole('button')).toHaveFocus();
});

it('does not call onClick while disabled', async () => {
const onClick = vi.fn();
render(
<Button
disabled
focusableWhenDisabled
onClick={onClick}
>
Hi
</Button>,
);
await userEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});

it('still dims and blocks the cursor', () => {
const { rerender } = render(<Button disabled>Hi</Button>);
const nativelyDisabled = screen.getByRole('button').className;
rerender(
<Button
disabled
focusableWhenDisabled
>
Hi
</Button>,
);
expect(screen.getByRole('button').className).toBe(nativelyDisabled);
});
});

it('forwards arbitrary button props and the ref', () => {
const ref = React.createRef<HTMLButtonElement>();
render(
Expand Down
Loading