@kennycorrea/ui 0.10.0 → 0.11.1

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 (70) hide show
  1. package/CHANGELOG.md +117 -16
  2. package/README.md +92 -1
  3. package/dist/atoms/ActionSheet/ActionSheet.d.ts +30 -0
  4. package/dist/atoms/ActionSheet/ActionSheet.js +42 -0
  5. package/dist/atoms/ActionSheet/confirm.d.ts +21 -0
  6. package/dist/atoms/ActionSheet/confirm.js +65 -0
  7. package/dist/atoms/ActionSheet/index.d.ts +2 -0
  8. package/dist/atoms/ActionSheet/index.js +2 -0
  9. package/dist/atoms/Button/Button.js +3 -3
  10. package/dist/atoms/Button/Button.types.d.ts +7 -1
  11. package/dist/atoms/Card/Card.d.ts +28 -7
  12. package/dist/atoms/Card/Card.js +45 -9
  13. package/dist/atoms/ChipGroup/ChipGroup.d.ts +31 -0
  14. package/dist/atoms/ChipGroup/ChipGroup.js +69 -0
  15. package/dist/atoms/ChipGroup/index.d.ts +1 -0
  16. package/dist/atoms/ChipGroup/index.js +1 -0
  17. package/dist/atoms/Input/Input.js +7 -1
  18. package/dist/atoms/List/List.d.ts +73 -0
  19. package/dist/atoms/List/List.js +88 -0
  20. package/dist/atoms/List/index.d.ts +1 -0
  21. package/dist/atoms/List/index.js +1 -0
  22. package/dist/atoms/List/swipe.d.ts +68 -0
  23. package/dist/atoms/List/swipe.js +207 -0
  24. package/dist/atoms/NavBar/BackButton.d.ts +10 -0
  25. package/dist/atoms/NavBar/BackButton.js +11 -0
  26. package/dist/atoms/NavBar/NavBar.d.ts +22 -0
  27. package/dist/atoms/NavBar/NavBar.js +29 -0
  28. package/dist/atoms/NavBar/index.d.ts +2 -0
  29. package/dist/atoms/NavBar/index.js +2 -0
  30. package/dist/atoms/PullToRefresh/PullToRefresh.d.ts +31 -0
  31. package/dist/atoms/PullToRefresh/PullToRefresh.js +110 -0
  32. package/dist/atoms/PullToRefresh/index.d.ts +1 -0
  33. package/dist/atoms/PullToRefresh/index.js +1 -0
  34. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.d.ts +13 -0
  35. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.js +27 -0
  36. package/dist/atoms/ResponsiveDialog/index.d.ts +1 -0
  37. package/dist/atoms/ResponsiveDialog/index.js +1 -0
  38. package/dist/atoms/ScrollStrip/ScrollStrip.d.ts +21 -0
  39. package/dist/atoms/ScrollStrip/ScrollStrip.js +62 -0
  40. package/dist/atoms/ScrollStrip/index.d.ts +1 -0
  41. package/dist/atoms/ScrollStrip/index.js +1 -0
  42. package/dist/atoms/SegmentedControl/SegmentedControl.d.ts +26 -0
  43. package/dist/atoms/SegmentedControl/SegmentedControl.js +44 -0
  44. package/dist/atoms/SegmentedControl/index.d.ts +1 -0
  45. package/dist/atoms/SegmentedControl/index.js +1 -0
  46. package/dist/atoms/Select/Select.js +1 -1
  47. package/dist/atoms/Sheet/Sheet.d.ts +63 -0
  48. package/dist/atoms/Sheet/Sheet.js +261 -0
  49. package/dist/atoms/Sheet/index.d.ts +1 -0
  50. package/dist/atoms/Sheet/index.js +1 -0
  51. package/dist/atoms/StatTile/StatTile.d.ts +36 -0
  52. package/dist/atoms/StatTile/StatTile.js +17 -0
  53. package/dist/atoms/StatTile/index.d.ts +1 -0
  54. package/dist/atoms/StatTile/index.js +1 -0
  55. package/dist/atoms/TabBar/TabBar.d.ts +42 -0
  56. package/dist/atoms/TabBar/TabBar.js +36 -0
  57. package/dist/atoms/TabBar/index.d.ts +1 -0
  58. package/dist/atoms/TabBar/index.js +1 -0
  59. package/dist/atoms/Textarea/Textarea.d.ts +4 -0
  60. package/dist/atoms/Textarea/Textarea.js +1 -1
  61. package/dist/atoms/Toast/Toast.d.ts +8 -2
  62. package/dist/atoms/Toast/Toast.js +19 -4
  63. package/dist/atoms/index.d.ts +11 -0
  64. package/dist/atoms/index.js +11 -0
  65. package/dist/primitives/index.d.ts +1 -1
  66. package/dist/primitives/index.js +1 -1
  67. package/dist/strings.d.ts +12 -0
  68. package/dist/strings.js +6 -0
  69. package/package.json +4 -1
  70. package/tokens.css +401 -0
package/CHANGELOG.md CHANGED
@@ -1,9 +1,110 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.0 — 2026-10-19
3
+ ## 0.11.1 — 2026-10-19
4
+
5
+ - **`ScrollStrip`** (axe `scrollable-region-focusable`) — now `role="region"` with an accessible name (`aria-label`, default `UiStrings.scrollableRegion` "Scrollable content"). It is a tab stop (`tabIndex=0`) only while its content overflows (ResizeObserver), with a `:focus-visible` ring; ArrowLeft/Right scroll about one item, Home/End jump. Layout, snap and `-mx-4` usage unchanged.
6
+ - **`SegmentedControl`** (axe `target-size`, WCAG 2.5.8) — items no longer shrink below their content, so five items on a 375px screen scroll the track horizontally instead of dropping under 24px; each item has a 24px minimum width, and on `(pointer: coarse)` a 44px hit area via `::before` (visual size unchanged). Labels now sit in a `.ui-segmented__label` span. Arrow-key radio behaviour unchanged.
7
+ - **`ChipGroup`** — 44px hit area on coarse pointers via `::before`. `TabBar` already met both points (pressed state via `aria-current`, 44px items).
8
+ - New string: `UiStrings.scrollableRegion`. New dev dependency: `axe-core` (tests).
9
+
10
+ ## 0.11.0 — 2026-10-19
11
+
12
+ - **Touch feedback** — Button, list rows, `StatTile` and `TabBar` items share a pressed state (`:active` scale `--press-scale` 0.97 + tint, `--press-duration` 120 ms); hover styles are in `@media (hover: hover)`. TabBar no longer dims on press.
13
+ - **`Button size="touch"`** (44px) and **`iconOnly`**, which grows the hit area to 44px on coarse pointers via `::before` without changing the visual size.
14
+ - **`Toaster`** — `position` adds `top-center` / `bottom-center` (vertical swipe-to-dismiss); `offset` takes any CSS length (e.g. `var(--tabbar-height)`).
15
+ - **Fields** — `Input`, `Textarea` and the `Select` trigger are 16px on coarse pointers (no iOS focus zoom); `inputMode` / `enterKeyHint` passthrough documented.
16
+
17
+ - **`PullToRefresh`** — wraps the page (`scroller="document"`, default) or its own scroll container (`"self"`). A touch drag from scroll-top past `threshold` shows a spinner and calls `onRefresh(): Promise<void>`, holding until it settles. Active only with `(pointer: coarse)` and only when the scroller is at the top; no effect on desktop. No transitions under `prefers-reduced-motion`. Label from `UiStrings.refreshing` (default "Refreshing"). A shortcut only: the parent must still offer a visible refresh action.
18
+
19
+ - **`StatTile`** — a compact KPI tile on `Card` (`variant`, `density`): `label`, `value` (tabular numerals), optional `delta` `{ value, direction, tone, label }` with an arrow and a positive/negative/neutral tone independent of direction, a `sparkline` slot, and `href` / `onClick` that make the whole tile a link or button with a chevron.
20
+ - **`ScrollStrip`** — a horizontal scroll-snap row (`snap-x mandatory`, scroll padding equal to `--page-gutter`, hidden scrollbar, items peek at the edge via `itemWidth`) that becomes a `columns`-wide CSS grid from `gridFrom` (default `md`). For KPI rows and card carousels.
21
+ - New token: `--page-gutter` (optional; falls back to 16px).
22
+
23
+ - **`SegmentedControl`** — one choice from 2–5 `options` `{ value, label, disabled? }` on a pill track with a sliding thumb (CSS transform, off under `prefers-reduced-motion`). Equal-width segments, `fullWidth`, `size` `sm` | `md`. Radio semantics with a roving tabindex: arrow keys move and select (wrapping, skipping disabled), Home/End jump. Hand-built; no new dependency.
24
+ - **`ChipGroup`** — horizontally scrolling filter chips, single (`value: string | null`, click the selected chip to clear) or `multiple` (`value: string[]`). Scroll-snap, fade edges that show only where there is more to scroll, and the selected chip scrolled into view. Chips are `aria-pressed` buttons; Left/Right/Home/End move focus.
25
+ - **Breaking:** `SegmentedControl` is now the new atom; Radix Themes' one moved to `RadixSegmentedControl` in `primitives`.
26
+
27
+ #
28
+ - **`NavBar`** — a sticky top bar for small screens: `title`, `leading` (a back-button slot) and
29
+ `trailing` (icon buttons). With `largeTitle`, a large left-aligned title sits in the content flow
30
+ and collapses into a small centred title in the bar as the page scrolls past it. The bar gains
31
+ `--material-bar` and a hairline only once scrolled; one `IntersectionObserver`, no scroll
32
+ listeners. Pads for `--safe-top`; height exposed as `--navbar-height`. Accessible: the large title
33
+ is the single `h1` and the small copy is `aria-hidden` (without `largeTitle` the small title is
34
+ the `h1`).
35
+ - **`BackButton`** — chevron plus optional `label`, 44px target; with no label its name is `UiStrings.back`.
36
+ - `UiStrings.back` (default "Back").
37
+ - **`TabBar`** / **`TabBarSpacer`** — fixed bottom navigation, 3–5 `items` `{ value, label, icon, activeIcon?, badge?, render? }`; `render` receives the props to spread on a router link (`aria-current="page"` on the active one). 49px + `--safe-bottom` (exposed as `--tabbar-height`), `--material-bar` ground, hairline top, brand-coloured active item, `:active` press feedback, hidden from `hideFrom` (default `md`).
38
+ - **`ActionSheet`** — `{ label, tone, icon?, onSelect }[]` with optional `title` / `message` and a
39
+ separated Cancel, on the grouped surface. Below `md` it is anchored to the bottom; at `md` and up
40
+ a small centred dialog. Built on Radix `AlertDialog`: no outside-click dismissal, and focus lands
41
+ on Cancel so Enter never fires a destructive action. Escape and Cancel run nothing.
42
+ - **`confirm({ title, message, confirmLabel, cancelLabel, tone })`** — resolves `true` on accept and
43
+ `false` on Cancel, Escape or any other dismissal; never rejects. Callable from anywhere, like
44
+ `toast()`. Mount one `<ConfirmProvider />` inside `<Theme>`; concurrent calls queue.
45
+ - `UiStrings.cancel` and `UiStrings.confirm` (defaults "Cancel", "Confirm").
46
+ - New dependency: `@radix-ui/react-alert-dialog`.
47
+ - **`Sheet`** — a bottom sheet on Radix Dialog: drag handle, `detent` `medium` | `large` | `fit`
48
+ (several at once via `detents`), velocity-based dismiss, a scrolling body that hands off to the
49
+ drag only at scroll-top, a footer above `--safe-bottom` and the keyboard (`visualViewport`), and
50
+ opt-in `scaleBackground` at `large`. Portals into the nearest Theme like the other floating atoms.
51
+ - **`ResponsiveDialog`** — `Dialog`'s props plus `sheetDetent`; a Dialog at `md` and up, a Sheet below.
52
+ - New dependency: `@radix-ui/react-dialog`.
53
+ - **`ListRow` `swipeActions`** `{ leading?, trailing? }` of `{ label, icon?, tone?, onAction }`, and opt-in
54
+ `fullSwipe` (past 60% of the row runs the first trailing action). Drag reveals the actions with
55
+ rubber-banding; one row open per `ListSection`; tap outside, Escape or tapping the row closes it.
56
+ The gesture is offered only on touch and without `prefers-reduced-motion`. The same actions are
57
+ always in the row's context menu (right-click, long-press) and in an overflow button — visible
58
+ when the gesture is off, visually hidden but focusable when it is on.
59
+ - `UiStrings.moreActions` — the overflow button's accessible name (default "More actions").
60
+
61
+
62
+ - **`StatTile`** — a compact KPI tile on `Card` (`variant`, `density`): `label`, `value` (tabular numerals), optional `delta` `{ value, direction, tone, label }` with an arrow and a positive/negative/neutral tone independent of direction, a `sparkline` slot, and `href` / `onClick` that make the whole tile a link or button with a chevron.
63
+ - **`ScrollStrip`** — a horizontal scroll-snap row (`snap-x mandatory`, scroll padding equal to `--page-gutter`, hidden scrollbar, items peek at the edge via `itemWidth`) that becomes a `columns`-wide CSS grid from `gridFrom` (default `md`). For KPI rows and card carousels.
64
+ - New token: `--page-gutter` (optional; falls back to 16px).
4
65
 
5
66
  ### Added
6
- - **`NotificationCard`** — a persistent rich toast in a viewport corner: icon, title,
67
+ - **`ListSection`, `ListRow` and `ListRowSkeleton`** — a native-style grouped list. A section
68
+ wraps rows on the grouped surface, with optional `header` and `footer` text outside the group
69
+ and `variant: 'inset' | 'full'`. Hairlines start after the leading slot. A row takes
70
+ `leading`, `title`, `subtitle`, `trailing`, `chevron`, `disabled` and `destructive`, and renders
71
+ a `button` (`onClick`), an `a` (`href`), your own element (`asChild`) or a plain `div`. Rows
72
+ are at least `--tap-min` tall with a pressed state; hover applies only under
73
+ `@media (hover: hover)`. Titles truncate, trailing values use tabular numerals. The list is
74
+ `<ul role="list">`. The skeleton announces `loading` from the strings provider.
75
+
76
+
77
+ - **`StatTile`** — a compact KPI tile on `Card` (`variant`, `density`): `label`, `value` (tabular numerals), optional `delta` `{ value, direction, tone, label }` with an arrow and a positive/negative/neutral tone independent of direction, a `sparkline` slot, and `href` / `onClick` that make the whole tile a link or button with a chevron.
78
+ - **`ScrollStrip`** — a horizontal scroll-snap row (`snap-x mandatory`, scroll padding equal to `--page-gutter`, hidden scrollbar, items peek at the edge via `itemWidth`) that becomes a `columns`-wide CSS grid from `gridFrom` (default `md`). For KPI rows and card carousels.
79
+ - New token: `--page-gutter` (optional; falls back to 16px).
80
+
81
+ ### Added
82
+ - **Mobile presentation layer in `tokens.css`** — an opt-in "grouped" surface style (cards on a
83
+ tinted canvas). Tokens: `--surface-grouped`, `--canvas-grouped`, `--separator-inset`,
84
+ `--radius-group` (12px), `--space-row-y` / `--space-row-x` (10 / 12px), `--tap-min` (44px),
85
+ `--safe-top/right/bottom/left` (`env(safe-area-inset-*)`, 0 fallback) and a translucent
86
+ `--material-bar` with `--material-bar-filter` (`saturate(180%) blur(20px)`).
87
+ - Tailwind utilities: `bg-grouped`, `bg-canvas-grouped`, `border-separator-inset`,
88
+ `rounded-group`, `min-h-tap`, `pt-safe`, `pb-safe`, `px-safe`, `bg-material`.
89
+ - Gray-11 and gray-12 text passes WCAG AA on the card, canvas and bar in light and dark
90
+ (`test/grouped.test.tsx`). README section with a before/after example.
91
+
92
+ - **`Card` `variant` and `density`** (also on `CardHeader`, `CardBody`; `density` on
93
+ `CardTitle`). `variant`: `outlined` (default, the current look), `grouped` (no border or
94
+ shadow, `bg-grouped`, `rounded-group`) or `plain` (no surface). `density`: `comfortable`
95
+ (default) or `compact` (row spacing tokens, smaller title). Both take Radix-style responsive
96
+ objects (`{ initial: 'grouped', md: 'outlined' }`, breakpoints `initial`, `xs`, `sm`, `md`,
97
+ `lg`, `xl`). Parts inherit from the `Card` and can override. `raised` is kept, and ignored by
98
+ `grouped` and `plain`. Exports `CardVariant`, `CardDensity`, `Responsive`. Responsive values
99
+ are data attributes matched by media queries in `tokens.css` (Radix breakpoints, not
100
+ Tailwind's). Example in `examples/Cards.tsx`, tests in `test/Card.test.tsx`.
101
+
102
+ No visual change to existing atoms.
103
+
104
+ ## 0.10.0 — 2026-10-19
105
+
106
+ ### Added
107
+ - **`NotificationCard`** — a persistent rich toast in a viewport corner: icon, title,
7
108
  description, collapsible body and actions; controlled or uncontrolled; `onDismiss(reason)`;
8
109
  non-modal, Escape only while focused; a labelled `section`, not a live region.
9
110
 
@@ -12,17 +113,17 @@
12
113
  inside one, its portal is wrapped in a nested `<Theme>` so it keeps the appearance.
13
114
  - README rewritten as a generic presentation of the library.
14
115
 
15
- ## 0.9.1 — 2026-10-18
116
+ ## 0.9.1 — 2026-10-18
16
117
 
17
118
  ### Fixed
18
119
  - **`Drawer` renders inside the nearest `<Theme>`** (`.radix-themes`) instead of under
19
120
  `<body>`. Outside a Theme none of the house tokens or the accent are defined, so in 0.9.0 the
20
- sheet lost the app's appearance — a dark app got a light sheet. Falls back to `<body>` when
121
+ sheet lost the app's appearance — a dark app got a light sheet. Falls back to `<body>` when
21
122
  there is no Theme; `container` still overrides it.
22
123
  - In `modal` mode, everything outside the sheet goes `inert` at every level up to `<body>`, not
23
124
  only the sheet's siblings, now that the sheet sits inside the Theme rather than beside it.
24
125
 
25
- ## 0.9.0 — 2026-10-18
126
+ ## 0.9.0 — 2026-10-18
26
127
 
27
128
  ### Added
28
129
  - **`Drawer`**, a bottom sheet with `collapsed` and `expanded` snap points, in two modes:
@@ -37,7 +138,7 @@
37
138
  ### Not verified
38
139
  - Only exercised in jsdom with simulated pointer events, not on a real touch device.
39
140
 
40
- ## 0.8.1 — 2026-10-11
141
+ ## 0.8.1 — 2026-10-11
41
142
 
42
143
  ### Fixed
43
144
  - **`Badge` text passes WCAG AA in both appearances.** Soft badges wrote their text in step a11
@@ -58,7 +159,7 @@
58
159
  ### Docs
59
160
  - README: the colour scales the atoms need, and their order relative to `base.css`.
60
161
 
61
- ## 0.8.0 — 2026-10-09
162
+ ## 0.8.0 — 2026-10-09
62
163
 
63
164
  ### Added
64
165
  - **Every Radix Themes component the atoms do not wrap**, re-exported unchanged from
@@ -68,11 +169,11 @@
68
169
  RadioCards, RadioGroup, ScrollArea, SegmentedControl, TabNav, Table, Tabs, TextField,
69
170
  AccessibleIcon, Portal, Reset, Slot, Slottable, ThemePanel, VisuallyHidden. Where an atom
70
171
  exists it remains the export under that name.
71
- - **`ThemeRoot`** — Themes' `<Theme>`, renamed because `Theme` is already this package's type.
172
+ - **`ThemeRoot`** — Themes' `<Theme>`, renamed because `Theme` is already this package's type.
72
173
  - **All of Radix Icons** (`@radix-ui/react-icons`, 320 `*Icon` components). `Icon` is
73
174
  unchanged and still the house glyph set.
74
175
 
75
- ## 0.7.0 — 2026-10-09
176
+ ## 0.7.0 — 2026-10-09
76
177
 
77
178
  ### Added
78
179
  - **`Alert`**: an inline message with `type` (`info` default, `warning`, `success`, `error`,
@@ -81,7 +182,7 @@
81
182
  theme's own accent. Errors and warnings are `role="alert"`, the rest `role="status"`.
82
183
  - **`Skeleton`** (Themes' skeleton) and **`SkeletonText`** (a `lines`-long paragraph placeholder).
83
184
 
84
- ## 0.6.1 — 2026-10-08
185
+ ## 0.6.1 — 2026-10-08
85
186
 
86
187
  ### Fixed
87
188
  - **`Slider` thumbs always have an accessible name.** Without `fillFrom`, `label` landed on the
@@ -91,7 +192,7 @@
91
192
  Radix's bare "Minimum"/"Maximum". Consumers using `fillFrom={min}` only to get a name can
92
193
  drop it.
93
194
 
94
- ## 0.6.0 — 2026-10-07
195
+ ## 0.6.0 — 2026-10-07
95
196
 
96
197
  ### Added
97
198
  - **`RichText`**: a Tiptap editor (StarterKit) under a Radix `Toolbar`. Marks (bold, italic,
@@ -99,22 +200,22 @@
99
200
  one, so the toolbar is one tab stop with arrow keys. `value`/`onChange` speak HTML; also
100
201
  `placeholder`, `disabled`, `invalid`, `rows`, `aria-label`/`aria-labelledby`, and a ref
101
202
  exposing `{ editor, focus }`.
102
- Styled as a Themes `TextArea` (surface, size 2) from public tokens — border, focus ring,
103
- background, invalid and disabled states — in `.ui-richtext*` in `tokens.css`.
203
+ Styled as a Themes `TextArea` (surface, size 2) from public tokens — border, focus ring,
204
+ background, invalid and disabled states — in `.ui-richtext*` in `tokens.css`.
104
205
  - `UiStrings` gains `formatting`, `bold`, `italic`, `strike`, `heading`, `bulletList`,
105
206
  `orderedList` (English defaults; hosts should translate them).
106
207
 
107
208
  ### Requires
108
209
  - `tokens.css` imported; the editor is unstyled without it.
109
210
 
110
- ## 0.5.0 — 2026-10-06
211
+ ## 0.5.0 — 2026-10-06
111
212
 
112
213
  ### Added
113
214
  - **`Slider` `fillFrom`**: fills from a fixed value to the thumb, in either direction, instead of
114
215
  from `min`. Same units as `value`, clamped to [min, max]; nothing is filled when the thumb sits
115
216
  on it. The fill's ends line up with the thumb's centre at every value, using Radix's own
116
217
  in-bounds offset. Built on `@radix-ui/react-slider` and styled from Themes' public tokens
117
- (`.ui-slider*` in `tokens.css`), because Themes' slider gives no way to place its range —
218
+ (`.ui-slider*` in `tokens.css`), because Themes' slider gives no way to place its range —
118
219
  consumers overriding `.rt-SliderRange` can drop that CSS.
119
220
  Single horizontal left-to-right thumb only: with two thumbs, `orientation="vertical"`,
120
221
  `inverted` or `dir="rtl"` it is ignored with a development warning. Margin props are not
@@ -127,7 +228,7 @@
127
228
  - Without `fillFrom`, `Slider` is still the Radix Themes slider, rendered exactly as in 0.4.0.
128
229
 
129
230
  ### Requires
130
- - `tokens.css` imported, as it already must be for toasts — the `fillFrom` path is unstyled
231
+ - `tokens.css` imported, as it already must be for toasts — the `fillFrom` path is unstyled
131
232
  without it.
132
233
 
133
234
  ## 0.4.0
package/README.md CHANGED
@@ -85,9 +85,53 @@ Do the same in your own code: set appearance on `<Theme>`, not with a `dark` cla
85
85
  ## Strings
86
86
 
87
87
  Components never contain literal text. The handful they need — a spinner's label, a select's
88
- placeholder, close/expand/collapse names — come from `UiStringsProvider`, which takes a partial
88
+ placeholder, close/expand/collapse names, the `ScrollStrip` region name (`scrollableRegion`) — come from `UiStringsProvider`, which takes a partial
89
89
  `UiStrings` object. Defaults are English. Most components also accept the label as a prop.
90
90
 
91
+ ## Mobile presentation layer
92
+
93
+ An opt-in "grouped" surface style — rows in rounded cards on a tinted canvas, as in native
94
+ settings screens. Nothing existing reads these tokens, so atoms render exactly as before.
95
+
96
+ | Token | Value |
97
+ | --- | --- |
98
+ | `--surface-grouped` | Card background (white / gray-3 in dark), no border |
99
+ | `--canvas-grouped` | Tinted page background (gray-3 / gray-1 in dark) |
100
+ | `--separator-inset` | Hairline for row separators |
101
+ | `--radius-group` | 12px |
102
+ | `--space-row-y` / `--space-row-x` | 10px / 12px |
103
+ | `--tap-min` | 44px |
104
+ | `--safe-top/right/bottom/left` | `env(safe-area-inset-*)`, `0px` fallback |
105
+ | `--material-bar`, `--material-bar-filter` | Translucent bar colour and `saturate(180%) blur(20px)` |
106
+
107
+ Tailwind utilities: `bg-grouped`, `bg-canvas-grouped`, `border-separator-inset`, `rounded-group`,
108
+ `min-h-tap`, `pt-safe`, `pb-safe`, `px-safe`, `bg-material`.
109
+
110
+ Gray-11 and gray-12 text pass WCAG AA on the card, the canvas and the bar (over white or black
111
+ content) in both appearances — see `test/grouped.test.tsx`. Remember `viewport-fit=cover` in the
112
+ viewport meta, or the safe-area insets are always 0.
113
+
114
+ **Before** — a bordered panel:
115
+
116
+ ```tsx
117
+ <div className="panel p-4">
118
+ <button className="py-2">Profile</button>
119
+ </div>
120
+ ```
121
+
122
+ **After** — a grouped list under a blurred bar:
123
+
124
+ ```tsx
125
+ <div className="min-h-screen bg-canvas-grouped px-safe">
126
+ <header className="bg-material pt-safe sticky top-0">Settings</header>
127
+ <ul className="rounded-group bg-grouped mx-3 divide-y divide-separator-inset">
128
+ <li><button className="min-h-tap w-full px-3 text-left">Profile</button></li>
129
+ <li><button className="min-h-tap w-full px-3 text-left">Notifications</button></li>
130
+ </ul>
131
+ <div className="pb-safe" />
132
+ </div>
133
+ ```
134
+
91
135
  ## Components
92
136
 
93
137
  ### Toast
@@ -104,6 +148,22 @@ toast.info({ title: 'Deleted', action: { label: 'Undo', onClick: restore } });
104
148
  toast.dismiss(id);
105
149
  ```
106
150
 
151
+ `position` also takes `'top-center'` and `'bottom-center'`; centred stacks swipe away vertically
152
+ (towards their edge). `offset` is the distance from the anchored edge and accepts any CSS length, so
153
+ the stack can clear a bar: `<Toaster position="top-center" offset="var(--navbar-height)" />`, or
154
+ `<Toaster position="bottom-center" offset="calc(var(--tabbar-height) + 8px)" />`.
155
+
156
+ ### Touch feedback and sizing
157
+
158
+ - Button, list rows, `StatTile` and `TabBar` items share one pressed state: `:active` scales to
159
+ `--press-scale` (0.97) with a gray tint over `--press-duration` (120 ms); off under
160
+ `prefers-reduced-motion`. Our hover styles are inside `@media (hover: hover)`.
161
+ - `<Button size="touch">` has a 44px minimum height. `<Button iconOnly variant="ghost">` keeps its
162
+ visual size but gets a 44px hit area on coarse pointers.
163
+ - `Input`, `Textarea` and `Select` render at 16px on coarse pointers so iOS does not zoom on focus.
164
+ `inputMode` and `enterKeyHint` on `Input` / `Textarea` pass through to the native element:
165
+ `<Input type="text" inputMode="decimal" enterKeyHint="done" />`.
166
+
107
167
  ### NotificationCard
108
168
 
109
169
  A persistent rich toast: it floats in a viewport corner and stays until it is closed.
@@ -171,6 +231,31 @@ A bottom sheet with `collapsed`/`expanded` snap points and a draggable handle.
171
231
  Snap and open state are controllable (`snap`/`onSnapChange`, `open`/`onOpenChange`). The handle
172
232
  is a real button with `aria-expanded`. Example: `examples/Drawers.tsx`.
173
233
 
234
+ ### Sheet and ResponsiveDialog
235
+
236
+ `Sheet` is a bottom sheet on Radix Dialog (focus trap, Escape, scrim and `aria-modal` are
237
+ Radix's). `detent` is `'medium'` (50% of the visible viewport), `'large'` (92%) or `'fit'` (the
238
+ content, capped at 90%). Pass `detents` to let the user move between several; dragging down from
239
+ the lowest dismisses.
240
+
241
+ ```tsx
242
+ <Sheet open={open} onOpenChange={setOpen} title="New booking" detents={['medium', 'large']}
243
+ defaultDetent="medium" scaleBackground footer={<Button>Save</Button>}>
244
+ <form>…</form>
245
+ </Sheet>
246
+
247
+ <ResponsiveDialog open={open} onOpenChange={setOpen} title="Edit" sheetDetent="large"
248
+ footer={<Button>Save</Button>}>…</ResponsiveDialog>
249
+ ```
250
+
251
+ A flick faster than 0.5 px/ms, or a drag of 25% of the height, dismisses. The body scrolls
252
+ normally and hands the gesture to the sheet only when it is at its top and pulled down. The footer
253
+ clears `--safe-bottom` and follows `visualViewport`, so it and the focused field stay above the
254
+ on-screen keyboard. `scaleBackground` scales the rest of the Theme at `large`; a transformed
255
+ ancestor re-parents `position: fixed` descendants, so fixed bars in the page scale with it.
256
+ `ResponsiveDialog` takes `Dialog`'s props plus `sheetDetent` and is a Dialog at `md` (768px) and
257
+ up; crossing the breakpoint while open remounts `children`, so keep form state in the parent.
258
+
174
259
  ### Slider
175
260
 
176
261
  `label` is required and becomes the `aria-label`. Values are arrays, one per thumb.
@@ -194,3 +279,9 @@ under Node's ESM resolution as well as bundlers.
194
279
 
195
280
  Publishing happens from the GitHub workflow on a release; `prepublishOnly` rebuilds `dist`.
196
281
  Changes are listed in [CHANGELOG.md](CHANGELOG.md).
282
+
283
+ ## Accessibility notes (mobile)
284
+
285
+ - **ScrollStrip** is a named `region` (`aria-label`, default `UiStrings.scrollableRegion`) and a tab stop only while it overflows; arrow keys scroll it by about one item.
286
+ - **SegmentedControl** scrolls horizontally when its items do not fit rather than shrinking them below 24px; on coarse pointers hit areas are 44px. With more than 4 options on a phone, prefer `ChipGroup`.
287
+ - **ChipGroup** chips are `aria-pressed` buttons with a 44px coarse-pointer hit area.
@@ -0,0 +1,30 @@
1
+ import { type ReactNode } from 'react';
2
+ export type ActionSheetTone = 'neutral' | 'brand' | 'danger';
3
+ export interface ActionSheetAction {
4
+ label: string;
5
+ /** `danger` is red. Default `neutral`. */
6
+ tone?: ActionSheetTone;
7
+ icon?: ReactNode;
8
+ /** Runs, then the sheet closes. */
9
+ onSelect: () => void;
10
+ }
11
+ export interface ActionSheetProps {
12
+ open: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ actions: ActionSheetAction[];
15
+ title?: ReactNode;
16
+ message?: ReactNode;
17
+ /** Overrides the Cancel button's label (otherwise the `cancel` UI string). */
18
+ cancelLabel?: string;
19
+ /** Where the portal mounts. Default: the nearest `<Theme>`, else rendered in place. */
20
+ container?: HTMLElement | null;
21
+ }
22
+ /**
23
+ * A short list of actions to choose between, on Radix `AlertDialog`: below `md` anchored to the
24
+ * bottom as a grouped list with a separated Cancel; at `md` and up a small centred dialog.
25
+ *
26
+ * AlertDialog rather than Dialog because the user is being asked to decide — it cannot be
27
+ * dismissed by clicking outside, and focus lands on Cancel, so Enter never fires a destructive
28
+ * action by accident. Escape and Cancel both close it without running anything.
29
+ */
30
+ export declare function ActionSheet({ open, onOpenChange, actions, title, message, cancelLabel, container, }: ActionSheetProps): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as AlertDialog from '@radix-ui/react-alert-dialog';
3
+ import { Theme } from '@radix-ui/themes';
4
+ import { useSyncExternalStore } from 'react';
5
+ import { useUiStrings } from '../../strings.js';
6
+ import { cn } from '../../utils/cn.js';
7
+ import { useThemePortalTarget } from '../../utils/themePortal.js';
8
+ /** Tailwind's `md`. At and above it the sheet becomes a small centred dialog. */
9
+ const MD = '(min-width: 768px)';
10
+ function subscribe(onChange) {
11
+ const query = window.matchMedia(MD);
12
+ query.addEventListener('change', onChange);
13
+ return () => query.removeEventListener('change', onChange);
14
+ }
15
+ function useIsMdUp() {
16
+ return useSyncExternalStore(subscribe, () => window.matchMedia(MD).matches, () => true);
17
+ }
18
+ const TONE = {
19
+ neutral: 'text-ink',
20
+ brand: 'text-brand-700',
21
+ danger: 'text-[var(--red-11)]',
22
+ };
23
+ /**
24
+ * A short list of actions to choose between, on Radix `AlertDialog`: below `md` anchored to the
25
+ * bottom as a grouped list with a separated Cancel; at `md` and up a small centred dialog.
26
+ *
27
+ * AlertDialog rather than Dialog because the user is being asked to decide — it cannot be
28
+ * dismissed by clicking outside, and focus lands on Cancel, so Enter never fires a destructive
29
+ * action by accident. Escape and Cancel both close it without running anything.
30
+ */
31
+ export function ActionSheet({ open, onOpenChange, actions, title, message, cancelLabel, container, }) {
32
+ const strings = useUiStrings();
33
+ const mdUp = useIsMdUp();
34
+ const { anchorRef, target, resolved } = useThemePortalTarget(container);
35
+ const cancel = cancelLabel ?? strings.cancel;
36
+ const hasHeader = title != null || message != null;
37
+ const header = hasHeader ? (_jsxs("div", { className: "px-4 py-3 text-center", children: [title != null ? (_jsx(AlertDialog.Title, { className: "m-0 text-sm font-medium text-ink", children: title })) : (_jsx(AlertDialog.Title, { className: "sr-only", children: cancel })), message != null ? (_jsx(AlertDialog.Description, { className: "m-0 mt-0.5 text-sm text-ink-muted", children: message })) : null] })) : null;
38
+ const rows = actions.map((a) => (_jsxs(AlertDialog.Action, { onClick: a.onSelect, "data-tone": a.tone ?? 'neutral', className: cn('flex min-h-[var(--tap-min,44px)] w-full items-center justify-center gap-2 border-t border-hairline px-4 text-base', 'outline-none hover:bg-[var(--gray-a3)] focus-visible:bg-[var(--gray-a3)] active:bg-[var(--gray-a4)]', TONE[a.tone ?? 'neutral']), children: [a.icon ? _jsx("span", { "aria-hidden": "true", children: a.icon }) : null, a.label] }, a.label)));
39
+ const cancelButton = (_jsx(AlertDialog.Cancel, { className: cn('flex min-h-[var(--tap-min,44px)] w-full items-center justify-center px-4 text-base font-medium text-ink', 'outline-none hover:bg-[var(--gray-a3)] focus-visible:bg-[var(--gray-a3)] active:bg-[var(--gray-a4)]'), children: cancel }));
40
+ const body = mdUp ? (_jsxs(AlertDialog.Content, { "data-presentation": "dialog", ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet-dialog fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl bg-sheet text-ink shadow-2xl outline-none ring-1 ring-hairline", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows }), _jsx("div", { className: "border-t border-hairline", children: cancelButton })] })) : (_jsxs(AlertDialog.Content, { "data-presentation": "sheet", ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-lg flex-col gap-2 px-2 pb-[max(0.5rem,var(--safe-bottom,0px))] outline-none", children: [_jsxs("div", { className: "overflow-hidden rounded-xl bg-grouped", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows })] }), _jsx("div", { className: "overflow-hidden rounded-xl bg-grouped", children: cancelButton })] }));
41
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), resolved ? (_jsx(AlertDialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsx(AlertDialog.Portal, { container: target ?? undefined, children: _jsxs(Theme, { hasBackground: false, children: [_jsx(AlertDialog.Overlay, { className: "ui-sheet-scrim fixed inset-0 z-40 bg-scrim" }), body] }) }) })) : null] }));
42
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ActionSheetTone } from './ActionSheet.js';
3
+ export interface ConfirmOptions {
4
+ title: ReactNode;
5
+ message?: ReactNode;
6
+ /** The accept button. Default: the `confirm` UI string. */
7
+ confirmLabel?: string;
8
+ /** The dismiss button. Default: the `cancel` UI string. */
9
+ cancelLabel?: string;
10
+ /** `danger` for destructive confirmations. Default `neutral`. */
11
+ tone?: ActionSheetTone;
12
+ }
13
+ /**
14
+ * Asks and resolves `true` on accept, `false` on Cancel, Escape or any other dismissal.
15
+ * Never rejects.
16
+ *
17
+ * if (await confirm({ title: 'Delete booking?', confirmLabel: 'Delete', tone: 'danger' })) …
18
+ */
19
+ export declare function confirm(options: ConfirmOptions): Promise<boolean>;
20
+ /** Renders the pending confirmation. Mount once, inside `<Theme>`, next to `<Toaster>`. */
21
+ export declare function ConfirmProvider(): import("react").JSX.Element | null;
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from 'react';
3
+ import { useUiStrings } from '../../strings.js';
4
+ import { ActionSheet } from './ActionSheet.js';
5
+ /**
6
+ * A module-level queue, like `toast()`: `confirm()` can be called from any handler without
7
+ * threading a hook through. One `<ConfirmProvider>` per app; calls made while one is showing
8
+ * wait their turn rather than replacing it, so nobody's question is silently answered "no".
9
+ */
10
+ let queue = [];
11
+ const listeners = new Set();
12
+ /** A fresh array per change, so `useSyncExternalStore` sees it. */
13
+ let snapshot;
14
+ function emit(next) {
15
+ queue = next;
16
+ snapshot = queue[0];
17
+ for (const l of listeners)
18
+ l();
19
+ }
20
+ function subscribe(l) {
21
+ listeners.add(l);
22
+ return () => listeners.delete(l);
23
+ }
24
+ /**
25
+ * Asks and resolves `true` on accept, `false` on Cancel, Escape or any other dismissal.
26
+ * Never rejects.
27
+ *
28
+ * if (await confirm({ title: 'Delete booking?', confirmLabel: 'Delete', tone: 'danger' })) …
29
+ */
30
+ export function confirm(options) {
31
+ return new Promise((resolve) => emit([...queue, { ...options, resolve }]));
32
+ }
33
+ /** Renders the pending confirmation. Mount once, inside `<Theme>`, next to `<Toaster>`. */
34
+ export function ConfirmProvider() {
35
+ const current = useSyncExternalStore(subscribe, () => snapshot, () => undefined);
36
+ const strings = useUiStrings();
37
+ if (!current)
38
+ return null;
39
+ const settle = (ok) => {
40
+ if (queue[0] !== current)
41
+ return;
42
+ current.resolve(ok);
43
+ emit(queue.slice(1));
44
+ };
45
+ return (_jsx(ActionSheet, { open: true, onOpenChange: (open) => {
46
+ if (!open)
47
+ settle(false);
48
+ }, title: current.title, message: current.message, cancelLabel: current.cancelLabel, actions: [
49
+ {
50
+ label: current.confirmLabel ?? strings.confirm,
51
+ tone: current.tone,
52
+ onSelect: () => settle(true),
53
+ },
54
+ ] }, queue.length === 0 ? 'none' : String(queueId(current))));
55
+ }
56
+ const ids = new WeakMap();
57
+ let nextId = 0;
58
+ function queueId(p) {
59
+ let id = ids.get(p);
60
+ if (id === undefined) {
61
+ id = ++nextId;
62
+ ids.set(p, id);
63
+ }
64
+ return id;
65
+ }
@@ -0,0 +1,2 @@
1
+ export * from './ActionSheet.js';
2
+ export * from './confirm.js';
@@ -0,0 +1,2 @@
1
+ export * from './ActionSheet.js';
2
+ export * from './confirm.js';
@@ -18,8 +18,8 @@ const VARIANTS = {
18
18
  danger: { variant: 'solid', color: 'red' },
19
19
  };
20
20
  /** Themes sizes are 1–4; ours are the three a form actually needs. */
21
- const SIZES = { sm: '2', md: '3', lg: '4' };
22
- export const Button = forwardRef(function Button({ variant = 'primary', size = 'md', loading = false, fullWidth = false, asChild = false, href, className, disabled, children, ...props }, ref) {
21
+ const SIZES = { sm: '2', md: '3', lg: '4', touch: '3' };
22
+ export const Button = forwardRef(function Button({ variant = 'primary', size = 'md', loading = false, fullWidth = false, iconOnly = false, asChild = false, href, className, disabled, children, ...props }, ref) {
23
23
  const intent = VARIANTS[variant];
24
24
  // An anchor is `asChild` underneath, so everything below treats the two the same: neither a
25
25
  // link nor a slotted child may be given `loading` or `disabled`, because those are button
@@ -29,6 +29,6 @@ export const Button = forwardRef(function Button({ variant = 'primary', size = '
29
29
  // Themes renders its own spinner and keeps the label's width while it spins, so the
30
30
  // button does not jump. It does not disable the button, which is why `disabled` still
31
31
  // accounts for it.
32
- loading: slotted ? undefined : loading, disabled: slotted ? undefined : disabled || loading, className: cn(fullWidth && 'w-full', className), ...props, children: href !== undefined ? _jsx("a", { href: href, children: children }) : children }));
32
+ loading: slotted ? undefined : loading, disabled: slotted ? undefined : disabled || loading, "data-variant": variant, "data-size": size, "data-icon-only": iconOnly || undefined, className: cn('ui-button', fullWidth && 'w-full', className), ...props, children: href !== undefined ? _jsx("a", { href: href, children: children }) : children }));
33
33
  return themed;
34
34
  });
@@ -12,7 +12,8 @@ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
12
12
  * panel. So `secondary` means exactly one thing everywhere and the other look has its own name.
13
13
  */
14
14
  export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
15
- export type ButtonSize = 'sm' | 'md' | 'lg';
15
+ /** `touch` is `md` type with a 44px minimum height, for primary actions on touch screens. */
16
+ export type ButtonSize = 'sm' | 'md' | 'lg' | 'touch';
16
17
  /**
17
18
  * Derived from Themes' own props rather than from `ButtonHTMLAttributes`.
18
19
  *
@@ -26,6 +27,11 @@ export interface ButtonProps extends Omit<ComponentPropsWithoutRef<typeof Themed
26
27
  /** Renders a spinner and disables the button. */
27
28
  loading?: boolean;
28
29
  fullWidth?: boolean;
30
+ /**
31
+ * The button holds only an icon. Its visual size is unchanged, but on coarse pointers the hit
32
+ * area grows to 44px with an invisible `::before`, so neighbours do not shrink.
33
+ */
34
+ iconOnly?: boolean;
29
35
  /** Renders the child element instead of a `button`, e.g. a router `Link`. */
30
36
  asChild?: boolean;
31
37
  /**
@@ -1,5 +1,19 @@
1
- import type { HTMLAttributes } from 'react';
2
- export interface CardProps extends HTMLAttributes<HTMLDivElement> {
1
+ import { type HTMLAttributes } from 'react';
2
+ export type CardVariant = 'outlined' | 'grouped' | 'plain';
3
+ export type CardDensity = 'comfortable' | 'compact';
4
+ /** Radix Themes' breakpoints, in the order they apply. `initial` is the unconditional value. */
5
+ export declare const CARD_BREAKPOINTS: readonly ["initial", "xs", "sm", "md", "lg", "xl"];
6
+ export type CardBreakpoint = (typeof CARD_BREAKPOINTS)[number];
7
+ /** A value, or one per breakpoint — Radix Themes' responsive prop convention. */
8
+ export type Responsive<T> = T | Partial<Record<CardBreakpoint, T>>;
9
+ interface CardStyleProps {
10
+ /** `outlined` is the bordered card; `grouped` a borderless rounded tile for tinted canvases;
11
+ * `plain` has no surface at all. Grouped and plain ignore `raised`. */
12
+ variant?: Responsive<CardVariant>;
13
+ /** `compact` tightens padding to the row spacing tokens and quietens the title. */
14
+ density?: Responsive<CardDensity>;
15
+ }
16
+ export interface CardProps extends HTMLAttributes<HTMLDivElement>, CardStyleProps {
3
17
  /** Lifts the card off the ground — for dialogs and anything that floats. */
4
18
  raised?: boolean;
5
19
  }
@@ -8,9 +22,16 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
8
22
  *
9
23
  * Themes pads its card for you; the house card is built from a header and a body with a
10
24
  * rule between them, so the padding belongs to those rather than to the card. `p-0` is the
11
- * whole of the customisation.
25
+ * whole of the customisation. Variant and density reach the header, title and body through
26
+ * context, and each can still override them.
12
27
  */
13
- export declare function Card({ raised, className, ...props }: CardProps): import("react").JSX.Element;
14
- export declare function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>): import("react").JSX.Element;
15
- export declare function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>): import("react").JSX.Element;
16
- export declare function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>): import("react").JSX.Element;
28
+ export declare function Card({ raised, variant, density, className, ...props }: CardProps): import("react").JSX.Element;
29
+ export interface CardPartProps extends HTMLAttributes<HTMLDivElement>, CardStyleProps {
30
+ }
31
+ export declare function CardHeader({ variant, density, className, ...props }: CardPartProps): import("react").JSX.Element;
32
+ export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
33
+ density?: Responsive<CardDensity>;
34
+ }
35
+ export declare function CardTitle({ density, className, ...props }: CardTitleProps): import("react").JSX.Element;
36
+ export declare function CardBody({ variant, density, className, ...props }: CardPartProps): import("react").JSX.Element;
37
+ export {};