diff --git a/.changeset/olive-jars-shout.md b/.changeset/olive-jars-shout.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/olive-jars-shout.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 37a7bbfebbf..7e6f29bfe02 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -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. @@ -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 @@ -171,19 +175,19 @@ 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'], @@ -191,19 +195,19 @@ export const variants = stylex.create({ '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'], @@ -211,19 +215,19 @@ export const variants = stylex.create({ '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'], @@ -235,20 +239,20 @@ 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'], @@ -256,20 +260,20 @@ export const variants = stylex.create({ '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'], @@ -277,20 +281,20 @@ export const variants = stylex.create({ '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'], @@ -299,19 +303,19 @@ 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'], @@ -319,19 +323,19 @@ export const variants = stylex.create({ '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'], @@ -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'], @@ -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', }, @@ -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', }, @@ -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', }, diff --git a/packages/ui/src/mosaic/components/button/button.test.tsx b/packages/ui/src/mosaic/components/button/button.test.tsx index 6fd7f0162b6..fe9c6be3433 100644 --- a/packages/ui/src/mosaic/components/button/button.test.tsx +++ b/packages/ui/src/mosaic/components/button/button.test.tsx @@ -197,6 +197,67 @@ describe('Mosaic Button', () => { expect(button).toHaveAttribute('data-disabled', ''); }); + describe('focusableWhenDisabled', () => { + it('marks the button aria-disabled instead of disabled', () => { + render( + , + ); + 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( + , + ); + await userEvent.tab(); + expect(screen.getByRole('button')).toHaveFocus(); + }); + + it('does not call onClick while disabled', async () => { + const onClick = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('still dims and blocks the cursor', () => { + const { rerender } = render(); + const nativelyDisabled = screen.getByRole('button').className; + rerender( + , + ); + expect(screen.getByRole('button').className).toBe(nativelyDisabled); + }); + }); + it('forwards arbitrary button props and the ref', () => { const ref = React.createRef(); render( diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 7906e9554b7..944f02bae92 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -1,3 +1,4 @@ +import { Button as HeadlessButton } from '@clerk/headless/button'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -20,6 +21,13 @@ export interface ButtonProps extends MosaicElementProps<'button'> { * effect on `variant='link'`, which is text rather than a control. */ touchTarget?: boolean; + /** + * Keeps the button in the tab order while `disabled`, so focus is not dropped when a button + * disables itself mid-interaction — while a form submits, say — and the user keeps their place + * on the page. The button is marked `aria-disabled` rather than `disabled`, and stays inert to + * clicks and keyboard activation. It dims and reads as disabled either way. + */ + focusableWhenDisabled?: boolean; } /** @@ -91,6 +99,7 @@ export const Button = React.forwardRef(function fullWidth = false, touchTarget = true, disabled = false, + focusableWhenDisabled = false, className, style, children, @@ -101,10 +110,10 @@ export const Button = React.forwardRef(function const isIconShape = shape === 'square' || shape === 'circle'; const hasTouchTarget = touchTarget && variant !== 'link'; return ( - + ); });