Skip to content

Commit 175b14a

Browse files
committed
feat(ui): wire Mosaic Button to the headless Button with focusableWhenDisabled
1 parent 75e873a commit 175b14a

4 files changed

Lines changed: 134 additions & 58 deletions

File tree

.changeset/olive-jars-shout.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/ui/src/mosaic/components/button/button.styles.ts

Lines changed: 59 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ const iconFadedNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative'
4848
const iconFadedOnPrimary = `color-mix(in oklab, ${colorVars['--cl-color-primary-foreground']}, ${colorVars['--cl-color-primary']} 40%)`;
4949
const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative-foreground']}, ${colorVars['--cl-color-negative']} 40%)`;
5050

51-
// Interactive states are gated on `:enabled`: the `disabled` attribute blocks activation but
52-
// not matching, and the button stays hit-testable so `cursor: not-allowed` renders and a
53-
// wrapping tooltip still gets the pointer. Disabled keeps its resting fill and only dims.
51+
// Interactive states are gated on `:not([data-disabled])`: the button stays hit-testable while
52+
// disabled so `cursor: not-allowed` renders and a wrapping tooltip still gets the pointer, which
53+
// means the states have to be suppressed by selector. Disabled keeps its resting fill and only dims.
54+
//
55+
// The gate is the reflected attribute rather than `:enabled`, because `focusableWhenDisabled` drops
56+
// the native `disabled` attribute to keep the button in the tab order. `data-disabled` is emitted
57+
// for both, so one selector covers them.
5458
//
5559
// Hover also excludes `:active` explicitly — StyleX gives at-rules extra priority, so a
5660
// `@media (hover: hover)` `:hover` would outrank a bare `:active` and win while pressing.
@@ -90,7 +94,7 @@ export const styles = stylex.create({
9094
// `fast` — `:active` stops matching as the color heads back. Instant press, soft settle.
9195
transitionDuration: {
9296
default: durationVars['--cl-duration-fast'],
93-
':enabled:active': durationVars['--cl-duration-instant'],
97+
':not([data-disabled]):active': durationVars['--cl-duration-instant'],
9498
},
9599
transitionProperty: 'background-color, border-color, color, opacity',
96100
// Linear, not `--cl-ease-default`: nothing here moves. An ease on already non-uniform
@@ -171,59 +175,59 @@ export const variants = stylex.create({
171175
'filled-primary': {
172176
'--_cl-icon-color': {
173177
default: iconFadedOnPrimary,
174-
':enabled[data-open]': colorVars['--cl-color-primary-foreground'],
178+
':not([data-disabled])[data-open]': colorVars['--cl-color-primary-foreground'],
175179
'@media (hover: hover)': {
176180
default: null,
177-
':enabled:hover': colorVars['--cl-color-primary-foreground'],
181+
':not([data-disabled]):hover': colorVars['--cl-color-primary-foreground'],
178182
},
179183
},
180184
backgroundColor: {
181185
default: colorVars['--cl-color-primary'],
182-
':enabled:active': primaryActive,
183-
':enabled[data-open]': primaryActive,
186+
':not([data-disabled]):active': primaryActive,
187+
':not([data-disabled])[data-open]': primaryActive,
184188
'@media (hover: hover)': {
185189
default: null,
186-
':enabled:hover:not(:active):not([data-open])': primaryHover,
190+
':not([data-disabled]):hover:not(:active):not([data-open])': primaryHover,
187191
},
188192
},
189193
color: colorVars['--cl-color-primary-foreground'],
190194
},
191195
'filled-neutral': {
192196
'--_cl-icon-color': {
193197
default: iconFadedNeutral,
194-
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
198+
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-foreground'],
195199
'@media (hover: hover)': {
196200
default: null,
197-
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
201+
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
198202
},
199203
},
200204
backgroundColor: {
201205
default: neutralStep0,
202-
':enabled:active': neutralStep2,
203-
':enabled[data-open]': neutralStep2,
206+
':not([data-disabled]):active': neutralStep2,
207+
':not([data-disabled])[data-open]': neutralStep2,
204208
'@media (hover: hover)': {
205209
default: null,
206-
':enabled:hover:not(:active):not([data-open])': neutralStep1,
210+
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep1,
207211
},
208212
},
209213
color: colorVars['--cl-color-neutral-foreground'],
210214
},
211215
'filled-negative': {
212216
'--_cl-icon-color': {
213217
default: iconFadedOnNegative,
214-
':enabled[data-open]': colorVars['--cl-color-negative-foreground'],
218+
':not([data-disabled])[data-open]': colorVars['--cl-color-negative-foreground'],
215219
'@media (hover: hover)': {
216220
default: null,
217-
':enabled:hover': colorVars['--cl-color-negative-foreground'],
221+
':not([data-disabled]):hover': colorVars['--cl-color-negative-foreground'],
218222
},
219223
},
220224
backgroundColor: {
221225
default: colorVars['--cl-color-negative'],
222-
':enabled:active': negativeActive,
223-
':enabled[data-open]': negativeActive,
226+
':not([data-disabled]):active': negativeActive,
227+
':not([data-disabled])[data-open]': negativeActive,
224228
'@media (hover: hover)': {
225229
default: null,
226-
':enabled:hover:not(:active):not([data-open])': negativeHover,
230+
':not([data-disabled]):hover:not(:active):not([data-open])': negativeHover,
227231
},
228232
},
229233
color: colorVars['--cl-color-negative-foreground'],
@@ -235,62 +239,62 @@ export const variants = stylex.create({
235239
'outline-primary': {
236240
'--_cl-icon-color': {
237241
default: iconFadedNeutral,
238-
':enabled[data-open]': colorVars['--cl-color-primary'],
242+
':not([data-disabled])[data-open]': colorVars['--cl-color-primary'],
239243
'@media (hover: hover)': {
240244
default: null,
241-
':enabled:hover': colorVars['--cl-color-primary'],
245+
':not([data-disabled]):hover': colorVars['--cl-color-primary'],
242246
},
243247
},
244248
borderColor: colorVars['--cl-color-border'],
245249
backgroundColor: {
246250
default: 'transparent',
247-
':enabled:active': neutralStep1,
248-
':enabled[data-open]': neutralStep1,
251+
':not([data-disabled]):active': neutralStep1,
252+
':not([data-disabled])[data-open]': neutralStep1,
249253
'@media (hover: hover)': {
250254
default: null,
251-
':enabled:hover:not(:active):not([data-open])': neutralStep0,
255+
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
252256
},
253257
},
254258
color: colorVars['--cl-color-primary'],
255259
},
256260
'outline-neutral': {
257261
'--_cl-icon-color': {
258262
default: iconFadedNeutral,
259-
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
263+
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-foreground'],
260264
'@media (hover: hover)': {
261265
default: null,
262-
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
266+
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
263267
},
264268
},
265269
borderColor: colorVars['--cl-color-border'],
266270
backgroundColor: {
267271
default: 'transparent',
268-
':enabled:active': neutralStep1,
269-
':enabled[data-open]': neutralStep1,
272+
':not([data-disabled]):active': neutralStep1,
273+
':not([data-disabled])[data-open]': neutralStep1,
270274
'@media (hover: hover)': {
271275
default: null,
272-
':enabled:hover:not(:active):not([data-open])': neutralStep0,
276+
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
273277
},
274278
},
275279
color: colorVars['--cl-color-neutral-foreground'],
276280
},
277281
'outline-negative': {
278282
'--_cl-icon-color': {
279283
default: iconFadedNegative,
280-
':enabled[data-open]': colorVars['--cl-color-negative'],
284+
':not([data-disabled])[data-open]': colorVars['--cl-color-negative'],
281285
'@media (hover: hover)': {
282286
default: null,
283-
':enabled:hover': colorVars['--cl-color-negative'],
287+
':not([data-disabled]):hover': colorVars['--cl-color-negative'],
284288
},
285289
},
286290
borderColor: colorVars['--cl-color-border'],
287291
backgroundColor: {
288292
default: 'transparent',
289-
':enabled:active': neutralStep1,
290-
':enabled[data-open]': neutralStep1,
293+
':not([data-disabled]):active': neutralStep1,
294+
':not([data-disabled])[data-open]': neutralStep1,
291295
'@media (hover: hover)': {
292296
default: null,
293-
':enabled:hover:not(:active):not([data-open])': neutralStep0,
297+
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
294298
},
295299
},
296300
color: colorVars['--cl-color-negative'],
@@ -299,39 +303,39 @@ export const variants = stylex.create({
299303
'ghost-primary': {
300304
'--_cl-icon-color': {
301305
default: iconFadedNeutral,
302-
':enabled[data-open]': colorVars['--cl-color-primary'],
306+
':not([data-disabled])[data-open]': colorVars['--cl-color-primary'],
303307
'@media (hover: hover)': {
304308
default: null,
305-
':enabled:hover': colorVars['--cl-color-primary'],
309+
':not([data-disabled]):hover': colorVars['--cl-color-primary'],
306310
},
307311
},
308312
backgroundColor: {
309313
default: 'transparent',
310-
':enabled:active': neutralStep1,
311-
':enabled[data-open]': neutralStep1,
314+
':not([data-disabled]):active': neutralStep1,
315+
':not([data-disabled])[data-open]': neutralStep1,
312316
'@media (hover: hover)': {
313317
default: null,
314-
':enabled:hover:not(:active):not([data-open])': neutralStep0,
318+
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
315319
},
316320
},
317321
color: colorVars['--cl-color-primary'],
318322
},
319323
'ghost-neutral': {
320324
'--_cl-icon-color': {
321325
default: iconFadedNeutral,
322-
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
326+
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-foreground'],
323327
'@media (hover: hover)': {
324328
default: null,
325-
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
329+
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
326330
},
327331
},
328332
backgroundColor: {
329333
default: 'transparent',
330-
':enabled:active': neutralStep1,
331-
':enabled[data-open]': neutralStep1,
334+
':not([data-disabled]):active': neutralStep1,
335+
':not([data-disabled])[data-open]': neutralStep1,
332336
'@media (hover: hover)': {
333337
default: null,
334-
':enabled:hover:not(:active):not([data-open])': neutralStep0,
338+
':not([data-disabled]):hover:not(:active):not([data-open])': neutralStep0,
335339
},
336340
},
337341
color: colorVars['--cl-color-neutral-foreground'],
@@ -341,19 +345,19 @@ export const variants = stylex.create({
341345
'ghost-negative': {
342346
'--_cl-icon-color': {
343347
default: iconFadedNegative,
344-
':enabled[data-open]': colorVars['--cl-color-negative'],
348+
':not([data-disabled])[data-open]': colorVars['--cl-color-negative'],
345349
'@media (hover: hover)': {
346350
default: null,
347-
':enabled:hover': colorVars['--cl-color-negative'],
351+
':not([data-disabled]):hover': colorVars['--cl-color-negative'],
348352
},
349353
},
350354
backgroundColor: {
351355
default: 'transparent',
352-
':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
353-
':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
356+
':not([data-disabled]):active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
357+
':not([data-disabled])[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
354358
'@media (hover: hover)': {
355359
default: null,
356-
':enabled:hover:not(:active):not([data-open])': colorVars['--cl-color-negative-faded'],
360+
':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-negative-faded'],
357361
},
358362
},
359363
color: colorVars['--cl-color-negative'],
@@ -366,14 +370,14 @@ export const variants = stylex.create({
366370
default: iconFadedNeutral,
367371
'@media (hover: hover)': {
368372
default: null,
369-
':enabled:hover': colorVars['--cl-color-primary'],
373+
':not([data-disabled]):hover': colorVars['--cl-color-primary'],
370374
},
371375
},
372376
backgroundColor: 'transparent',
373377
color: colorVars['--cl-color-primary'],
374378
paddingInlineEnd: 0,
375379
paddingInlineStart: 0,
376-
textDecorationLine: { default: 'none', ':enabled:hover': 'underline' },
380+
textDecorationLine: { default: 'none', ':not([data-disabled]):hover': 'underline' },
377381
textUnderlineOffset: '2px',
378382
height: 'auto',
379383
},
@@ -382,14 +386,14 @@ export const variants = stylex.create({
382386
default: iconFadedNeutral,
383387
'@media (hover: hover)': {
384388
default: null,
385-
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
389+
':not([data-disabled]):hover': colorVars['--cl-color-neutral-foreground'],
386390
},
387391
},
388392
backgroundColor: 'transparent',
389393
color: colorVars['--cl-color-neutral-foreground'],
390394
paddingInlineEnd: 0,
391395
paddingInlineStart: 0,
392-
textDecorationLine: { default: 'none', ':enabled:hover': 'underline' },
396+
textDecorationLine: { default: 'none', ':not([data-disabled]):hover': 'underline' },
393397
textUnderlineOffset: '2px',
394398
height: 'auto',
395399
},
@@ -398,14 +402,14 @@ export const variants = stylex.create({
398402
default: iconFadedNegative,
399403
'@media (hover: hover)': {
400404
default: null,
401-
':enabled:hover': colorVars['--cl-color-negative'],
405+
':not([data-disabled]):hover': colorVars['--cl-color-negative'],
402406
},
403407
},
404408
backgroundColor: 'transparent',
405409
color: colorVars['--cl-color-negative'],
406410
paddingInlineEnd: 0,
407411
paddingInlineStart: 0,
408-
textDecorationLine: { default: 'none', ':enabled:hover': 'underline' },
412+
textDecorationLine: { default: 'none', ':not([data-disabled]):hover': 'underline' },
409413
textUnderlineOffset: '2px',
410414
height: 'auto',
411415
},

packages/ui/src/mosaic/components/button/button.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,67 @@ describe('Mosaic Button', () => {
197197
expect(button).toHaveAttribute('data-disabled', '');
198198
});
199199

200+
describe('focusableWhenDisabled', () => {
201+
it('marks the button aria-disabled instead of disabled', () => {
202+
render(
203+
<Button
204+
disabled
205+
focusableWhenDisabled
206+
>
207+
Hi
208+
</Button>,
209+
);
210+
const button = screen.getByRole('button');
211+
expect(button).not.toBeDisabled();
212+
expect(button).toHaveAttribute('aria-disabled', 'true');
213+
// The styles gate every interactive state on `data-disabled`, so it has to be
214+
// reflected identically whether or not the native attribute is present.
215+
expect(button).toHaveAttribute('data-disabled', '');
216+
});
217+
218+
it('stays reachable by keyboard', async () => {
219+
render(
220+
<Button
221+
disabled
222+
focusableWhenDisabled
223+
>
224+
Hi
225+
</Button>,
226+
);
227+
await userEvent.tab();
228+
expect(screen.getByRole('button')).toHaveFocus();
229+
});
230+
231+
it('does not call onClick while disabled', async () => {
232+
const onClick = vi.fn();
233+
render(
234+
<Button
235+
disabled
236+
focusableWhenDisabled
237+
onClick={onClick}
238+
>
239+
Hi
240+
</Button>,
241+
);
242+
await userEvent.click(screen.getByRole('button'));
243+
expect(onClick).not.toHaveBeenCalled();
244+
});
245+
246+
it('still dims and blocks the cursor', () => {
247+
const { rerender } = render(<Button disabled>Hi</Button>);
248+
const nativelyDisabled = screen.getByRole('button').className;
249+
rerender(
250+
<Button
251+
disabled
252+
focusableWhenDisabled
253+
>
254+
Hi
255+
</Button>,
256+
);
257+
expect(screen.getByRole('button').className).toBe(nativelyDisabled);
258+
});
259+
});
260+
200261
it('forwards arbitrary button props and the ref', () => {
201262
const ref = React.createRef<HTMLButtonElement>();
202263
render(

0 commit comments

Comments
 (0)