// The one button. Variants and tones are token-driven, so dark mode and RTL // need no extra rules; `loading` keeps the label mounted to avoid a layout jump. // // `loading` does NOT set the native `disabled` attribute, and that WILL read as a mistake — it set // one until 2026-09. Four things go wrong when a control disables itself mid-flow: the browser // moves focus off it to `
`, so a keyboard user's next Tab restarts at the top of the // document; a disabled control is exempt from WCAG's contrast minimum, so the state the user most // needs to read is the one allowed to be unreadable; it explains nothing, because `disabled` has // no announced reason; and it does not actually prevent the double submit, which is a race on the // server. `aria-disabled` says unavailable and keeps the control focusable, the click is refused // here, and the form refuses it again — `Form busy` — because the button is not the only way in. import type { JSX } from 'solid-js'; import { ariaBool } from '../a11y'; import { cx } from '../cx'; import styles from './Button.module.scss'; import { buttonClassKeys } from './button-classes'; import { Spinner } from './Spinner'; import type { ButtonVariant, Size, Tone } from './variants'; export interface ButtonProps { /** Visible label. Never hardcoded inside the component. */ children: JSX.Element; variant?: ButtonVariant | undefined; tone?: Tone | undefined; size?: Size | undefined; type?: 'button' | 'submit' | 'reset' | undefined; disabled?: boolean | undefined; /** Blocks interaction and announces progress via `aria-busy`. */ loading?: boolean | undefined; fullWidth?: boolean | undefined; iconStart?: JSX.Element | undefined; iconEnd?: JSX.Element | undefined; id?: string | undefined; class?: string | undefined; 'aria-label'?: string | undefined; 'aria-controls'?: string | undefined; 'aria-expanded'?: boolean | undefined; onClick?: JSX.EventHandlerUnion