import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, fontFamilyVars, fontWeightVars, radiusVars, space, targetVars, typeScaleVars, } from '../../tokens.stylex'; import { iconScope } from '../icon/icon.markers.stylex'; // Neutral fills are the three neutral alpha steps, shared across variants: // 100 — `filled-neutral` rest, `ghost-*` hover // 200 — `filled-neutral` hover, `ghost-*` pressed // 300 — `filled-neutral` pressed // Opaque fills blend toward their own on-fill at the same 12%/18%. Neutral has no fill // to blend from, so it rides the opacity ramp above. const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 12%)`; const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 18%)`; const outlineHover = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 6%, ${colorVars['--cl-color-surface']})`; const outlinePressed = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, ${colorVars['--cl-color-surface']})`; const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`; const negativePressed = `color-mix(in oklab, ${colorVars['--cl-color-negative-subtle']}, ${colorVars['--cl-color-negative']} 8%)`; const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`; // 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. // Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys` // reads a computed key as its identifier name and fails the ordering. // // `[data-open]` takes the pressed fill too, so a button acting as a disclosure trigger stays // visibly engaged for as long as its surface is open. Disclosure primitives set it on the // trigger (`popover-trigger.tsx` and friends); a plain button never carries it. It is excluded // from hover for the same reason `:active` is — otherwise moving the pointer over an open // trigger would lift it back to the lighter hover step. export const styles = stylex.create({ base: { '--_cl-icon-color': 'currentColor', // Handed to `Icon`, which needs its own copy: transitions don't inherit, so without this // the icon would still be catching up 0.1s after the button itself has landed. '--_cl-icon-duration': { default: durationVars['--cl-duration-base'], ':not([data-disabled]):active': durationVars['--cl-duration-instant'], ':not([data-disabled]):hover': durationVars['--cl-duration-instant'], }, borderColor: 'transparent', borderRadius: `var(--_cl-button-radius, ${radiusVars['--cl-radius-md']})`, borderStyle: 'solid', borderWidth: '1px', alignItems: 'center', // Strips UA control styling so what's below is the whole appearance, not an override. appearance: 'none', cursor: 'pointer', display: 'inline-flex', // A button is sized by its own axis, not by whatever row it lands in. flexShrink: 0, fontFamily: fontFamilyVars['--cl-font-family-sans'], fontWeight: fontWeightVars['--cl-font-medium'], justifyContent: 'center', // The duration a state carries governs the transition INTO it, so one declaration per // state gives an instant arrival and a 0.15s settle out. Instant because a hover or a // press confirms something the user just did, and confirmation cannot lag; see `motion.md`. transitionDuration: { default: durationVars['--cl-duration-base'], ':not([data-disabled]):active': durationVars['--cl-duration-instant'], ':not([data-disabled]):hover': durationVars['--cl-duration-instant'], }, transitionProperty: 'background-color, border-color, color, opacity, text-decoration-color', // Linear, not `--cl-ease-default`: nothing here moves. An ease on already non-uniform // color interpolation just drags the midpoint, and the house curve's overshoot would // extrapolate past the target color. transitionTimingFunction: 'linear', // A double-click on a button is a double-click, not a text selection. userSelect: 'none', // A wrapped label would outgrow the fixed height, so it truncates instead (see `withTruncatableLabel`). whiteSpace: 'nowrap', }, // shape — icon buttons zero their inline padding; width tracks the height. Longhands because // StyleX ranks a longhand above a shorthand, so `paddingInline` would lose to what `sizes` sets. shapeSquare: { borderRadius: `var(--_cl-button-radius, ${radiusVars['--cl-radius-md']})`, paddingInlineEnd: 0, paddingInlineStart: 0, }, shapeCircle: { borderRadius: radiusVars['--cl-radius-full'], paddingInlineEnd: 0, paddingInlineStart: 0, }, // Under a coarse pointer the hit area floors at the target size — grown by an overlay // rather than by `min-height`, so the button keeps the size its axis gives it and only the // region answering a tap gets bigger. Its own atom rather than a per-size override: the // floor is one physical constant, and `link` — text, not a control — opts out by not // receiving it. // // The insets resolve against the button's own box, so one expression covers every size: // the overlay lands at exactly the target height whatever the shortfall, and clamps to the // button's bounds once the control is already past the floor. `position` is scoped to the // media query too — a fine pointer generates no overlay, so it shouldn't take on the // stacking change either. touchTarget: { position: { default: null, '@media (pointer: coarse)': 'relative' }, '::after': { insetBlock: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`, // Text buttons are already past the floor inline — growing that axis too would only // reach into a neighbor's space for nothing. insetInline: 0, content: { default: null, '@media (pointer: coarse)': '""' }, position: 'absolute', }, }, // Icon buttons are square, so the floor has to reach the inline axis as well or the region // ends up tall and narrow. touchTargetIcon: { '::after': { insetInline: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`, }, }, // state / modifiers fullWidth: { flex: '1', width: '100%' }, disabled: { cursor: 'not-allowed', opacity: 0.5 }, }); // variant × color, one entry per cell of the design matrix, keyed `-` so the // component can index directly. Each cell is self-contained so it reads — and tunes — against // the spec without tracing shared parts. export const variants = stylex.create({ // The pressed state stays outside the hover media query so no-hover devices still get one. 'filled-primary': { backgroundColor: { default: colorVars['--cl-color-brand'], ':not([data-disabled]):active': primaryActive, ':not([data-disabled])[data-open]': primaryActive, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': primaryHover, }, }, color: colorVars['--cl-color-brand-foreground'], }, 'filled-neutral': { backgroundColor: { default: colorVars['--cl-color-neutral-alpha-100'], ':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-300'], ':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-300'], '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-200'], }, }, color: colorVars['--cl-color-foreground'], }, 'filled-negative': { backgroundColor: { default: colorVars['--cl-color-negative'], ':not([data-disabled]):active': negativeActive, ':not([data-disabled])[data-open]': negativeActive, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': negativeHover, }, }, color: colorVars['--cl-color-negative-foreground'], }, // The border is `--cl-color-border` in every state — it never transitions, the fill just // rises underneath it. Keeps the border opaque so it can't alpha-fade against an incoming // fill, and leaves it independently themeable. 'outline-primary': { borderColor: colorVars['--cl-color-border'], backgroundColor: { default: colorVars['--cl-color-surface'], ':not([data-disabled]):active': outlinePressed, ':not([data-disabled])[data-open]': outlinePressed, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': outlineHover, }, }, color: colorVars['--cl-color-brand'], }, 'outline-neutral': { borderColor: colorVars['--cl-color-border'], backgroundColor: { default: colorVars['--cl-color-surface'], ':not([data-disabled]):active': outlinePressed, ':not([data-disabled])[data-open]': outlinePressed, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': outlineHover, }, }, color: colorVars['--cl-color-foreground'], }, 'outline-negative': { borderColor: colorVars['--cl-color-border'], backgroundColor: { default: colorVars['--cl-color-surface'], ':not([data-disabled]):active': negativePressed, ':not([data-disabled])[data-open]': negativePressed, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-negative-subtle'], }, }, color: colorVars['--cl-color-negative'], }, 'ghost-primary': { backgroundColor: { default: 'transparent', ':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-200'], ':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-200'], '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-100'], }, }, color: colorVars['--cl-color-brand'], }, 'ghost-neutral': { backgroundColor: { default: 'transparent', ':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-200'], ':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-200'], '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-100'], }, }, color: colorVars['--cl-color-foreground'], }, // The one ghost that tints instead of graying, so its pressed step walks its own faded // fill toward the negative it carries rather than joining the gray ramp. 'ghost-negative': { backgroundColor: { default: 'transparent', ':not([data-disabled]):active': negativePressed, ':not([data-disabled])[data-open]': negativePressed, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-negative-subtle'], }, }, color: colorVars['--cl-color-negative'], }, // link opts out of the box the size axis sets — it reads as text, not a control. Per-side // zeros for the same reason `shapeSquare` uses them. // // The underline is always drawn and only its color moves: `text-decoration-line` is a keyword, // so toggling it cannot tween and the exit would snap where every other property fades. A // transparent decoration paints nothing and never participates in layout. 'link-primary': { backgroundColor: 'transparent', color: colorVars['--cl-color-brand'], paddingInlineEnd: 0, paddingInlineStart: 0, textDecorationColor: { default: 'transparent', ':not([data-disabled]):hover': 'currentColor' }, textDecorationLine: 'underline', textDecorationSkipInk: 'auto', textUnderlinePosition: 'from-font', height: 'auto', }, 'link-neutral': { backgroundColor: 'transparent', color: colorVars['--cl-color-foreground'], paddingInlineEnd: 0, paddingInlineStart: 0, textDecorationColor: { default: 'transparent', ':not([data-disabled]):hover': 'currentColor' }, textDecorationLine: 'underline', textDecorationSkipInk: 'auto', textUnderlinePosition: 'from-font', height: 'auto', }, 'link-negative': { backgroundColor: 'transparent', color: colorVars['--cl-color-negative'], paddingInlineEnd: 0, paddingInlineStart: 0, textDecorationColor: { default: 'transparent', ':not([data-disabled]):hover': 'currentColor' }, textDecorationLine: 'underline', textDecorationSkipInk: 'auto', textUnderlinePosition: 'from-font', height: 'auto', }, }); // size — height-driven; padding sets only the inline axis. An icon's side tightens to the inset // it already has above and below, `(height - icon) / 2`, so it sits in a square cell. // /* eslint-disable @stylexjs/no-lookahead-selectors -- every browser this package builds for supports `:has()` (`stylex-lightningcss.config.mjs`); an older one keeps the untightened padding. */ export const sizes = stylex.create({ xs: { gap: space['1'], fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], paddingInlineEnd: { default: space['2'], [stylex.when.descendant("[data-icon='inline-end']", iconScope)]: space['1.5'], }, paddingInlineStart: { default: space['2'], [stylex.when.descendant("[data-icon='inline-start']", iconScope)]: space['1.5'], }, height: space['6'], }, sm: { // sm runs a step tighter than md on every inline measure, gap included gap: space['1.5'], fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], paddingInlineEnd: { default: space['2.5'], [stylex.when.descendant("[data-icon='inline-end']", iconScope)]: space['2'], }, paddingInlineStart: { default: space['2.5'], [stylex.when.descendant("[data-icon='inline-start']", iconScope)]: space['2'], }, height: space['7'], }, md: { gap: space['2'], fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], paddingInlineEnd: { default: space['3'], [stylex.when.descendant("[data-icon='inline-end']", iconScope)]: space['2'], }, paddingInlineStart: { default: space['3'], [stylex.when.descendant("[data-icon='inline-start']", iconScope)]: space['2'], }, height: space['8'], }, lg: { gap: space['2'], fontSize: typeScaleVars['--cl-text-base-size'], lineHeight: typeScaleVars['--cl-text-base-leading'], paddingInlineEnd: { default: space['3'], [stylex.when.descendant("[data-icon='inline-end']", iconScope)]: space['2'], }, paddingInlineStart: { default: space['3'], [stylex.when.descendant("[data-icon='inline-start']", iconScope)]: space['2'], }, height: space['9'], }, }); /* eslint-enable @stylexjs/no-lookahead-selectors */ export const iconSizes = stylex.create({ xs: { width: space['6'] }, sm: { width: space['7'] }, md: { width: space['8'] }, lg: { width: space['9'] }, });