hlmnhkl-ui 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/components/Accordion/Accordion.module.css +5 -0
  4. package/src/components/Accordion/Accordion.tsx +1 -1
  5. package/src/components/Alert/Alert.module.css +20 -6
  6. package/src/components/Badge/Badge.module.css +6 -0
  7. package/src/components/Breadcrumb/Breadcrumb.tsx +7 -5
  8. package/src/components/Button/Button.module.css +29 -0
  9. package/src/components/Button/Button.tsx +6 -2
  10. package/src/components/Collapsible/Collapsible.module.css +8 -0
  11. package/src/components/Collapsible/Collapsible.tsx +3 -1
  12. package/src/components/CopyButton/CopyButton.module.css +19 -6
  13. package/src/components/CopyButton/CopyButton.tsx +15 -9
  14. package/src/components/Dialog/Dialog.module.css +3 -1
  15. package/src/components/EmptyState/EmptyState.module.css +1 -0
  16. package/src/components/IconButton/IconButton.module.css +17 -1
  17. package/src/components/Input/Input.module.css +7 -0
  18. package/src/components/Input/Input.tsx +20 -1
  19. package/src/components/Link/Link.module.css +8 -5
  20. package/src/components/NumberInput/NumberInput.tsx +13 -4
  21. package/src/components/Pagination/Pagination.module.css +1 -1
  22. package/src/components/Pagination/Pagination.tsx +13 -10
  23. package/src/components/ProgressBar/ProgressBar.tsx +1 -1
  24. package/src/components/ScrollArea/ScrollArea.tsx +6 -2
  25. package/src/components/SearchInput/SearchInput.module.css +1 -1
  26. package/src/components/SearchInput/SearchInput.tsx +4 -1
  27. package/src/components/SegmentedControl/SegmentedControl.module.css +29 -2
  28. package/src/components/SegmentedControl/SegmentedControl.tsx +7 -1
  29. package/src/components/Select/Select.module.css +2 -2
  30. package/src/components/Select/Select.tsx +1 -1
  31. package/src/components/Sheet/Sheet.module.css +1 -0
  32. package/src/components/Spinner/Spinner.tsx +7 -1
  33. package/src/components/Switch/Switch.tsx +5 -0
  34. package/src/components/Tag/Tag.module.css +3 -0
  35. package/src/components/Tag/Tag.tsx +16 -6
  36. package/src/components/Textarea/Textarea.tsx +16 -1
  37. package/src/components/Toast/Toast.tsx +4 -2
  38. package/src/tokens.css +8 -0
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # hlmnhkl/ui
2
2
 
3
- React components that move with meaning. Black, white and grey, accessible, and copied into your app so the code is yours.
3
+ React components, measured down to the part you never see: the 44px tap area, the 2px focus ring, the timing of every move. Black, white and grey, and copied into your app so the code is yours.
4
4
 
5
5
  <picture>
6
6
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-dark.png">
7
- <img alt="The hlmnhkl/ui homepage: the logo and name, the install command, and live components below." src="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-light.png">
7
+ <img alt="The hlmnhkl/ui homepage: a real Switch drawn large, with thin lines reading its live tap area, focus ring and timing, under the line 'Measured down to the part you never see.'" src="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-light.png">
8
8
  </picture>
9
9
 
10
10
  **[Docs](https://ui.hlmnhkl.my)** · [Installation](https://ui.hlmnhkl.my/installation) · [Components](https://ui.hlmnhkl.my/button) · [What's new](https://ui.hlmnhkl.my/changelog)
@@ -58,7 +58,7 @@ Every copied file starts with one line naming hlmnhkl/ui and the MIT licence, so
58
58
  - **50 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
59
59
  - **One look, set by tokens.** Every colour, size and timing comes from one file, `tokens.css`. Colour appears only to mean something: good, caution or bad.
60
60
  - **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
61
- - **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, 44px touch targets, screen reader labels, and reduced motion.
61
+ - **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone.
62
62
  - **Small.** React 19 and CSS Modules. No other runtime packages: no animation library, no icon package.
63
63
  - **Works in** Vite, Next.js (App Router) and React Router.
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
@@ -45,6 +45,11 @@
45
45
  box-shadow: inset 0 0 0 var(--hl-line-focus) var(--hl-color-fg);
46
46
  }
47
47
 
48
+ .title {
49
+ min-width: 0;
50
+ overflow-wrap: anywhere;
51
+ }
52
+
48
53
  .chevron {
49
54
  flex: none;
50
55
  width: var(--hl-icon-md);
@@ -105,7 +105,7 @@ export function AccordionItem({ value, title, disabled, children }: AccordionIte
105
105
  disabled={disabled}
106
106
  onClick={() => ctx.toggle(value)}
107
107
  >
108
- <span>{title}</span>
108
+ <span className={styles.title}>{title}</span>
109
109
  <ChevronDownIcon className={styles.chevron} />
110
110
  </button>
111
111
  </Heading>
@@ -29,18 +29,32 @@
29
29
  .error .title { font-weight: var(--hl-weight-semibold); }
30
30
 
31
31
  .banner {
32
+ --strip: var(--hl-color-inverse);
33
+ --strip-fg: var(--hl-color-inverse-fg);
32
34
  border-radius: 0;
33
35
  border-width: 0 0 var(--hl-line);
34
- background: var(--hl-color-inverse);
35
- color: var(--hl-color-inverse-fg);
36
+ background: var(--strip);
37
+ color: var(--strip-fg);
36
38
  align-items: center;
37
39
  padding-block: var(--hl-space-3);
38
40
  }
39
41
 
40
- .banner .body { color: inherit; opacity: 0.8; }
41
- .banner .close { color: inherit; }
42
- @media (hover: hover) {
43
- .banner .close:hover { background: var(--hl-color-inverse-hover); }
42
+ /* Swapped on the children, not the strip, so the strip still reads the page's own values. Links and buttons in the action stay visible. */
43
+ .banner > * {
44
+ --hl-color-bg: var(--strip);
45
+ --hl-color-surface: var(--strip);
46
+ --hl-color-surface-hover: color-mix(in srgb, var(--strip-fg) 14%, var(--strip));
47
+ --hl-color-surface-active: color-mix(in srgb, var(--strip-fg) 20%, var(--strip));
48
+ --hl-color-fg: var(--strip-fg);
49
+ --hl-color-fg-muted: color-mix(in srgb, var(--strip-fg) 80%, var(--strip));
50
+ --hl-color-border: color-mix(in srgb, var(--strip-fg) 24%, var(--strip));
51
+ --hl-color-border-strong: color-mix(in srgb, var(--strip-fg) 55%, var(--strip));
52
+ --hl-color-inverse: var(--strip-fg);
53
+ --hl-color-inverse-fg: var(--strip);
54
+ --hl-color-inverse-hover: color-mix(in srgb, var(--strip-fg) 82%, var(--strip));
55
+ --hl-color-accent: var(--strip-fg);
56
+ --hl-color-accent-fg: var(--strip);
57
+ --hl-focus-ring: 0 0 0 var(--hl-line-focus) var(--strip), 0 0 0 calc(var(--hl-line-focus) * 2) var(--strip-fg);
44
58
  }
45
59
 
46
60
  .icon {
@@ -2,6 +2,7 @@
2
2
  display: inline-flex;
3
3
  align-items: center;
4
4
  gap: var(--hl-space-2);
5
+ max-width: 100%;
5
6
  height: var(--hl-badge-h);
6
7
  padding: 0 var(--hl-space-3);
7
8
  border: var(--hl-line) solid transparent;
@@ -26,13 +27,18 @@
26
27
  .outline[data-tone] { border-color: var(--tone); color: var(--tone); }
27
28
  .subtle[data-tone] { background: var(--tone-soft); color: var(--tone); }
28
29
 
30
+ /* Clip only the x axis: line-height is 1, so hiding y would cut descenders. */
29
31
  .text {
30
32
  display: inline-block;
33
+ min-width: 0;
34
+ overflow-x: clip;
35
+ text-overflow: ellipsis;
31
36
  animation: pop var(--hl-motion-slow) var(--hl-ease-spring);
32
37
  }
33
38
 
34
39
  .dot {
35
40
  position: relative;
41
+ flex: none;
36
42
  width: var(--hl-dot);
37
43
  height: var(--hl-dot);
38
44
  border-radius: var(--hl-radius-full);
@@ -17,7 +17,7 @@ export type BreadcrumbItem = {
17
17
  export type BreadcrumbProps = {
18
18
  /** Path from the top level down. The last item is the current page. */
19
19
  items: BreadcrumbItem[]
20
- /** Above this many items, the middle collapses into a "…" button. */
20
+ /** Above this many items, the middle collapses into a "…" button. With 2 or less, only "…" and the current page show. */
21
21
  maxItems?: number
22
22
  /** Name of the nav landmark. */
23
23
  label?: string
@@ -31,14 +31,16 @@ export function Breadcrumb({ items, maxItems = 4, label = 'Breadcrumb', expandLa
31
31
  const [expanded, setExpanded] = useState(false)
32
32
  const listRef = useRef<HTMLOListElement>(null)
33
33
  const collapse = !expanded && items.length > maxItems
34
- const tail = Math.max(1, maxItems - 2)
35
- const visible = collapse ? [items[0], null, ...items.slice(-tail)] : items
34
+ // Under 3 there is no room for the first item, so only "…" and the current page stay.
35
+ const head = maxItems < 3 ? 0 : 1
36
+ const tail = Math.max(1, maxItems - head - 1)
37
+ const visible = collapse ? [...items.slice(0, head), null, ...items.slice(-tail)] : items
36
38
 
37
39
  // The "…" button is gone after expanding, so focus moves to the first link it revealed.
38
40
  useLayoutEffect(() => {
39
41
  if (!expanded) return
40
- listRef.current?.querySelector<HTMLElement>(':scope > li:nth-child(2) > :is(a, button)')?.focus()
41
- }, [expanded])
42
+ listRef.current?.querySelector<HTMLElement>(`:scope > li:nth-child(${head + 1}) > :is(a, button)`)?.focus()
43
+ }, [expanded, head])
42
44
 
43
45
  return (
44
46
  <nav aria-label={label} className={cx(styles.root, className)}>
@@ -2,7 +2,9 @@
2
2
  --h: var(--hl-control-h-md);
3
3
  position: relative;
4
4
  display: inline-grid;
5
+ grid-template-columns: minmax(0, auto);
5
6
  place-items: center;
7
+ max-width: 100%;
6
8
  min-height: var(--h);
7
9
  padding: 0 var(--hl-space-6);
8
10
  border: var(--hl-line) solid transparent;
@@ -31,6 +33,14 @@
31
33
  inset-inline: 0;
32
34
  }
33
35
 
36
+ @media (pointer: coarse) {
37
+ .sm::after { inset-inline: min(0px, calc((100% - var(--hl-hit-min)) / 2)); }
38
+ }
39
+
40
+ /* Beside another button, the hit area sinks below every box in the row, so it only fills the empty gap. */
41
+ :has(> button + .root, > .root + button) { isolation: isolate; }
42
+ :has(> button + .root, > .root + button) > .root::after { z-index: -1; }
43
+
34
44
  .root:focus-visible {
35
45
  outline: none;
36
46
  box-shadow: var(--hl-focus-ring);
@@ -105,11 +115,30 @@
105
115
  filter var(--hl-motion-base) var(--hl-ease-out);
106
116
  }
107
117
 
118
+ .content { max-width: 100%; }
119
+
120
+ /* Clip only the x axis: line-height is 1, so hiding y would cut descenders. */
121
+ .label {
122
+ min-width: 0;
123
+ overflow-x: clip;
124
+ text-overflow: ellipsis;
125
+ }
126
+
108
127
  .icon {
109
128
  display: inline-flex;
129
+ flex: none;
110
130
  font-size: var(--hl-icon-md);
111
131
  }
112
132
 
133
+ .srOnly {
134
+ position: absolute;
135
+ width: 1px;
136
+ height: 1px;
137
+ overflow: hidden;
138
+ clip-path: inset(50%);
139
+ white-space: nowrap;
140
+ }
141
+
113
142
  /* The spinner shares the label's grid cell, so the width never changes. */
114
143
  .spinner {
115
144
  opacity: 0;
@@ -66,8 +66,12 @@ export function Button({
66
66
  <span className={styles.label}>{children}</span>
67
67
  {trailingIcon && <span className={styles.icon}>{trailingIcon}</span>}
68
68
  </span>
69
- <span className={styles.spinner} aria-hidden={!loading}>
70
- {loading && <Spinner size="sm" label={loadingLabel} />}
69
+ <span className={styles.spinner} aria-hidden="true">
70
+ {loading && <Spinner size="sm" label={null} />}
71
+ </span>
72
+ {/* Stays mounted, because screen readers skip a live region that appears with its text. */}
73
+ <span className={styles.srOnly} role="status">
74
+ {loading ? loadingLabel : null}
71
75
  </span>
72
76
  </>
73
77
  )
@@ -8,6 +8,7 @@
8
8
  display: inline-flex;
9
9
  align-items: center;
10
10
  align-self: flex-start;
11
+ max-width: 100%;
11
12
  gap: var(--hl-space-3);
12
13
  min-height: var(--hl-hit-min);
13
14
  padding: 0 var(--hl-space-4);
@@ -31,7 +32,14 @@
31
32
  .trigger:disabled { opacity: 0.45; cursor: not-allowed; }
32
33
  .trigger:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
33
34
 
35
+ .label {
36
+ min-width: 0;
37
+ overflow-wrap: anywhere;
38
+ text-align: start;
39
+ }
40
+
34
41
  .chevron {
42
+ flex: none;
35
43
  width: var(--hl-icon-md);
36
44
  height: var(--hl-icon-md);
37
45
  color: var(--hl-color-fg-muted);
@@ -40,6 +40,8 @@ export function Collapsible({ label, trigger, open: openProp, defaultOpen = fals
40
40
  cloneElement(trigger, {
41
41
  'aria-expanded': open,
42
42
  'aria-controls': `${id}-panel`,
43
+ // Only set when disabled, so a trigger's own disabled prop is kept otherwise.
44
+ ...(disabled ? { disabled: true, 'aria-disabled': true } : null),
43
45
  onClick: (e: MouseEvent<HTMLElement>) => {
44
46
  trigger.props.onClick?.(e)
45
47
  toggle()
@@ -54,7 +56,7 @@ export function Collapsible({ label, trigger, open: openProp, defaultOpen = fals
54
56
  disabled={disabled}
55
57
  onClick={toggle}
56
58
  >
57
- <span>{label}</span>
59
+ <span className={styles.label}>{label}</span>
58
60
  <ChevronDownIcon className={styles.chevron} />
59
61
  </button>
60
62
  )}
@@ -36,6 +36,7 @@
36
36
  .root:active { transform: scale(var(--hl-press-scale-strong)); transition-duration: var(--hl-motion-instant); }
37
37
  .root:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
38
38
  .root[data-copied] { border-color: var(--hl-color-fg); }
39
+ .root[data-failed] { border-color: var(--hl-color-danger); color: var(--hl-color-danger); }
39
40
 
40
41
  .icons {
41
42
  display: grid;
@@ -44,13 +45,15 @@
44
45
  }
45
46
 
46
47
  .copy,
47
- .check {
48
+ .check,
49
+ .fail {
48
50
  grid-area: 1 / 1;
49
51
  width: 100%;
50
52
  height: 100%;
51
53
  }
52
54
 
53
- .copy {
55
+ .copy,
56
+ .fail {
54
57
  transition:
55
58
  opacity var(--hl-motion-fast) var(--hl-ease-out),
56
59
  transform var(--hl-motion-slow) var(--hl-ease-spring);
@@ -68,7 +71,11 @@
68
71
  transition: stroke-dashoffset var(--hl-motion-base) var(--hl-ease-in);
69
72
  }
70
73
 
71
- .root[data-copied] .copy { opacity: 0; transform: scale(0.4) rotate(-45deg); }
74
+ .fail { opacity: 0; transform: scale(0.4); }
75
+
76
+ .root[data-copied] .copy,
77
+ .root[data-failed] .copy { opacity: 0; transform: scale(0.4) rotate(-45deg); }
78
+ .root[data-failed] .fail { opacity: 1; transform: none; }
72
79
 
73
80
  .root[data-copied] .check {
74
81
  stroke-dashoffset: 0;
@@ -81,7 +88,8 @@
81
88
  }
82
89
 
83
90
  .labelIdle,
84
- .labelDone {
91
+ .labelDone,
92
+ .labelFailed {
85
93
  grid-area: 1 / 1;
86
94
  transition:
87
95
  transform var(--hl-motion-slow) var(--hl-ease-spring),
@@ -89,8 +97,13 @@
89
97
  }
90
98
 
91
99
  .labelDone { transform: translateY(100%); opacity: 0; }
92
- .root[data-copied] .labelIdle { transform: translateY(-100%); opacity: 0; }
93
- .root[data-copied] .labelDone { transform: none; opacity: 1; }
100
+ /* Out of the layout until a copy fails, so the longer words do not widen every button. */
101
+ .labelFailed { display: none; }
102
+ .root[data-failed] .labelFailed { display: block; }
103
+ .root[data-copied] .labelIdle,
104
+ .root[data-failed] .labelIdle { transform: translateY(-100%); opacity: 0; }
105
+ .root[data-copied] .labelDone,
106
+ .root[data-failed] .labelFailed { transform: none; opacity: 1; }
94
107
 
95
108
  .srOnly {
96
109
  position: absolute;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from 'react'
4
4
  import { cx } from '../../internal/cx'
5
- import { CopyIcon } from '../../internal/icons'
5
+ import { CopyIcon, XIcon } from '../../internal/icons'
6
6
  import styles from './CopyButton.module.css'
7
7
 
8
8
  export type CopyButtonProps = {
@@ -12,11 +12,13 @@ export type CopyButtonProps = {
12
12
  label?: string
13
13
  /** Shown and spoken after a successful copy. */
14
14
  copiedLabel?: string
15
+ /** Shown and spoken when the browser blocks the copy. */
16
+ failedLabel?: string
15
17
  /** Shows the label as text next to the icon. */
16
18
  showLabel?: boolean
17
19
  /** Size of the button. */
18
20
  size?: 'sm' | 'md'
19
- /** How long the check stays, in ms. */
21
+ /** How long the check or the failed state stays, in ms. */
20
22
  resetAfter?: number
21
23
  /** Called after the text is copied, with the copied text. Not called if copying fails. */
22
24
  onCopy?: (value: string) => void
@@ -49,29 +51,31 @@ export function CopyButton({
49
51
  value,
50
52
  label = 'Copy',
51
53
  copiedLabel = 'Copied',
54
+ failedLabel = 'Could not copy',
52
55
  showLabel = false,
53
56
  size = 'md',
54
57
  resetAfter = 1800,
55
58
  onCopy,
56
59
  className,
57
60
  }: CopyButtonProps) {
58
- const [copied, setCopied] = useState(false)
61
+ const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
59
62
  const timer = useRef<number | undefined>(undefined)
60
63
  useEffect(() => () => window.clearTimeout(timer.current), [])
61
64
 
62
65
  const copy = async () => {
63
- if (!(await writeText(value))) return
64
- setCopied(true)
65
- onCopy?.(value)
66
+ const ok = await writeText(value)
67
+ setStatus(ok ? 'copied' : 'failed')
68
+ if (ok) onCopy?.(value)
66
69
  window.clearTimeout(timer.current)
67
- timer.current = window.setTimeout(() => setCopied(false), resetAfter)
70
+ timer.current = window.setTimeout(() => setStatus('idle'), resetAfter)
68
71
  }
69
72
 
70
73
  return (
71
74
  <button
72
75
  type="button"
73
76
  className={cx(styles.root, styles[size], showLabel && styles.withLabel, className)}
74
- data-copied={copied || undefined}
77
+ data-copied={status === 'copied' || undefined}
78
+ data-failed={status === 'failed' || undefined}
75
79
  aria-label={showLabel ? undefined : label}
76
80
  onClick={copy}
77
81
  >
@@ -80,14 +84,16 @@ export function CopyButton({
80
84
  <svg viewBox="0 0 24 24" className={styles.check}>
81
85
  <path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1" />
82
86
  </svg>
87
+ <XIcon className={styles.fail} />
83
88
  </span>
84
89
  {showLabel && (
85
90
  <span className={styles.labels}>
86
91
  <span className={styles.labelIdle}>{label}</span>
87
92
  <span className={styles.labelDone} aria-hidden="true">{copiedLabel}</span>
93
+ <span className={styles.labelFailed} aria-hidden="true">{failedLabel}</span>
88
94
  </span>
89
95
  )}
90
- <span className={styles.srOnly} aria-live="polite">{copied ? copiedLabel : ''}</span>
96
+ <span className={styles.srOnly} aria-live="polite">{status === 'copied' ? copiedLabel : status === 'failed' ? failedLabel : ''}</span>
91
97
  </button>
92
98
  )
93
99
  }
@@ -36,7 +36,9 @@
36
36
  animation: panelIn var(--hl-motion-slow) var(--hl-ease-spring);
37
37
  }
38
38
 
39
- .panel[data-state="closed"] { animation: panelOut var(--hl-motion-base) var(--hl-ease-in) forwards; }
39
+ /* A closing panel takes no clicks, so a double click cannot run a footer action twice.
40
+ The overlay still catches them, so they never reach the page behind. */
41
+ .panel[data-state="closed"] { animation: panelOut var(--hl-motion-base) var(--hl-ease-in) forwards; pointer-events: none; }
40
42
 
41
43
  .sm { max-width: var(--hl-panel-w-sm); }
42
44
  .lg { max-width: var(--hl-panel-w-lg); }
@@ -32,6 +32,7 @@
32
32
  font-size: var(--hl-text-lg);
33
33
  font-weight: var(--hl-weight-semibold);
34
34
  letter-spacing: var(--hl-tracking-tight);
35
+ overflow-wrap: anywhere;
35
36
  }
36
37
 
37
38
  h1.title { font-size: var(--hl-text-2xl); }
@@ -23,7 +23,23 @@
23
23
  .root::after {
24
24
  content: "";
25
25
  position: absolute;
26
- inset: min(0px, calc((100% - var(--hl-hit-min)) / 2));
26
+ inset-block: min(0px, calc((100% - var(--hl-hit-min)) / 2));
27
+ inset-inline: 0;
28
+ }
29
+
30
+ /* Wider only for touch, as in Button: a mouse does not need it, and dense desktop rows have no gap to spare. */
31
+ @media (pointer: coarse) {
32
+ .root::after { inset-inline: min(0px, calc((100% - var(--hl-hit-min)) / 2)); }
33
+ }
34
+
35
+ /* Beside another button, the hit area sinks below every box in the row, so it only fills the empty gap.
36
+ Links are left out: a card link with this button laid over it must not swallow the hit area. */
37
+ :has(> button + .root, > .root + button) { isolation: isolate; }
38
+ :has(> button + .root, > .root + button) > .root::after { z-index: -1; }
39
+
40
+ /* A narrow gap leaves no room to share, so a ghost button beside another one takes the full width as its own box. */
41
+ @media (pointer: coarse) {
42
+ .ghost:is(.root + .root, :has(+ .root)) { min-inline-size: var(--hl-hit-min); }
27
43
  }
28
44
 
29
45
  .sm { --s: var(--hl-control-h-sm); }
@@ -38,6 +38,13 @@
38
38
  .lg { --h: var(--hl-control-h-lg); }
39
39
  .floating { --h: var(--hl-field-h-floating); }
40
40
 
41
+ /* On touch the input reaches over the field border, so the whole 44px box is the input's own target. */
42
+ @media (pointer: coarse) {
43
+ .field { min-height: max(var(--h), var(--hl-hit-min)); }
44
+ .control { margin-block: calc(var(--hl-line) * -1); }
45
+ .field[data-invalid] .control { margin-block: calc(var(--hl-line-thick) * -1); }
46
+ }
47
+
41
48
  @media (hover: hover) {
42
49
  .field:hover { border-color: var(--hl-color-fg-muted); }
43
50
  }
@@ -1,8 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useId, useState, type ComponentPropsWithRef, type ReactNode } from 'react'
3
+ import { useEffect, useId, useRef, useState, type ComponentPropsWithRef, type ReactNode } from 'react'
4
4
  import { cx } from '../../internal/cx'
5
5
  import { FieldMessage } from '../../internal/FieldMessage'
6
+ import { mergeRefs } from '../../internal/mergeRefs'
6
7
  import styles from './Input.module.css'
7
8
 
8
9
  export type InputProps = Omit<ComponentPropsWithRef<'input'>, 'size'> & {
@@ -49,6 +50,7 @@ export function Input({
49
50
  defaultValue,
50
51
  onChange,
51
52
  labels: labelsProp,
53
+ ref,
52
54
  'aria-describedby': describedByProp,
53
55
  ...rest
54
56
  }: InputProps) {
@@ -60,6 +62,22 @@ export function Input({
60
62
  const [innerFilled, setInnerFilled] = useState(() => String(defaultValue ?? '') !== '')
61
63
  const filled = value !== undefined ? String(value) !== '' : innerFilled
62
64
  const floating = labelPlacement === 'floating'
65
+ const inner = useRef<HTMLInputElement>(null)
66
+
67
+ // A native form reset changes the value without an onChange
68
+ useEffect(() => {
69
+ const form = inner.current?.form
70
+ if (!form) return
71
+ let frame = 0
72
+ const onReset = () => {
73
+ frame = requestAnimationFrame(() => setInnerFilled(inner.current?.value !== ''))
74
+ }
75
+ form.addEventListener('reset', onReset)
76
+ return () => {
77
+ form.removeEventListener('reset', onReset)
78
+ cancelAnimationFrame(frame)
79
+ }
80
+ }, [])
63
81
  // The caller's ids join ours, so help and error stay linked.
64
82
  const describedBy = [help && !error ? helpId : null, error ? errorId : null, describedByProp]
65
83
  .filter(Boolean)
@@ -89,6 +107,7 @@ export function Input({
89
107
  )}
90
108
  <input
91
109
  id={inputId}
110
+ ref={mergeRefs(inner, ref)}
92
111
  className={styles.input}
93
112
  value={value}
94
113
  defaultValue={defaultValue}
@@ -1,12 +1,13 @@
1
+ /* Inline, not inline-flex, so a long link wraps with the sentence and each line keeps its underline. */
1
2
  .root {
2
3
  position: relative;
3
- display: inline-flex;
4
- align-items: baseline;
5
- gap: var(--hl-space-1);
4
+ display: inline;
6
5
  color: var(--hl-color-fg);
7
6
  font-weight: var(--hl-weight-medium);
8
7
  text-decoration: none;
9
8
  border-radius: var(--hl-radius-xs);
9
+ -webkit-box-decoration-break: clone;
10
+ box-decoration-break: clone;
10
11
  -webkit-tap-highlight-color: transparent;
11
12
  }
12
13
 
@@ -32,12 +33,14 @@
32
33
  }
33
34
 
34
35
  .standalone {
35
- min-height: var(--hl-hit-min);
36
+ display: inline-flex;
36
37
  align-items: center;
38
+ min-height: var(--hl-hit-min);
37
39
  }
38
40
 
39
41
  .arrow {
40
- align-self: center;
42
+ margin-inline-start: var(--hl-space-1);
43
+ vertical-align: middle;
41
44
  width: 0.85em;
42
45
  height: 0.85em;
43
46
  transition: transform var(--hl-motion-base) var(--hl-ease-spring);
@@ -109,6 +109,9 @@ export function NumberInput({
109
109
  const [text, setText] = useState('')
110
110
  const valueRef = useRef(value)
111
111
  valueRef.current = value
112
+ // The hold timer keeps calling the bump from its first render, so it reads the typed text from a ref
113
+ const typed = useRef<string | null>(null)
114
+ typed.current = editing ? text : null
112
115
  const hold = useRef<{ timer?: number; count: number }>({ count: 0 })
113
116
  const decimals = (String(step).split('.')[1] ?? '').length
114
117
  const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format])
@@ -121,11 +124,16 @@ export function NumberInput({
121
124
  valueRef.current = next
122
125
  setValue(next)
123
126
  }
124
- if (editing) setText(next === null ? '' : plain.format(next))
127
+ if (editing) {
128
+ const shown = next === null ? '' : plain.format(next)
129
+ typed.current = shown
130
+ setText(shown)
131
+ }
125
132
  }
126
133
  const bump = (dir: 1 | -1, size = step) => {
127
- const base = valueRef.current ?? (min > -Infinity ? min : 0)
128
- commit(valueRef.current === null ? base : base + dir * size)
134
+ const current = (typed.current === null ? null : parseNumber(typed.current, locale)) ?? valueRef.current
135
+ const base = current ?? (min > -Infinity ? min : 0)
136
+ commit(current === null ? base : base + dir * size)
129
137
  }
130
138
 
131
139
  const stopHold = () => {
@@ -133,7 +141,8 @@ export function NumberInput({
133
141
  hold.current.count = 0
134
142
  }
135
143
  const startHold = (dir: 1 | -1) => (e: PointerEvent<HTMLButtonElement>) => {
136
- if (e.button !== 0) return
144
+ // Chromium still sends pointer events to a disabled button
145
+ if (e.button !== 0 || e.currentTarget.disabled) return
137
146
  e.preventDefault()
138
147
  bump(dir)
139
148
  // A disabled button gets no pointerup, so listen on the window
@@ -68,7 +68,7 @@
68
68
  @media (hover: hover) {
69
69
  .btn:hover:not(:disabled):not([aria-current]) { background: var(--hl-color-surface-hover); }
70
70
  }
71
- .btn:active:not(:disabled) { transform: scale(0.92); }
71
+ .btn:active:not(:disabled) { transform: scale(var(--hl-press-scale-strong)); }
72
72
  .btn[aria-current="page"] { color: var(--hl-color-accent-fg); }
73
73
  .btn:disabled { opacity: 0.35; cursor: not-allowed; }
74
74
  .btn:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
@@ -29,17 +29,18 @@ export type PaginationProps = {
29
29
  className?: string
30
30
  }
31
31
 
32
+ const span = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => from + i)
33
+
34
+ // Always the same number of slots, so the row keeps its width. A gap hides at least two pages.
32
35
  function range(page: number, count: number, siblings: number): Array<number | 'gap'> {
33
36
  const total = siblings * 2 + 5
34
- if (count <= total) return Array.from({ length: count }, (_, i) => i + 1)
35
- const left = Math.max(page - siblings, 2)
36
- const right = Math.min(page + siblings, count - 1)
37
- const out: Array<number | 'gap'> = [1]
38
- if (left > 2) out.push('gap')
39
- for (let p = left; p <= right; p++) out.push(p)
40
- if (right < count - 1) out.push('gap')
41
- out.push(count)
42
- return out
37
+ if (count <= total) return span(1, count)
38
+ const side = total - 2
39
+ const leftGap = page - siblings > 3
40
+ const rightGap = page + siblings < count - 2
41
+ if (!leftGap) return [...span(1, side), 'gap', count]
42
+ if (!rightGap) return [1, 'gap', ...span(count - side + 1, count)]
43
+ return [1, 'gap', ...span(page - siblings, page + siblings), 'gap', count]
43
44
  }
44
45
 
45
46
  export function Pagination({
@@ -54,7 +55,9 @@ export function Pagination({
54
55
  pageLabel = (p) => `Page ${p}`,
55
56
  className,
56
57
  }: PaginationProps) {
57
- const [page, setPage] = useControllable(pageProp, defaultPage, onPageChange)
58
+ const [rawPage, setPage] = useControllable(pageProp, defaultPage, onPageChange)
59
+ // A stored page can sit past the end after pageCount drops.
60
+ const page = Math.min(Math.max(rawPage, 1), Math.max(pageCount, 1))
58
61
  const listRef = useRef<HTMLUListElement>(null)
59
62
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null)
60
63
  const [ready, setReady] = useState(false)
@@ -37,7 +37,7 @@ export function ProgressBar({ label, value, max = 100, showValue = true, hideLab
37
37
  aria-labelledby={id}
38
38
  aria-valuemin={determinate ? 0 : undefined}
39
39
  aria-valuemax={determinate ? max : undefined}
40
- aria-valuenow={determinate ? value : undefined}
40
+ aria-valuenow={determinate ? Math.min(Math.max(value, 0), max) : undefined}
41
41
  className={cx(styles.track, styles[size])}
42
42
  data-indeterminate={!determinate || undefined}
43
43
  >
@@ -90,13 +90,17 @@ export function ScrollArea({ label, maxHeight, orientation = 'vertical', classNa
90
90
  e.preventDefault()
91
91
  e.stopPropagation()
92
92
  const track = e.currentTarget.parentElement as HTMLElement
93
+ const size = axis === 'y' ? vp.clientHeight : vp.clientWidth
93
94
  const full = axis === 'y' ? vp.scrollHeight : vp.scrollWidth
94
- const trackLen = axis === 'y' ? track.clientHeight : track.clientWidth
95
+ const pad = parseFloat(getComputedStyle(track).paddingTop) || 0
96
+ const trackLen = (axis === 'y' ? track.clientHeight : track.clientWidth) - pad * 2
97
+ const thumbLen = axis === 'y' ? e.currentTarget.offsetHeight : e.currentTarget.offsetWidth
98
+ // The thumb travels the track minus its own length, which is all the scroll there is.
95
99
  drag.current = {
96
100
  axis,
97
101
  start: axis === 'y' ? e.clientY : e.clientX,
98
102
  scroll: axis === 'y' ? vp.scrollTop : vp.scrollLeft,
99
- ratio: full / trackLen,
103
+ ratio: (full - size) / Math.max(1, trackLen - thumbLen),
100
104
  pointer: e.pointerId,
101
105
  }
102
106
  e.currentTarget.setPointerCapture(e.pointerId)
@@ -45,7 +45,7 @@
45
45
  }
46
46
 
47
47
  @keyframes popIn {
48
- from { opacity: 0; transform: scale(0.4) rotate(-90deg); }
48
+ from { opacity: 0; transform: scale(var(--hl-check-scale)) rotate(-90deg); }
49
49
  }
50
50
 
51
51
  @keyframes fadeIn {
@@ -42,11 +42,14 @@ export function SearchInput({
42
42
  const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)
43
43
  const inputRef = useRef<HTMLInputElement>(null)
44
44
 
45
- // Ignored while another field has focus, so it never steals typed text
45
+ // Ignored while another field has focus, so it never steals typed text, and while a modal
46
+ // this field is not in is open, so focus never leaves the modal.
46
47
  useEffect(() => {
47
48
  if (!shortcut) return
48
49
  const onKey = (e: KeyboardEvent) => {
49
50
  if (e.key !== shortcut || e.metaKey || e.ctrlKey || e.altKey || isTyping(document.activeElement)) return
51
+ const modals = document.querySelectorAll('[aria-modal="true"]')
52
+ if (modals.length && !modals[modals.length - 1].contains(inputRef.current)) return
50
53
  e.preventDefault()
51
54
  inputRef.current?.focus()
52
55
  inputRef.current?.select()
@@ -36,8 +36,9 @@
36
36
  align-items: center;
37
37
  justify-content: center;
38
38
  gap: var(--hl-space-3);
39
+ --pad: var(--hl-space-5);
39
40
  min-height: calc(var(--hl-control-h-md) - var(--hl-space-1) * 2);
40
- padding: 0 var(--hl-space-5);
41
+ padding: 0 var(--pad);
41
42
  border: 0;
42
43
  border-radius: var(--hl-radius-control);
43
44
  background: transparent;
@@ -60,8 +61,8 @@
60
61
  }
61
62
 
62
63
  .sm .item {
64
+ --pad: var(--hl-space-4);
63
65
  min-height: calc(var(--hl-control-h-sm) - var(--hl-space-1) * 2);
64
- padding: 0 var(--hl-space-4);
65
66
  font-size: var(--hl-text-xs);
66
67
  }
67
68
 
@@ -79,5 +80,31 @@
79
80
 
80
81
  .icon {
81
82
  display: inline-flex;
83
+ flex: none;
82
84
  font-size: var(--hl-icon-md);
83
85
  }
86
+
87
+ /* Too wide for its box (a narrow phone): less padding first, then long labels end in an ellipsis */
88
+ .root[data-tight] .item {
89
+ flex: 1 1 auto;
90
+ min-width: 0;
91
+ --pad: var(--hl-space-3);
92
+ }
93
+
94
+ .text {
95
+ min-width: 0;
96
+ overflow: hidden;
97
+ text-overflow: ellipsis;
98
+ }
99
+
100
+ /* Segments touch, so the box itself is the 44px target. The floor sits on the content, so the
101
+ overflow check still sees the natural width, and tight mode shrinks no further than 44px. */
102
+ @media (pointer: coarse) {
103
+ .text,
104
+ .icon:only-child {
105
+ min-width: calc(var(--hl-hit-min) - var(--pad) * 2);
106
+ justify-content: center;
107
+ text-align: center;
108
+ }
109
+ .root[data-tight] .item { min-width: var(--hl-hit-min); }
110
+ }
@@ -47,6 +47,12 @@ export function SegmentedControl({
47
47
 
48
48
  useLayoutEffect(() => {
49
49
  const measure = () => {
50
+ // Tight mode only when the segments overflow at their normal size, so a wide screen keeps the normal look
51
+ const root = rootRef.current
52
+ if (root) {
53
+ root.removeAttribute('data-tight')
54
+ if (root.scrollWidth > root.clientWidth) root.setAttribute('data-tight', '')
55
+ }
50
56
  const el = refs.current[index]
51
57
  if (el) setPill({ x: el.offsetLeft, w: el.offsetWidth })
52
58
  }
@@ -119,7 +125,7 @@ export function SegmentedControl({
119
125
  onClick={() => setValue(o.value)}
120
126
  >
121
127
  {o.icon && <span className={styles.icon}>{o.icon}</span>}
122
- {o.label}
128
+ {o.label && <span className={styles.text}>{o.label}</span>}
123
129
  </button>
124
130
  )
125
131
  })}
@@ -54,7 +54,7 @@
54
54
 
55
55
  .trigger:focus-visible { box-shadow: var(--hl-focus-ring); }
56
56
  .trigger[data-invalid] { border-color: var(--hl-color-danger); border-width: var(--hl-line-thick); }
57
- .trigger:disabled { opacity: 0.5; cursor: not-allowed; background: var(--hl-color-surface-sunken); }
57
+ .trigger:disabled { opacity: var(--hl-disabled-opacity); cursor: not-allowed; background: var(--hl-color-surface-sunken); }
58
58
 
59
59
  .value {
60
60
  min-width: 0;
@@ -149,7 +149,7 @@
149
149
  width: var(--hl-icon-md);
150
150
  height: var(--hl-icon-md);
151
151
  opacity: 0;
152
- transform: scale(0.4);
152
+ transform: scale(var(--hl-check-scale));
153
153
  transition:
154
154
  opacity var(--hl-motion-fast) var(--hl-ease-out),
155
155
  transform var(--hl-motion-slow) var(--hl-ease-spring);
@@ -127,7 +127,7 @@ export function Select({
127
127
  } else if (k === 'ArrowUp') {
128
128
  e.preventDefault()
129
129
  openList(enabled(selectedIndex) ? selectedIndex : last())
130
- } else if (k.length === 1) {
130
+ } else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
131
131
  openList()
132
132
  runTypeahead(k)
133
133
  }
@@ -35,6 +35,7 @@
35
35
  /* No "from" frame: the close starts where a drag left the sheet */
36
36
  .sheet[data-state="closed"] {
37
37
  animation: sheetOut var(--hl-motion-slow) var(--hl-ease-in-out) forwards;
38
+ pointer-events: none;
38
39
  }
39
40
 
40
41
  .grab {
@@ -1,3 +1,6 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
1
4
  import { cx } from '../../internal/cx'
2
5
  import styles from './Spinner.module.css'
3
6
 
@@ -11,13 +14,16 @@ export type SpinnerProps = {
11
14
  }
12
15
 
13
16
  export function Spinner({ size = 'md', label = 'Loading', className }: SpinnerProps) {
17
+ // The label lands after the empty live region is mounted, so screen readers hear it.
18
+ const [mounted, setMounted] = useState(false)
19
+ useEffect(() => setMounted(true), [])
14
20
  return (
15
21
  <span className={cx(styles.root, styles[size], className)} role={label ? 'status' : undefined}>
16
22
  <svg className={styles.svg} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
17
23
  <circle className={styles.track} cx="12" cy="12" r="9.5" />
18
24
  <circle className={styles.arc} cx="12" cy="12" r="9.5" pathLength="100" />
19
25
  </svg>
20
- {label && <span className={styles.srOnly}>{label}</span>}
26
+ {label && <span className={styles.srOnly}>{mounted ? label : null}</span>}
21
27
  </span>
22
28
  )
23
29
  }
@@ -18,6 +18,8 @@ export type SwitchProps = Omit<ComponentPropsWithRef<'button'>, 'onChange' | 'va
18
18
  onCheckedChange?: (checked: boolean) => void
19
19
  /** Puts the label before the switch, like a settings row. */
20
20
  labelPosition?: 'start' | 'end'
21
+ /** Form field name. The form gets "on" while the switch is on, and nothing while it is off. */
22
+ name?: string
21
23
  }
22
24
 
23
25
  export function Switch({
@@ -31,6 +33,7 @@ export function Switch({
31
33
  className,
32
34
  disabled,
33
35
  onClick,
36
+ name,
34
37
  'aria-describedby': describedByProp,
35
38
  ...rest
36
39
  }: SwitchProps) {
@@ -61,6 +64,8 @@ export function Switch({
61
64
  <label htmlFor={switchId} className={styles.label}>{label}</label>
62
65
  {description && <span id={`${switchId}-desc`} className={styles.description}>{description}</span>}
63
66
  </span>
67
+ {/* Submits like a checkbox: "on" when on, nothing when off */}
68
+ {name && checked && <input type="hidden" name={name} value="on" />}
64
69
  </div>
65
70
  )
66
71
  }
@@ -1,6 +1,7 @@
1
1
  .root {
2
2
  display: inline-flex;
3
3
  align-items: center;
4
+ max-width: 100%;
4
5
  height: var(--hl-control-h-sm);
5
6
  border: var(--hl-line) solid var(--hl-color-border-strong);
6
7
  border-radius: var(--hl-radius-control);
@@ -26,6 +27,7 @@
26
27
  display: inline-flex;
27
28
  align-items: center;
28
29
  gap: var(--hl-space-2);
30
+ min-width: 0;
29
31
  height: 100%;
30
32
  padding: 0 var(--hl-space-4);
31
33
  }
@@ -85,6 +87,7 @@
85
87
  position: relative;
86
88
  display: grid;
87
89
  place-items: center;
90
+ flex: none;
88
91
  width: var(--hl-mini-button);
89
92
  height: var(--hl-mini-button);
90
93
  margin-right: var(--hl-space-1);
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useRef, useState, type ReactNode } from 'react'
4
4
  import { cx } from '../../internal/cx'
5
+ import { focusables } from '../../internal/focus'
5
6
  import { CheckIcon, XIcon } from '../../internal/icons'
6
7
  import { easing, motionMs } from '../../internal/motion'
7
8
  import styles from './Tag.module.css'
@@ -43,16 +44,25 @@ export function Tag({ children, onRemove, removeLabel, selected, onSelectedChang
43
44
  const [leaving, setLeaving] = useState(false)
44
45
  const selectable = selected !== undefined
45
46
 
47
+ // The remove button is about to unmount, so focus moves to the next control (or the one before) instead of the body.
48
+ const finish = () => {
49
+ const el = ref.current
50
+ if (el?.contains(document.activeElement)) {
51
+ const others = focusables(document.body).filter((n) => !el.contains(n))
52
+ const next = others.find((n) => el.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING)
53
+ const prev = [...others].reverse().find((n) => el.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING)
54
+ ;(next ?? prev)?.focus({ preventScroll: true })
55
+ }
56
+ onRemove?.()
57
+ }
58
+
46
59
  // Collapse first so neighbours slide in, then tell the parent
47
60
  const remove = () => {
48
61
  const el = ref.current
49
62
  if (!el || leaving) return
50
63
  setLeaving(true)
51
64
  const ms = motionMs('base')
52
- if (ms === 0 || !el.animate) {
53
- onRemove?.()
54
- return
55
- }
65
+ if (ms === 0 || !el.animate) return finish()
56
66
  const w = el.offsetWidth
57
67
  const anim = el.animate(
58
68
  [
@@ -61,7 +71,7 @@ export function Tag({ children, onRemove, removeLabel, selected, onSelectedChang
61
71
  ],
62
72
  { duration: ms, easing: easing('in-out'), fill: 'forwards' },
63
73
  )
64
- anim.onfinish = () => onRemove?.()
74
+ anim.onfinish = finish
65
75
  }
66
76
 
67
77
  const label = typeof children === 'string' ? children : labels.item
@@ -88,7 +98,7 @@ export function Tag({ children, onRemove, removeLabel, selected, onSelectedChang
88
98
  <span className={styles.static}>{content}</span>
89
99
  )}
90
100
  {onRemove && (
91
- <button type="button" className={styles.remove} aria-label={removeLabel ?? labels.remove(label)} onClick={remove} disabled={leaving}>
101
+ <button type="button" className={styles.remove} aria-label={removeLabel ?? labels.remove(label)} onClick={remove}>
92
102
  <XIcon />
93
103
  </button>
94
104
  )}
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useId, useLayoutEffect, useRef, type ComponentPropsWithRef, type ReactNode } from 'react'
3
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, type ComponentPropsWithRef, type ReactNode } from 'react'
4
4
  import { cx } from '../../internal/cx'
5
5
  import { FieldMessage } from '../../internal/FieldMessage'
6
6
  import { mergeRefs } from '../../internal/mergeRefs'
@@ -74,6 +74,21 @@ export function Textarea({
74
74
 
75
75
  useLayoutEffect(resize, [resize, value])
76
76
 
77
+ // A native form reset changes the value without an onChange
78
+ useEffect(() => {
79
+ const form = inner.current?.form
80
+ if (!form) return
81
+ let frame = 0
82
+ const onReset = () => {
83
+ frame = requestAnimationFrame(resize)
84
+ }
85
+ form.addEventListener('reset', onReset)
86
+ return () => {
87
+ form.removeEventListener('reset', onReset)
88
+ cancelAnimationFrame(frame)
89
+ }
90
+ }, [resize])
91
+
77
92
  const describedBy = [help && !error ? helpId : null, error ? errorId : null, showCount && maxLength ? countId : null, describedByProp]
78
93
  .filter(Boolean)
79
94
  .join(' ')
@@ -76,8 +76,10 @@ export function ToastProvider({ children, visible = 3, label = 'Notifications',
76
76
 
77
77
  const say = (opts: Partial<ToastOptions>, variant?: ToastVariant) => {
78
78
  const text = `${opts.title}${opts.description ? `. ${opts.description}` : ''}`
79
- if (variant === 'error') setUrgent(text)
80
- else setAnnounce(text)
79
+ // The same text twice is not read again, so a trailing space makes it change.
80
+ const fresh = (prev: string) => (prev === text ? `${text} ` : text)
81
+ if (variant === 'error') setUrgent(fresh)
82
+ else setAnnounce(fresh)
81
83
  }
82
84
 
83
85
  const remove = useCallback((id: number, dir: Item['leaving'] = 'down') => {
package/src/tokens.css CHANGED
@@ -187,6 +187,12 @@
187
187
  /* Small controls (icon buttons, checkboxes, steppers) press deeper so the tap is felt. */
188
188
  --hl-press-scale-strong: 0.9;
189
189
  --hl-pop-scale: 0.96;
190
+ /* A check mark or small icon grows in from this size, and a ticked box bumps up to the peak. */
191
+ --hl-check-scale: 0.4;
192
+ --hl-check-peak-scale: 1.12;
193
+
194
+ /* One look for every disabled control */
195
+ --hl-disabled-opacity: 0.5;
190
196
 
191
197
  /* Layers */
192
198
  --hl-z-sticky: 10;
@@ -299,6 +305,8 @@
299
305
  --hl-press-scale: 1;
300
306
  --hl-press-scale-strong: 1;
301
307
  --hl-pop-scale: 1;
308
+ --hl-check-scale: 1;
309
+ --hl-check-peak-scale: 1;
302
310
  }
303
311
  }
304
312