@kennycorrea/ui 0.9.1 → 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 (78) hide show
  1. package/CHANGELOG.md +120 -14
  2. package/README.md +204 -273
  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/Drawer/Drawer.d.ts +1 -1
  18. package/dist/atoms/Drawer/Drawer.js +9 -13
  19. package/dist/atoms/Input/Input.js +7 -1
  20. package/dist/atoms/List/List.d.ts +73 -0
  21. package/dist/atoms/List/List.js +88 -0
  22. package/dist/atoms/List/index.d.ts +1 -0
  23. package/dist/atoms/List/index.js +1 -0
  24. package/dist/atoms/List/swipe.d.ts +68 -0
  25. package/dist/atoms/List/swipe.js +207 -0
  26. package/dist/atoms/NavBar/BackButton.d.ts +10 -0
  27. package/dist/atoms/NavBar/BackButton.js +11 -0
  28. package/dist/atoms/NavBar/NavBar.d.ts +22 -0
  29. package/dist/atoms/NavBar/NavBar.js +29 -0
  30. package/dist/atoms/NavBar/index.d.ts +2 -0
  31. package/dist/atoms/NavBar/index.js +2 -0
  32. package/dist/atoms/NotificationCard/NotificationCard.d.ts +52 -0
  33. package/dist/atoms/NotificationCard/NotificationCard.js +78 -0
  34. package/dist/atoms/NotificationCard/index.d.ts +1 -0
  35. package/dist/atoms/NotificationCard/index.js +1 -0
  36. package/dist/atoms/PullToRefresh/PullToRefresh.d.ts +31 -0
  37. package/dist/atoms/PullToRefresh/PullToRefresh.js +110 -0
  38. package/dist/atoms/PullToRefresh/index.d.ts +1 -0
  39. package/dist/atoms/PullToRefresh/index.js +1 -0
  40. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.d.ts +13 -0
  41. package/dist/atoms/ResponsiveDialog/ResponsiveDialog.js +27 -0
  42. package/dist/atoms/ResponsiveDialog/index.d.ts +1 -0
  43. package/dist/atoms/ResponsiveDialog/index.js +1 -0
  44. package/dist/atoms/ScrollStrip/ScrollStrip.d.ts +17 -0
  45. package/dist/atoms/ScrollStrip/ScrollStrip.js +15 -0
  46. package/dist/atoms/ScrollStrip/index.d.ts +1 -0
  47. package/dist/atoms/ScrollStrip/index.js +1 -0
  48. package/dist/atoms/SegmentedControl/SegmentedControl.d.ts +23 -0
  49. package/dist/atoms/SegmentedControl/SegmentedControl.js +41 -0
  50. package/dist/atoms/SegmentedControl/index.d.ts +1 -0
  51. package/dist/atoms/SegmentedControl/index.js +1 -0
  52. package/dist/atoms/Select/Select.js +1 -1
  53. package/dist/atoms/Sheet/Sheet.d.ts +63 -0
  54. package/dist/atoms/Sheet/Sheet.js +261 -0
  55. package/dist/atoms/Sheet/index.d.ts +1 -0
  56. package/dist/atoms/Sheet/index.js +1 -0
  57. package/dist/atoms/StatTile/StatTile.d.ts +36 -0
  58. package/dist/atoms/StatTile/StatTile.js +17 -0
  59. package/dist/atoms/StatTile/index.d.ts +1 -0
  60. package/dist/atoms/StatTile/index.js +1 -0
  61. package/dist/atoms/TabBar/TabBar.d.ts +42 -0
  62. package/dist/atoms/TabBar/TabBar.js +36 -0
  63. package/dist/atoms/TabBar/index.d.ts +1 -0
  64. package/dist/atoms/TabBar/index.js +1 -0
  65. package/dist/atoms/Textarea/Textarea.d.ts +4 -0
  66. package/dist/atoms/Textarea/Textarea.js +1 -1
  67. package/dist/atoms/Toast/Toast.d.ts +8 -2
  68. package/dist/atoms/Toast/Toast.js +19 -4
  69. package/dist/atoms/index.d.ts +12 -0
  70. package/dist/atoms/index.js +12 -0
  71. package/dist/primitives/index.d.ts +1 -1
  72. package/dist/primitives/index.js +1 -1
  73. package/dist/strings.d.ts +13 -3
  74. package/dist/strings.js +5 -0
  75. package/dist/utils/themePortal.d.ts +19 -0
  76. package/dist/utils/themePortal.js +28 -0
  77. package/package.json +4 -2
  78. package/tokens.css +454 -0
package/README.md CHANGED
@@ -1,350 +1,281 @@
1
1
  # @kennycorrea/ui
2
2
 
3
- Upfront's house component layer: the atoms over Radix Themes, the token bridge, and the accent
4
- logic. Shared by the dashboard (`upfront-web-app`), the marketing site (`upfront-web-site`) and
5
- the admin app (`upfront-admin`).
6
-
7
- **The product brief, the roadmap and every design document live in the `upfront` repository**,
8
- not here. This file covers only what is true about this package.
9
-
10
- ## Why it exists
11
-
12
- Two apps said `variant="secondary"` and `text-ink-muted`, and each carried its own copy of what
13
- those meant. `docs/ecosystem.md` in the product repository called starting a third app the
14
- trigger, and this is the trigger being pulled.
15
-
16
- **The drift, measured rather than assumed.** It was real, and it was concentrated in the
17
- components rather than the colours:
18
-
19
- | | dashboard | site | visible? |
20
- | --- | --- | --- | --- |
21
- | `Button` `secondary` | Themes' `soft` | Themes' `surface` | **yes** |
22
- | `danger` variant | yes | absent | **yes** |
23
- | sizes | `sm`/`md`/`lg` | raw Themes `1`–`4` | **yes** |
24
- | `href` prop | absent | yes | **yes** |
25
- | `--font-sans` | `'Inter', …` | `ui-sans-serif, …` | no — neither app loads Inter |
26
- | `--color-ink` fallback | `#1d3b31` | `#1a211f` | only if Radix's CSS fails to load |
27
- | `--color-hairline` fallback | 12% | 10% | same |
28
-
29
- The `.radix-themes` blocks — the *live* colour assignments — had been kept in step by hand and
30
- agreed. It was the `@theme` fallbacks underneath them that had not, which is drift that is real
31
- and invisible: the least urgent kind and the easiest to leave lying around for years.
32
-
33
- So the honest case for this package is the component API, not the palette.
34
-
35
- ## The Button disagreement, resolved
36
-
37
- **`secondary` now means exactly one thing: Themes' `soft`.** The site's bordered, translucent
38
- look is still available and is called **`outline`**.
39
-
40
- Making one of them simply win would have been a decision about the word rather than about the
41
- need — the site wants the bordered button because it sits over a dark hero, and the dashboard
42
- wants the tinted one because it sits on a panel. Both needs are real; only the name was
43
- ambiguous.
44
-
45
- The package's `Button` is the superset: `loading`, `fullWidth`, ref forwarding and `danger` from
46
- the dashboard, `href` from the site. Nothing was dropped.
47
-
48
- ## Using it
49
-
50
- ```ts
51
- import { Button, Card, Select, UiStringsProvider } from '@kennycorrea/ui';
3
+ A small React + TypeScript component library built on top of
4
+ [Radix Themes](https://www.radix-ui.com/themes). It wraps Radix's components in a consistent,
5
+ opinionated API, adds a few components Radix does not ship, and bridges Radix's design tokens to
6
+ Tailwind utilities.
7
+
8
+ - **Atoms** — `Button`, `Input`, `Select`, `Checkbox`, `Switch`, `Slider`, `Dialog`, `Drawer`,
9
+ `Popover`, `Tooltip`, `Toast`, `NotificationCard`, `RichText` and more.
10
+ - **Primitives** — Radix Themes' layout and typography components, re-exported.
11
+ - **Tokens** — `tokens.css` maps Radix Themes variables to Tailwind colour utilities.
12
+ - **Theming** — light/dark/system appearance and a configurable accent list.
13
+ - **No words of its own** — the few strings components need come from `UiStringsProvider`.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @kennycorrea/ui @radix-ui/themes react react-dom
52
19
  ```
53
20
 
54
- And in the app's own `index.css`:
21
+ In your stylesheet:
55
22
 
56
23
  ```css
57
24
  @import 'tailwindcss';
58
25
  @source '../node_modules/@kennycorrea/ui/dist';
26
+ @import '@radix-ui/themes/styles.css';
59
27
  @import '@kennycorrea/ui/tokens.css';
60
28
  ```
61
29
 
62
- ⚠ **The `@source` line is not optional.** Tailwind does not scan `node_modules`, so without it
63
- every utility class used *inside* the atoms — `w-full`, `inline-flex` — is missing from the
64
- generated CSS. The components come out subtly unstyled and nothing appears in the console.
65
-
66
- ### Colour scales — required
30
+ The `@source` line matters: Tailwind does not scan `node_modules`, so without it the utility
31
+ classes used inside the components are missing from the generated CSS.
67
32
 
68
- The atoms name five Radix scales: **gray**, **jade** (`Badge` success, `Toast` success),
69
- **amber** (warning), **red** (danger/error) and **blue** (`Toast`/`Alert` info). If the app
70
- imports Radix Themes piecemeal rather than the whole `@radix-ui/themes/styles.css`, every one of
71
- them must be imported, **after `base.css`** as Radix documents it, plus the app's own accent:
33
+ Wrap the app in Radix's `<Theme>` and, optionally, `UiStringsProvider`:
72
34
 
73
- ```css
74
- @import '@radix-ui/themes/tokens/base.css';
75
- @import '@radix-ui/themes/tokens/colors/gray.css';
76
- @import '@radix-ui/themes/tokens/colors/jade.css';
77
- @import '@radix-ui/themes/tokens/colors/amber.css';
78
- @import '@radix-ui/themes/tokens/colors/red.css';
79
- @import '@radix-ui/themes/tokens/colors/blue.css';
80
- /* …the accent the app offers, if not one of the above */
81
- @import '@radix-ui/themes/components.css';
82
- @import '@radix-ui/themes/utilities.css';
35
+ ```tsx
36
+ import { Theme } from '@radix-ui/themes';
37
+ import { Button, UiStringsProvider } from '@kennycorrea/ui';
38
+
39
+ <Theme appearance="dark" accentColor="teal">
40
+ <UiStringsProvider strings={{ close: 'Fechar', expand: 'Expandir', collapse: 'Recolher' }}>
41
+ <Button>Save</Button>
42
+ </UiStringsProvider>
43
+ </Theme>;
83
44
  ```
84
45
 
85
- A missing scale is not an error to CSS — the variables are just undefined, and badges and toasts
86
- render colourless. Since 0.8.1 the first `Badge` or `Toaster` to mount checks for them and
87
- `console.warn`s the missing imports by name, outside production builds.
46
+ ### Colour scales
88
47
 
89
- ## Theming — 0.2.0
48
+ If you import Radix Themes piecemeal instead of `styles.css`, import **gray**, **jade**, **amber**,
49
+ **red** and **blue** (used by status variants) plus your accent, after `base.css`. A missing scale
50
+ is not a CSS error — the variables are just undefined — so in development the first `Badge` or
51
+ `Toaster` to mount warns about missing scales in the console.
90
52
 
91
- **A product configures its own accents, default and storage namespace.** Until 0.2.0 all three
92
- were hardcoded to the first app that used this package: the fallback accent was jade, the accent
93
- list was six fixed colours, and preferences were stored under `upfront.theme` / `upfront.accent`.
53
+ ## Theming
94
54
 
95
55
  ```ts
96
- // Once, in the app.
56
+ import { createTheming, applyTheme } from '@kennycorrea/ui';
57
+
97
58
  export const theming = createTheming({
98
59
  accents: ['teal', 'crimson'],
99
- defaultAccent: 'teal',
100
- storagePrefix: 'myapp',
60
+ defaultAccent: 'teal', // must be one of `accents` — a type error otherwise
61
+ storagePrefix: 'myapp', // required: namespaces stored preferences
101
62
  });
102
63
 
103
64
  theming.readAccent(); // 'teal' | 'crimson'
104
65
  ```
105
66
 
106
- Any Radix accent name works, and a product with one brand colour passes one. The list is the
107
- order a picker should render.
67
+ Every accent you offer needs its Radix colour scale imported.
108
68
 
109
- **`defaultAccent` is typed as a member of `accents`**, so naming one that is not on the list is
110
- `TS2322` rather than a colourless first paint — verified by compiling the mistake.
69
+ ### Dark mode and portals
111
70
 
112
- **The literal array needs no `as const`**; the `const` type parameter infers the tuple. A list
113
- declared separately does, because `const list = ['teal']` widens to `string[]` and is rejected.
71
+ Radix Themes defines its colour variables (`--color-panel-solid`, `--gray-*`, `--accent-*`, …)
72
+ only on and under the `.radix-themes` element that `<Theme>` renders, and switches light/dark
73
+ with a `.light`/`.dark` class on that element. **Anything rendered outside it gets light colours,
74
+ or none, whatever appearance the app is in.**
114
75
 
115
- ⚠ **`storagePrefix` is required and has no default.** A default would be one product's name
116
- silently storing another's preferences, and because separate origins never collide, nothing would
117
- ever reveal it.
76
+ So no component in this library portals to `document.body`. Floating components (`Drawer`,
77
+ `NotificationCard`) portal into the nearest `.radix-themes` ancestor, wrapped in a nested
78
+ `<Theme>` that inherits appearance and accent — or into a `container` you pass — and render in
79
+ place when there is no Theme above them. Their colours, borders and shadows use only theme
80
+ variables: the appearance is whatever `<Theme>` says, never `prefers-color-scheme`.
118
81
 
119
- ⚠ **Every accent needs its Radix colour scale imported** in the app's `main.tsx` — see Colour
120
- scales below. A missing scale is not an error; it is a colourless app.
82
+ Do the same in your own code: set appearance on `<Theme>`, not with a `dark` class on
83
+ `<html>`/`<body>`.
121
84
 
122
- **A factory, not a `configure()` call.** Module-level mutable state would have to be written
123
- before the first read, making correctness depend on import order — the same class of silent bug as
124
- the CSS import order this package already has to warn about. An instance cannot be read before it
125
- is made.
85
+ ## Strings
126
86
 
127
- `applyTheme` stays a free function, because `data-theme` is the contract between it and
128
- `tokens.css` rather than a preference: two apps disagreeing about the attribute name would each
129
- need their own stylesheet.
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
89
+ `UiStrings` object. Defaults are English. Most components also accept the label as a prop.
130
90
 
131
- ### What still works unchanged
91
+ ## Mobile presentation layer
132
92
 
133
- `readTheme`, `storeTheme`, `readAccent` and `storeAccent` are still exported as free functions,
134
- bound to an instance with the six default accents, jade, and the `upfront.*` keys. **One
135
- implementation, not two** — they are literally a pre-made instance, so no call site had to change
136
- and nobody who already picked a theme loses it. `ACCENTS` remains as a deprecated alias for
137
- `DEFAULT_ACCENTS`, the same array.
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.
138
95
 
139
- A new product should call `createTheming` with its own name rather than inherit those.
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)` |
140
106
 
141
- ## What is deliberately not here
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`.
142
109
 
143
- - **Dictionaries.** Words belong to an app. The two strings the atoms say on their own behalf —
144
- a standing spinner's label and an empty select's placeholder — come from `UiStringsProvider`,
145
- which each app feeds from its own dictionary. Defaults are English so a host that forgets to
146
- wrap gets something legible rather than something wrong.
147
- - **Auth, stores, API clients.** Same reason, and they were never shared.
148
- - **`ACCENT_LABELS`.** The accent *list* is structure and lives here; what each one is called is
149
- words and does not.
150
- - **Molecules and organisms.** `FormField`, `ConfirmDialog` and the rest stay in the dashboard
151
- until a second app wants one. Moving a component here should be a response to a second
152
- consumer, not an anticipation of one.
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.
153
113
 
154
- ## Colour scales
114
+ **Before** — a bordered panel:
155
115
 
156
- Each app imports only the Radix colour scales it uses, in `main.tsx`, and **the import order is
157
- load-bearing** — scales define `--gray-*` literally and `base.css` remaps it onto the chosen
158
- `grayColor`, so `base.css` must come last. Adding an accent to `ACCENTS` means adding its scale
159
- in every app that offers it, or it resolves to nothing.
160
-
161
- ## ⚠ 0.1.0 was broken for any Node consumer
116
+ ```tsx
117
+ <div className="panel p-4">
118
+ <button className="py-2">Profile</button>
119
+ </div>
120
+ ```
162
121
 
163
- **Fixed in 0.1.1.** The package compiled with `moduleResolution: bundler`, which leaves relative
164
- imports extensionless in the output — `export * from './atoms'`. Vite resolves that happily, so
165
- every build passed and the linked `file:../ui` setup never complained. **Node does not support
166
- directory imports**, so the moment the package was installed from the registry it failed:
122
+ **After** — a grouped list under a blurred bar:
167
123
 
168
- Directory import '…/@kennycorrea/ui/dist/atoms' is not supported
169
- resolving ES modules imported from …/@kennycorrea/ui/dist/index.js
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
+ ```
170
134
 
171
- It surfaced in the dashboard's Vitest run, which resolves dependencies with Node semantics. A
172
- bundler-only consumer would never have noticed, which is why it shipped.
135
+ ## Components
173
136
 
174
- The fix is the rule this project already applies in `api/`, whose own brief states it: **an
175
- explicit `.js` on every relative import**, and `/index.js` for a directory. `tsconfig.json` is now
176
- `moduleResolution: nodenext`, so `tsc` *refuses* the extensionless form — verified by putting one
177
- back and watching it error — rather than silently emitting it again.
137
+ ### Toast
178
138
 
179
- ⚠ **The lesson is about what "the build passes" is worth.** Three apps built, 46 tests passed and
180
- a browser check confirmed the styling, all while the package was unimportable by Node. Nothing was
181
- wrong with those checks; they simply all went through the same bundler. The first consumer with
182
- different resolution semantics found it immediately.
139
+ Mount `<Toaster />` once inside `<Theme>` and call `toast()` from anywhere.
183
140
 
184
- ## Releasing
141
+ ```tsx
142
+ <Toaster duration={5000} position="bottom-right" />
185
143
 
186
- `npm run build` compiles to `dist/` with type declarations. The version in `package.json` is the
187
- contract: bump it, and bump the dependency in each consumer.
144
+ toast({ title: 'Saved' });
145
+ toast.success({ title: 'Moved', description: 'Tue 14:30' });
146
+ const id = toast.error({ title: 'Payment failed', duration: Infinity });
147
+ toast.info({ title: 'Deleted', action: { label: 'Undo', onClick: restore } });
148
+ toast.dismiss(id);
149
+ ```
188
150
 
189
- **`files` in `package.json` is the published surface**: `dist`, `tokens.css` and this README.
190
- `src` is deliberately not in it — consumers import from `dist`, and shipping the TypeScript
191
- source as well would let an app import a path that bypasses the build and the types.
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)" />`.
192
155
 
193
- `prepublishOnly` runs the build, so `npm publish` cannot ship a stale `dist` — which would
194
- otherwise publish silently, with a version number that says nothing about it. `dist/` is
195
- gitignored and present in the tarball, which is the right way round: the build output is not
196
- worth reviewing in a diff and is worth shipping.
156
+ ### Touch feedback and sizing
197
157
 
198
- **The scope is `@kennycorrea` on npm.** `upfront` was not available, so the account was converted
199
- into an organisation. Settling that before the first publish rather than after was worth doing:
200
- the name is an import line in three apps, so renaming it was free today and would have cost a
201
- major version and three coordinated bumps after a release.
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" />`.
202
166
 
203
- GitHub Packages was the alternative and would have forced `@kennyrafael/ui`, because it requires
204
- the scope to match the repository owner. `publishConfig.access: "public"` is set, because a scoped
205
- package defaults to restricted and restricted needs a paid plan.
167
+ ### NotificationCard
206
168
 
207
- ### The publish workflow
169
+ A persistent rich toast: it floats in a viewport corner and stays until it is closed.
208
170
 
209
- `.github/workflows/npm-publish.yml`: a `build` job that runs `npm test`, then `publish-npm`
210
- which needs it.
171
+ ```tsx
172
+ import { Button, NotificationCard } from '@kennycorrea/ui';
173
+
174
+ const [open, setOpen] = useState(true);
175
+
176
+ <NotificationCard
177
+ position="bottom-right" // bottom-left | top-right | top-left
178
+ open={open}
179
+ onOpenChange={setOpen}
180
+ onDismiss={(reason) => track(reason)} // 'close-button' | 'escape' | 'programmatic'
181
+ icon={<AppIcon />}
182
+ title="Add this app to your computer"
183
+ description="Open it like any other app."
184
+ expandLabel="Show me how"
185
+ collapseLabel="Hide steps"
186
+ actions={<Button onClick={install}>Install</Button>}
187
+ >
188
+ <ol>
189
+ <li>Open the browser menu</li>
190
+ <li>Choose "Install app"</li>
191
+ </ol>
192
+ </NotificationCard>;
193
+ ```
211
194
 
212
- ⚠ **Pushing to `main` does not publish, and is not meant to.** The trigger is
213
- `release: [created]` — a GitHub *release*, which means a tag and a release object. A commit on
214
- `main` fires nothing and leaves no run in the Actions tab, which looks identical to a broken
215
- workflow. `workflow_dispatch` is also enabled, so the first publish and any retry can be run from
216
- the Actions tab without inventing a version to hang a release off.
195
+ - **Never auto-dismisses.** Controlled (`open`/`onOpenChange`) or uncontrolled (`defaultOpen`).
196
+ - **Slots**: `icon`, `title`, `description`, `children` (collapsible body), `actions` (footer).
197
+ - **Collapsible body** with a built-in toggle (`aria-expanded`/`aria-controls`), controlled
198
+ (`expanded`/`onExpandedChange`) or uncontrolled (`defaultExpanded`). Labels come from
199
+ `expandLabel`/`collapseLabel` or the `expand`/`collapse` strings.
200
+ - **Close** button named by `closeLabel` or the `close` string. `onDismiss` reports why it
201
+ closed; a controlled `open` set to `false` by the host is `'programmatic'`.
202
+ - **Non-modal**: no focus trap, the page stays interactive, and Escape closes it only while focus
203
+ is inside it.
204
+ - **Quiet**: a `section` labelled by its title and described by its description — not
205
+ `role="alert"` or a live region, so it is not announced on every page load.
206
+ - 16px from the corner, `min(24rem, 100vw - 2rem)` wide, above page content and below dialogs.
207
+ Enters and exits with a transform, switched off under `prefers-reduced-motion`.
208
+ - Themed as described under [Dark mode and portals](#dark-mode-and-portals); pass `container`
209
+ to choose the portal target.
217
210
 
218
- Two things that fail at the `npm publish` step rather than earlier, and are worth checking before
219
- blaming the workflow:
211
+ Full example, in light and dark: `examples/NotificationCards.tsx`.
220
212
 
221
- - **The token must be a repository or organisation Actions secret**, not an account-level one.
222
- GitHub's account-level secrets are for Codespaces; Actions reads repository, environment and
223
- organisation secrets. The step references `secrets.npm_token`, and secret names are
224
- case-insensitive, so `NPM_TOKEN` matches.
225
- - **It must be an npm _Automation_ token** (or a granular token with publish rights). A classic
226
- *Publish* token fails with `EOTP` the moment the account or org requires 2FA for publishing,
227
- because there is no one there to type a one-time password. That failure reads like a bad
228
- credential and is not one.
213
+ ### Drawer
229
214
 
230
- ⚠ **`npm test` has to keep existing.** It is lint, a typecheck and (since 0.5.0) the Vitest suite — and
231
- it is load-bearing for a reason that has nothing to do with quality:
232
- without a `test` script the `build` job fails on `Missing script: "test"`, `publish-npm` never
233
- runs because it needs `build`, and **the release publishes nothing while looking like it worked.**
234
- That was the state when the workflow was first added.
235
-
236
- ### Where it is consumed from today
215
+ A bottom sheet with `collapsed`/`expanded` snap points and a draggable handle.
237
216
 
238
- ✅ **All three apps depend on a version from the registry**, as of 2026-09-30. The
239
- `file:../ui` arrangement is gone, and each app now builds on Vercel from its own repository.
217
+ ```tsx
218
+ // Non-modal: floating banner, page stays usable.
219
+ <Drawer mode="non-modal" title="Get started" peek={<p>Swipe up.</p>}
220
+ footer={<Button>Start</Button>}>
221
+ <ol>…</ol>
222
+ </Drawer>
240
223
 
241
- ```jsonc
242
- // a new consumer
243
- "@kennycorrea/ui": "^0.2.0"
244
-
245
- // web, site and admin, still — see the note below
246
- "@kennycorrea/ui": "^0.1.1"
224
+ // Modal: scrim, focus trapped, page inert; Escape and the scrim close it.
225
+ <Drawer open={open} onOpenChange={setOpen} defaultSnap="expanded" title="New item"
226
+ showCloseButton>
227
+ <form>…</form>
228
+ </Drawer>
247
229
  ```
248
230
 
249
- ⚠ **Never floor a range at 0.1.0** — its published output cannot be imported by Node. `^0.1.0`
250
- resolves correctly today because npm picks the newest match, but a range whose floor is unusable
251
- documents nothing and would let a lockfile built from scratch land back on it.
252
-
253
- ⚠ **Under `0.x`, `^` does not cross a minor.** `^0.1.1` will **not** take 0.2.0, so moving a
254
- consumer onto the new theming API is a `package.json` change in each app, not just a lockfile
255
- refresh. The free functions still work, so an app that does not need `createTheming` can stay
256
- where it is — and the three existing apps deliberately do, for now. Moving them is a one-line
257
- change each plus a lockfile, worth doing to stop three consumers drifting a minor behind, and
258
- worth doing as its own commit rather than inside this one.
231
+ Snap and open state are controllable (`snap`/`onSnapChange`, `open`/`onOpenChange`). The handle
232
+ is a real button with `aria-expanded`. Example: `examples/Drawers.tsx`.
259
233
 
260
- ⚠ **A `file:` install was a symlink; a version install is a copy.** Edits here no longer appear in
261
- a consumer. An app that needs an unreleased change wants `npm link` or a temporary `file:`
262
- override — never a copied file, which is the drift this package exists to end.
234
+ ### Sheet and ResponsiveDialog
263
235
 
264
- ## Toasts — 0.3.0
265
-
266
- Mount `<Toaster />` once, inside `<Theme>`, and call `toast()` from anywhere — it is a factory
267
- over a module-level store, so each call stacks another toast and returns its id.
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.
268
240
 
269
241
  ```tsx
270
- <Toaster duration={5000} position="bottom-right" />
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>
271
246
 
272
- toast({ title: 'Saved' }); // info by default
273
- toast.success({ title: 'Booking moved', description: 'Tue 14:30' });
274
- toast.warning({ title: 'Close to the IVA ceiling', duration: 10_000 });
275
- const id = toast.error({ title: 'Payment failed', duration: Infinity });
276
- toast.info({ title: 'Cancelled', action: { label: 'Undo', onClick: restore } });
277
- toast.dismiss(id); // or toast.dismiss() for all
247
+ <ResponsiveDialog open={open} onOpenChange={setOpen} title="Edit" sheetDetent="large"
248
+ footer={<Button>Save</Button>}>…</ResponsiveDialog>
278
249
  ```
279
250
 
280
- `duration` overrides the `Toaster` default per toast. An action closes the toast after its
281
- `onClick` unless `keepOpen` is set. Passing an existing `id` replaces that toast in place.
282
- The close button's name comes from `UiStrings.dismiss`, so feed it through `UiStringsProvider`.
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.
283
258
 
284
- ## Slider — 0.4.0
259
+ ### Slider
285
260
 
286
- A thin wrap of Radix Themes' `Slider` at size `2`, like the other atoms. `label` is required and
287
- becomes the `aria-label`, because a slider has no visible text of its own.
261
+ `label` is required and becomes the `aria-label`. Values are arrays, one per thumb.
262
+ `fillFrom` fills from a value to the thumb; `marks` adds labelled ticks.
288
263
 
289
264
  ```tsx
290
- <Slider label="Reminder lead time (hours)" min={1} max={48} step={1}
291
- value={[hours]} onValueChange={([h]) => setHours(h)} />
292
- <Slider label="Price range" defaultValue={[10, 60]} min={0} max={200} /> // two thumbs = range
265
+ <Slider label="Lead time (hours)" min={1} max={48} value={[h]} onValueChange={([v]) => setH(v)} />
266
+ <Slider label="Cycle" min={0} max={29} value={day} onValueChange={setDay} fillFrom={16}
267
+ marks={[0, 16, 29].map((value) => ({ value, label: String(value) }))} />
293
268
  ```
294
269
 
295
- Values are arrays, as in Radix: one entry per thumb.
270
+ ## Development
296
271
 
297
- ## Slider fill origin and marks — 0.5.0
298
-
299
- ```tsx
300
- const marks = [0, 6, 11, 16, 22, 29].map((value) => ({ value, label: String(value) }));
301
- <Slider label="Budget cycle" min={0} max={29} value={day} onValueChange={setDay}
302
- fillFrom={16} marks={marks} />
272
+ ```bash
273
+ npm test
303
274
  ```
304
275
 
305
- `fillFrom` fills from that value to the thumb, either way. Themes' slider cannot place its
306
- range, so with `fillFrom` the atom is built on `@radix-ui/react-slider` and styled by
307
- `.ui-slider*` in `tokens.css` from Themes' public tokens — no `.rt-*` class is touched.
308
- Without it, nothing changed. Limits and details: [CHANGELOG.md](CHANGELOG.md); a full example
309
- is in `examples/BudgetCycleSlider.tsx`.
310
-
311
- ⚠ `--ui-slider-thumb-size` mirrors Themes' size-2 thumb (`--space-2 + --space-1`). The fill
312
- and the marks are placed from it, so if Themes ever resizes its thumb, change it there.
313
-
314
- ## Drawer — bottom sheet
315
-
316
- A sheet anchored to the bottom of the viewport: full width on phones, `max-w-lg` and centred
317
- above that (`maxWidthClassName` to change it), safe-area aware, themed from the house tokens.
318
-
319
- ```tsx
320
- // Non-modal: a floating banner, the page stays usable, no focus trap.
321
- <Drawer mode="non-modal" title="Take deposits" peek={<p>Swipe up to see how.</p>}
322
- footer={<Button>Get started</Button>}>
323
- <ol>…steps…</ol>
324
- </Drawer>
325
-
326
- // Modal: scrim, focus trapped, page inert, Escape and the scrim close it.
327
- <Drawer open={open} onOpenChange={setOpen} defaultSnap="expanded" title="New client"
328
- showCloseButton footer={<Button type="submit" form="f">Save</Button>}>
329
- <form id="f">…</form>
330
- </Drawer>
331
- ```
276
+ runs lint (Biome), a typecheck and the Vitest suite (jsdom + Testing Library). `npm run build`
277
+ compiles to `dist/`. Relative imports carry an explicit `.js` extension so the output works
278
+ under Node's ESM resolution as well as bundlers.
332
279
 
333
- - **Snap points** `collapsed` (header + `peek`) and `expanded` (body, scrolls inside, up to
334
- 80vh). Controlled with `snap`/`onSnapChange`, or uncontrolled from `defaultSnap`; likewise
335
- `open`/`onOpenChange`/`defaultOpen`.
336
- - **Drag** the header with touch or mouse: it follows the pointer, then settles past 60px or a
337
- flick faster than 0.5px/ms. Down from collapsed closes when `dismissible` (the default).
338
- A drag from inside the body counts only when the body is scrolled to the top and moving down.
339
- - **The handle is a real button** with `aria-expanded`/`aria-controls`, so keyboard and screen
340
- reader users toggle it the same way.
341
- - **No words of its own.** The handle and close button names come from `expandLabel`,
342
- `collapseLabel` and `closeLabel`, or the new `expand`, `collapse` and `close` keys of
343
- `UiStringsProvider`.
344
- - Motion is a CSS transform, switched off under `prefers-reduced-motion`.
345
-
346
- It portals into the nearest `<Theme>` so it inherits the appearance and accent, falling back to
347
- `<body>`; `container` overrides that.
348
-
349
- Built by hand rather than on Radix Dialog because the non-modal half must not do what Dialog
350
- does to the rest of the page. Both examples are in `examples/Drawers.tsx`.
280
+ Publishing happens from the GitHub workflow on a release; `prepublishOnly` rebuilds `dist`.
281
+ Changes are listed in [CHANGELOG.md](CHANGELOG.md).
@@ -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;