@kennycorrea/ui 0.10.0 → 0.11.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 (70) hide show
  1. package/CHANGELOG.md +110 -16
  2. package/README.md +85 -0
  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 +17 -0
  39. package/dist/atoms/ScrollStrip/ScrollStrip.js +15 -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 +23 -0
  43. package/dist/atoms/SegmentedControl/SegmentedControl.js +41 -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 +10 -0
  68. package/dist/strings.js +5 -0
  69. package/package.json +3 -1
  70. package/tokens.css +393 -0
package/CHANGELOG.md CHANGED
@@ -1,9 +1,103 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.0 — 2026-10-19
3
+ ## 0.11.0 — 2026-10-19
4
+
5
+ - **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.
6
+ - **`Button size="touch"`** (44px) and **`iconOnly`**, which grows the hit area to 44px on coarse pointers via `::before` without changing the visual size.
7
+ - **`Toaster`** — `position` adds `top-center` / `bottom-center` (vertical swipe-to-dismiss); `offset` takes any CSS length (e.g. `var(--tabbar-height)`).
8
+ - **Fields** — `Input`, `Textarea` and the `Select` trigger are 16px on coarse pointers (no iOS focus zoom); `inputMode` / `enterKeyHint` passthrough documented.
9
+
10
+ - **`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.
11
+
12
+ - **`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.
13
+ - **`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.
14
+ - New token: `--page-gutter` (optional; falls back to 16px).
15
+
16
+ - **`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.
17
+ - **`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.
18
+ - **Breaking:** `SegmentedControl` is now the new atom; Radix Themes' one moved to `RadixSegmentedControl` in `primitives`.
19
+
20
+ #
21
+ - **`NavBar`** — a sticky top bar for small screens: `title`, `leading` (a back-button slot) and
22
+ `trailing` (icon buttons). With `largeTitle`, a large left-aligned title sits in the content flow
23
+ and collapses into a small centred title in the bar as the page scrolls past it. The bar gains
24
+ `--material-bar` and a hairline only once scrolled; one `IntersectionObserver`, no scroll
25
+ listeners. Pads for `--safe-top`; height exposed as `--navbar-height`. Accessible: the large title
26
+ is the single `h1` and the small copy is `aria-hidden` (without `largeTitle` the small title is
27
+ the `h1`).
28
+ - **`BackButton`** — chevron plus optional `label`, 44px target; with no label its name is `UiStrings.back`.
29
+ - `UiStrings.back` (default "Back").
30
+ - **`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`).
31
+ - **`ActionSheet`** — `{ label, tone, icon?, onSelect }[]` with optional `title` / `message` and a
32
+ separated Cancel, on the grouped surface. Below `md` it is anchored to the bottom; at `md` and up
33
+ a small centred dialog. Built on Radix `AlertDialog`: no outside-click dismissal, and focus lands
34
+ on Cancel so Enter never fires a destructive action. Escape and Cancel run nothing.
35
+ - **`confirm({ title, message, confirmLabel, cancelLabel, tone })`** — resolves `true` on accept and
36
+ `false` on Cancel, Escape or any other dismissal; never rejects. Callable from anywhere, like
37
+ `toast()`. Mount one `<ConfirmProvider />` inside `<Theme>`; concurrent calls queue.
38
+ - `UiStrings.cancel` and `UiStrings.confirm` (defaults "Cancel", "Confirm").
39
+ - New dependency: `@radix-ui/react-alert-dialog`.
40
+ - **`Sheet`** — a bottom sheet on Radix Dialog: drag handle, `detent` `medium` | `large` | `fit`
41
+ (several at once via `detents`), velocity-based dismiss, a scrolling body that hands off to the
42
+ drag only at scroll-top, a footer above `--safe-bottom` and the keyboard (`visualViewport`), and
43
+ opt-in `scaleBackground` at `large`. Portals into the nearest Theme like the other floating atoms.
44
+ - **`ResponsiveDialog`** — `Dialog`'s props plus `sheetDetent`; a Dialog at `md` and up, a Sheet below.
45
+ - New dependency: `@radix-ui/react-dialog`.
46
+ - **`ListRow` `swipeActions`** `{ leading?, trailing? }` of `{ label, icon?, tone?, onAction }`, and opt-in
47
+ `fullSwipe` (past 60% of the row runs the first trailing action). Drag reveals the actions with
48
+ rubber-banding; one row open per `ListSection`; tap outside, Escape or tapping the row closes it.
49
+ The gesture is offered only on touch and without `prefers-reduced-motion`. The same actions are
50
+ always in the row's context menu (right-click, long-press) and in an overflow button — visible
51
+ when the gesture is off, visually hidden but focusable when it is on.
52
+ - `UiStrings.moreActions` — the overflow button's accessible name (default "More actions").
53
+
54
+
55
+ - **`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.
56
+ - **`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.
57
+ - New token: `--page-gutter` (optional; falls back to 16px).
4
58
 
5
59
  ### Added
6
- - **`NotificationCard`** — a persistent rich toast in a viewport corner: icon, title,
60
+ - **`ListSection`, `ListRow` and `ListRowSkeleton`** — a native-style grouped list. A section
61
+ wraps rows on the grouped surface, with optional `header` and `footer` text outside the group
62
+ and `variant: 'inset' | 'full'`. Hairlines start after the leading slot. A row takes
63
+ `leading`, `title`, `subtitle`, `trailing`, `chevron`, `disabled` and `destructive`, and renders
64
+ a `button` (`onClick`), an `a` (`href`), your own element (`asChild`) or a plain `div`. Rows
65
+ are at least `--tap-min` tall with a pressed state; hover applies only under
66
+ `@media (hover: hover)`. Titles truncate, trailing values use tabular numerals. The list is
67
+ `<ul role="list">`. The skeleton announces `loading` from the strings provider.
68
+
69
+
70
+ - **`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.
71
+ - **`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.
72
+ - New token: `--page-gutter` (optional; falls back to 16px).
73
+
74
+ ### Added
75
+ - **Mobile presentation layer in `tokens.css`** — an opt-in "grouped" surface style (cards on a
76
+ tinted canvas). Tokens: `--surface-grouped`, `--canvas-grouped`, `--separator-inset`,
77
+ `--radius-group` (12px), `--space-row-y` / `--space-row-x` (10 / 12px), `--tap-min` (44px),
78
+ `--safe-top/right/bottom/left` (`env(safe-area-inset-*)`, 0 fallback) and a translucent
79
+ `--material-bar` with `--material-bar-filter` (`saturate(180%) blur(20px)`).
80
+ - Tailwind utilities: `bg-grouped`, `bg-canvas-grouped`, `border-separator-inset`,
81
+ `rounded-group`, `min-h-tap`, `pt-safe`, `pb-safe`, `px-safe`, `bg-material`.
82
+ - Gray-11 and gray-12 text passes WCAG AA on the card, canvas and bar in light and dark
83
+ (`test/grouped.test.tsx`). README section with a before/after example.
84
+
85
+ - **`Card` `variant` and `density`** (also on `CardHeader`, `CardBody`; `density` on
86
+ `CardTitle`). `variant`: `outlined` (default, the current look), `grouped` (no border or
87
+ shadow, `bg-grouped`, `rounded-group`) or `plain` (no surface). `density`: `comfortable`
88
+ (default) or `compact` (row spacing tokens, smaller title). Both take Radix-style responsive
89
+ objects (`{ initial: 'grouped', md: 'outlined' }`, breakpoints `initial`, `xs`, `sm`, `md`,
90
+ `lg`, `xl`). Parts inherit from the `Card` and can override. `raised` is kept, and ignored by
91
+ `grouped` and `plain`. Exports `CardVariant`, `CardDensity`, `Responsive`. Responsive values
92
+ are data attributes matched by media queries in `tokens.css` (Radix breakpoints, not
93
+ Tailwind's). Example in `examples/Cards.tsx`, tests in `test/Card.test.tsx`.
94
+
95
+ No visual change to existing atoms.
96
+
97
+ ## 0.10.0 — 2026-10-19
98
+
99
+ ### Added
100
+ - **`NotificationCard`** — a persistent rich toast in a viewport corner: icon, title,
7
101
  description, collapsible body and actions; controlled or uncontrolled; `onDismiss(reason)`;
8
102
  non-modal, Escape only while focused; a labelled `section`, not a live region.
9
103
 
@@ -12,17 +106,17 @@
12
106
  inside one, its portal is wrapped in a nested `<Theme>` so it keeps the appearance.
13
107
  - README rewritten as a generic presentation of the library.
14
108
 
15
- ## 0.9.1 — 2026-10-18
109
+ ## 0.9.1 — 2026-10-18
16
110
 
17
111
  ### Fixed
18
112
  - **`Drawer` renders inside the nearest `<Theme>`** (`.radix-themes`) instead of under
19
113
  `<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
114
+ sheet lost the app's appearance — a dark app got a light sheet. Falls back to `<body>` when
21
115
  there is no Theme; `container` still overrides it.
22
116
  - In `modal` mode, everything outside the sheet goes `inert` at every level up to `<body>`, not
23
117
  only the sheet's siblings, now that the sheet sits inside the Theme rather than beside it.
24
118
 
25
- ## 0.9.0 — 2026-10-18
119
+ ## 0.9.0 — 2026-10-18
26
120
 
27
121
  ### Added
28
122
  - **`Drawer`**, a bottom sheet with `collapsed` and `expanded` snap points, in two modes:
@@ -37,7 +131,7 @@
37
131
  ### Not verified
38
132
  - Only exercised in jsdom with simulated pointer events, not on a real touch device.
39
133
 
40
- ## 0.8.1 — 2026-10-11
134
+ ## 0.8.1 — 2026-10-11
41
135
 
42
136
  ### Fixed
43
137
  - **`Badge` text passes WCAG AA in both appearances.** Soft badges wrote their text in step a11
@@ -58,7 +152,7 @@
58
152
  ### Docs
59
153
  - README: the colour scales the atoms need, and their order relative to `base.css`.
60
154
 
61
- ## 0.8.0 — 2026-10-09
155
+ ## 0.8.0 — 2026-10-09
62
156
 
63
157
  ### Added
64
158
  - **Every Radix Themes component the atoms do not wrap**, re-exported unchanged from
@@ -68,11 +162,11 @@
68
162
  RadioCards, RadioGroup, ScrollArea, SegmentedControl, TabNav, Table, Tabs, TextField,
69
163
  AccessibleIcon, Portal, Reset, Slot, Slottable, ThemePanel, VisuallyHidden. Where an atom
70
164
  exists it remains the export under that name.
71
- - **`ThemeRoot`** — Themes' `<Theme>`, renamed because `Theme` is already this package's type.
165
+ - **`ThemeRoot`** — Themes' `<Theme>`, renamed because `Theme` is already this package's type.
72
166
  - **All of Radix Icons** (`@radix-ui/react-icons`, 320 `*Icon` components). `Icon` is
73
167
  unchanged and still the house glyph set.
74
168
 
75
- ## 0.7.0 — 2026-10-09
169
+ ## 0.7.0 — 2026-10-09
76
170
 
77
171
  ### Added
78
172
  - **`Alert`**: an inline message with `type` (`info` default, `warning`, `success`, `error`,
@@ -81,7 +175,7 @@
81
175
  theme's own accent. Errors and warnings are `role="alert"`, the rest `role="status"`.
82
176
  - **`Skeleton`** (Themes' skeleton) and **`SkeletonText`** (a `lines`-long paragraph placeholder).
83
177
 
84
- ## 0.6.1 — 2026-10-08
178
+ ## 0.6.1 — 2026-10-08
85
179
 
86
180
  ### Fixed
87
181
  - **`Slider` thumbs always have an accessible name.** Without `fillFrom`, `label` landed on the
@@ -91,7 +185,7 @@
91
185
  Radix's bare "Minimum"/"Maximum". Consumers using `fillFrom={min}` only to get a name can
92
186
  drop it.
93
187
 
94
- ## 0.6.0 — 2026-10-07
188
+ ## 0.6.0 — 2026-10-07
95
189
 
96
190
  ### Added
97
191
  - **`RichText`**: a Tiptap editor (StarterKit) under a Radix `Toolbar`. Marks (bold, italic,
@@ -99,22 +193,22 @@
99
193
  one, so the toolbar is one tab stop with arrow keys. `value`/`onChange` speak HTML; also
100
194
  `placeholder`, `disabled`, `invalid`, `rows`, `aria-label`/`aria-labelledby`, and a ref
101
195
  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`.
196
+ Styled as a Themes `TextArea` (surface, size 2) from public tokens — border, focus ring,
197
+ background, invalid and disabled states — in `.ui-richtext*` in `tokens.css`.
104
198
  - `UiStrings` gains `formatting`, `bold`, `italic`, `strike`, `heading`, `bulletList`,
105
199
  `orderedList` (English defaults; hosts should translate them).
106
200
 
107
201
  ### Requires
108
202
  - `tokens.css` imported; the editor is unstyled without it.
109
203
 
110
- ## 0.5.0 — 2026-10-06
204
+ ## 0.5.0 — 2026-10-06
111
205
 
112
206
  ### Added
113
207
  - **`Slider` `fillFrom`**: fills from a fixed value to the thumb, in either direction, instead of
114
208
  from `min`. Same units as `value`, clamped to [min, max]; nothing is filled when the thumb sits
115
209
  on it. The fill's ends line up with the thumb's centre at every value, using Radix's own
116
210
  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 —
211
+ (`.ui-slider*` in `tokens.css`), because Themes' slider gives no way to place its range —
118
212
  consumers overriding `.rt-SliderRange` can drop that CSS.
119
213
  Single horizontal left-to-right thumb only: with two thumbs, `orientation="vertical"`,
120
214
  `inverted` or `dir="rtl"` it is ignored with a development warning. Margin props are not
@@ -127,7 +221,7 @@
127
221
  - Without `fillFrom`, `Slider` is still the Radix Themes slider, rendered exactly as in 0.4.0.
128
222
 
129
223
  ### Requires
130
- - `tokens.css` imported, as it already must be for toasts — the `fillFrom` path is unstyled
224
+ - `tokens.css` imported, as it already must be for toasts — the `fillFrom` path is unstyled
131
225
  without it.
132
226
 
133
227
  ## 0.4.0
package/README.md CHANGED
@@ -88,6 +88,50 @@ Components never contain literal text. The handful they need — a spinner's lab
88
88
  placeholder, close/expand/collapse names — 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.
@@ -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 {};