@kennycorrea/ui 0.9.0 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — 2026-10-19
4
+
5
+ ### Added
6
+ - **`NotificationCard`** — a persistent rich toast in a viewport corner: icon, title,
7
+ description, collapsible body and actions; controlled or uncontrolled; `onDismiss(reason)`;
8
+ non-modal, Escape only while focused; a labelled `section`, not a live region.
9
+
10
+ ### Changed
11
+ - **`Drawer` never portals to `<body>`.** Without a `<Theme>` above it, it renders in place;
12
+ inside one, its portal is wrapped in a nested `<Theme>` so it keeps the appearance.
13
+ - README rewritten as a generic presentation of the library.
14
+
15
+ ## 0.9.1 — 2026-10-18
16
+
17
+ ### Fixed
18
+ - **`Drawer` renders inside the nearest `<Theme>`** (`.radix-themes`) instead of under
19
+ `<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
21
+ there is no Theme; `container` still overrides it.
22
+ - In `modal` mode, everything outside the sheet goes `inert` at every level up to `<body>`, not
23
+ only the sheet's siblings, now that the sheet sits inside the Theme rather than beside it.
24
+
3
25
  ## 0.9.0 — 2026-10-18
4
26
 
5
27
  ### Added
package/README.md CHANGED
@@ -1,347 +1,196 @@
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.
108
-
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.
111
-
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.
114
-
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.
118
-
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.
121
-
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.
126
-
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.
130
-
131
- ### What still works unchanged
132
-
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.
138
-
139
- A new product should call `createTheming` with its own name rather than inherit those.
140
-
141
- ## What is deliberately not here
142
-
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.
67
+ Every accent you offer needs its Radix colour scale imported.
153
68
 
154
- ## Colour scales
69
+ ### Dark mode and portals
155
70
 
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.
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.**
160
75
 
161
- ## ⚠ 0.1.0 was broken for any Node consumer
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`.
162
81
 
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:
82
+ Do the same in your own code: set appearance on `<Theme>`, not with a `dark` class on
83
+ `<html>`/`<body>`.
167
84
 
168
- Directory import '…/@kennycorrea/ui/dist/atoms' is not supported
169
- resolving ES modules imported from …/@kennycorrea/ui/dist/index.js
85
+ ## Strings
170
86
 
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.
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.
173
90
 
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.
91
+ ## Components
178
92
 
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.
93
+ ### Toast
183
94
 
184
- ## Releasing
95
+ Mount `<Toaster />` once inside `<Theme>` and call `toast()` from anywhere.
185
96
 
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.
188
-
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.
192
-
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.
197
-
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.
202
-
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.
206
-
207
- ### The publish workflow
208
-
209
- `.github/workflows/npm-publish.yml`: a `build` job that runs `npm test`, then `publish-npm`
210
- which needs it.
211
-
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.
217
-
218
- Two things that fail at the `npm publish` step rather than earlier, and are worth checking before
219
- blaming the workflow:
97
+ ```tsx
98
+ <Toaster duration={5000} position="bottom-right" />
220
99
 
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.
100
+ toast({ title: 'Saved' });
101
+ toast.success({ title: 'Moved', description: 'Tue 14:30' });
102
+ const id = toast.error({ title: 'Payment failed', duration: Infinity });
103
+ toast.info({ title: 'Deleted', action: { label: 'Undo', onClick: restore } });
104
+ toast.dismiss(id);
105
+ ```
229
106
 
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
107
+ ### NotificationCard
237
108
 
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.
109
+ A persistent rich toast: it floats in a viewport corner and stays until it is closed.
240
110
 
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"
111
+ ```tsx
112
+ import { Button, NotificationCard } from '@kennycorrea/ui';
113
+
114
+ const [open, setOpen] = useState(true);
115
+
116
+ <NotificationCard
117
+ position="bottom-right" // bottom-left | top-right | top-left
118
+ open={open}
119
+ onOpenChange={setOpen}
120
+ onDismiss={(reason) => track(reason)} // 'close-button' | 'escape' | 'programmatic'
121
+ icon={<AppIcon />}
122
+ title="Add this app to your computer"
123
+ description="Open it like any other app."
124
+ expandLabel="Show me how"
125
+ collapseLabel="Hide steps"
126
+ actions={<Button onClick={install}>Install</Button>}
127
+ >
128
+ <ol>
129
+ <li>Open the browser menu</li>
130
+ <li>Choose "Install app"</li>
131
+ </ol>
132
+ </NotificationCard>;
247
133
  ```
248
134
 
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.
135
+ - **Never auto-dismisses.** Controlled (`open`/`onOpenChange`) or uncontrolled (`defaultOpen`).
136
+ - **Slots**: `icon`, `title`, `description`, `children` (collapsible body), `actions` (footer).
137
+ - **Collapsible body** with a built-in toggle (`aria-expanded`/`aria-controls`), controlled
138
+ (`expanded`/`onExpandedChange`) or uncontrolled (`defaultExpanded`). Labels come from
139
+ `expandLabel`/`collapseLabel` or the `expand`/`collapse` strings.
140
+ - **Close** button named by `closeLabel` or the `close` string. `onDismiss` reports why it
141
+ closed; a controlled `open` set to `false` by the host is `'programmatic'`.
142
+ - **Non-modal**: no focus trap, the page stays interactive, and Escape closes it only while focus
143
+ is inside it.
144
+ - **Quiet**: a `section` labelled by its title and described by its description — not
145
+ `role="alert"` or a live region, so it is not announced on every page load.
146
+ - 16px from the corner, `min(24rem, 100vw - 2rem)` wide, above page content and below dialogs.
147
+ Enters and exits with a transform, switched off under `prefers-reduced-motion`.
148
+ - Themed as described under [Dark mode and portals](#dark-mode-and-portals); pass `container`
149
+ to choose the portal target.
259
150
 
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.
151
+ Full example, in light and dark: `examples/NotificationCards.tsx`.
263
152
 
264
- ## Toasts — 0.3.0
153
+ ### Drawer
265
154
 
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.
155
+ A bottom sheet with `collapsed`/`expanded` snap points and a draggable handle.
268
156
 
269
157
  ```tsx
270
- <Toaster duration={5000} position="bottom-right" />
158
+ // Non-modal: floating banner, page stays usable.
159
+ <Drawer mode="non-modal" title="Get started" peek={<p>Swipe up.</p>}
160
+ footer={<Button>Start</Button>}>
161
+ <ol>…</ol>
162
+ </Drawer>
271
163
 
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
164
+ // Modal: scrim, focus trapped, page inert; Escape and the scrim close it.
165
+ <Drawer open={open} onOpenChange={setOpen} defaultSnap="expanded" title="New item"
166
+ showCloseButton>
167
+ <form>…</form>
168
+ </Drawer>
278
169
  ```
279
170
 
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`.
171
+ Snap and open state are controllable (`snap`/`onSnapChange`, `open`/`onOpenChange`). The handle
172
+ is a real button with `aria-expanded`. Example: `examples/Drawers.tsx`.
283
173
 
284
- ## Slider — 0.4.0
174
+ ### Slider
285
175
 
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.
176
+ `label` is required and becomes the `aria-label`. Values are arrays, one per thumb.
177
+ `fillFrom` fills from a value to the thumb; `marks` adds labelled ticks.
288
178
 
289
179
  ```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
180
+ <Slider label="Lead time (hours)" min={1} max={48} value={[h]} onValueChange={([v]) => setH(v)} />
181
+ <Slider label="Cycle" min={0} max={29} value={day} onValueChange={setDay} fillFrom={16}
182
+ marks={[0, 16, 29].map((value) => ({ value, label: String(value) }))} />
293
183
  ```
294
184
 
295
- Values are arrays, as in Radix: one entry per thumb.
296
-
297
- ## Slider fill origin and marks — 0.5.0
185
+ ## Development
298
186
 
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} />
187
+ ```bash
188
+ npm test
303
189
  ```
304
190
 
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
- ```
191
+ runs lint (Biome), a typecheck and the Vitest suite (jsdom + Testing Library). `npm run build`
192
+ compiles to `dist/`. Relative imports carry an explicit `.js` extension so the output works
193
+ under Node's ESM resolution as well as bundlers.
332
194
 
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
- Built by hand rather than on Radix Dialog because the non-modal half must not do what Dialog
347
- does to the rest of the page. Both examples are in `examples/Drawers.tsx`.
195
+ Publishing happens from the GitHub workflow on a release; `prepublishOnly` rebuilds `dist`.
196
+ Changes are listed in [CHANGELOG.md](CHANGELOG.md).
@@ -43,7 +43,7 @@ export interface DrawerProps {
43
43
  /** Caps the width on wider screens. Default `max-w-lg`; pass `max-w-none` for full width. */
44
44
  maxWidthClassName?: string;
45
45
  className?: string;
46
- /** Where the portal mounts. Default `document.body`. */
46
+ /** Where the portal mounts. Default: the nearest `<Theme>` (`.radix-themes`), else rendered in place. */
47
47
  container?: HTMLElement | null;
48
48
  }
49
49
  /**
@@ -59,4 +59,4 @@ export interface DrawerProps {
59
59
  * from inside the body only counts when the body is scrolled to its top and moving down, so the
60
60
  * content still scrolls normally.
61
61
  */
62
- export declare function Drawer({ mode, open: openProp, defaultOpen, onOpenChange, onClose, snap: snapProp, defaultSnap, onSnapChange, dismissible, title, description, peek, children, footer, showCloseButton, closeLabel, expandLabel, collapseLabel, maxWidthClassName, className, container, }: DrawerProps): import("react").ReactPortal | null;
62
+ export declare function Drawer({ mode, open: openProp, defaultOpen, onOpenChange, onClose, snap: snapProp, defaultSnap, onSnapChange, dismissible, title, description, peek, children, footer, showCloseButton, closeLabel, expandLabel, collapseLabel, maxWidthClassName, className, container, }: DrawerProps): import("react").JSX.Element;
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useId, useRef, useState, } from 'react';
3
- import { createPortal } from 'react-dom';
4
3
  import { useUiStrings } from '../../strings.js';
5
4
  import { cn } from '../../utils/cn.js';
5
+ import { ThemePortal, useThemePortalTarget } from '../../utils/themePortal.js';
6
6
  /** Pixels of travel that commit a drag regardless of speed. */
7
7
  const DISTANCE = 60;
8
8
  /** Pixels per millisecond that commit a flick regardless of distance. */
@@ -53,21 +53,28 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
53
53
  setOpen(false);
54
54
  onClose?.();
55
55
  }, [setOpen, onClose]);
56
+ // Portal into the nearest <Theme> so the sheet inherits its appearance, accent and tokens;
57
+ // a portal under <body> would sit outside `.radix-themes`, where none of them are defined.
58
+ // With no Theme above it the sheet renders in place rather than escaping to <body>.
59
+ const { anchorRef, target, resolved } = useThemePortalTarget(container);
56
60
  // Modal: everything else on the page goes inert, focus moves in, and comes back on close.
57
61
  useEffect(() => {
58
- if (!open || !modal)
62
+ // The wrapper only exists once the target is known, so wait for it.
63
+ if (!open || !modal || !resolved)
59
64
  return;
60
65
  const wrapper = wrapperRef.current;
61
- const root = wrapper?.parentElement;
62
66
  const previous = document.activeElement;
63
67
  const made = [];
64
- if (root) {
65
- for (const el of Array.from(root.children)) {
66
- if (el !== wrapper && !el.hasAttribute('inert')) {
68
+ // Every sibling on the way up to <body>: the app inside the Theme, and anything beside it.
69
+ for (let node = wrapper ?? null; node && node !== document.body;) {
70
+ const parent = node.parentElement;
71
+ for (const el of Array.from(parent?.children ?? [])) {
72
+ if (el !== node && !el.hasAttribute('inert')) {
67
73
  el.setAttribute('inert', '');
68
74
  made.push(el);
69
75
  }
70
76
  }
77
+ node = parent;
71
78
  }
72
79
  const first = sheetRef.current?.querySelector(FOCUSABLE);
73
80
  (first ?? sheetRef.current)?.focus();
@@ -76,7 +83,7 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
76
83
  el.removeAttribute('inert');
77
84
  previous?.focus?.();
78
85
  };
79
- }, [open, modal]);
86
+ }, [open, modal, resolved]);
80
87
  useEffect(() => {
81
88
  if (!open || !modal || !dismissible)
82
89
  return;
@@ -176,15 +183,15 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
176
183
  }
177
184
  setSnap(expanded ? 'collapsed' : 'expanded');
178
185
  };
179
- if (!open)
180
- return null;
181
- const target = container ?? (typeof document === 'undefined' ? null : document.body);
182
- if (!target)
183
- return null;
186
+ // A hidden marker stays in the React tree's DOM position, so the portal can find its Theme.
187
+ const anchor = _jsx("span", { ref: anchorRef, hidden: true, "data-drawer-anchor": "" });
188
+ if (!open || !resolved)
189
+ return anchor;
184
190
  const dragHandlers = {
185
191
  onPointerMove: move,
186
192
  onPointerUp: end,
187
193
  onPointerCancel: cancel,
188
194
  };
189
- return createPortal(_jsxs("div", { ref: wrapperRef, "data-drawer-root": "", children: [modal ? (_jsx("div", { "data-testid": "drawer-scrim", "aria-hidden": "true", className: "fixed inset-0 z-40 bg-scrim", onClick: dismissible ? close : undefined })) : null, _jsxs("div", { ref: sheetRef, role: modal ? 'dialog' : 'region', "aria-modal": modal ? true : undefined, "aria-labelledby": titleId, "aria-describedby": description ? descId : undefined, tabIndex: -1, "data-snap": snap, "data-mode": mode, onKeyDown: trapTab, style: { transform: `translateY(${Math.max(offset, -40)}px)` }, className: cn('fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col', 'rounded-t-2xl border border-b-0 border-hairline bg-sheet text-ink shadow-2xl', 'pb-[env(safe-area-inset-bottom)] outline-none', !dragging && 'transition-transform duration-200 ease-out motion-reduce:transition-none', maxWidthClassName, className), children: [_jsxs("div", { className: "touch-none select-none px-5 pt-2", onPointerDown: (e) => begin(e, false), ...dragHandlers, children: [_jsx("button", { type: "button", "aria-expanded": expanded, "aria-controls": bodyId, "aria-label": expanded ? (collapseLabel ?? strings.collapse) : (expandLabel ?? strings.expand), onClick: onHandleClick, className: "mx-auto flex h-6 w-16 cursor-grab items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-brand-700 active:cursor-grabbing", children: _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-10 rounded-full bg-hairline" }) }), _jsxs("div", { className: "flex items-start gap-3 pb-3 pt-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { id: titleId, className: "m-0 text-base font-medium text-brand-900", children: title }), description ? (_jsx("p", { id: descId, className: "m-0 mt-1 text-sm text-ink-muted", children: description })) : null] }), showCloseButton && dismissible ? (_jsx("button", { type: "button", "aria-label": closeLabel ?? strings.close, onClick: close, className: "-mr-1 flex size-8 shrink-0 items-center justify-center rounded-full text-ink-muted hover:bg-brand-700/8 focus-visible:outline-2 focus-visible:outline-brand-700", children: _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3.5 3.5l8 8m0-8l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }) })) : null] })] }), !expanded && peek ? _jsx("div", { className: "px-5 pb-4", children: peek }) : null, _jsx("div", { ref: bodyRef, id: bodyId, hidden: !expanded, className: "max-h-[80vh] overflow-y-auto overscroll-contain px-5 pb-4", onPointerDown: (e) => begin(e, true), ...dragHandlers, children: children }), footer ? (_jsx("div", { className: "flex justify-end gap-2 border-t border-hairline px-5 py-3", children: footer })) : null] })] }), target);
195
+ return (_jsxs(_Fragment, { children: [anchor, _jsx(ThemePortal, { target: target, children: _jsxs("div", { ref: wrapperRef, "data-drawer-root": "", children: [modal ? (_jsx("div", { "data-testid": "drawer-scrim", "aria-hidden": "true", className: "fixed inset-0 z-40 bg-scrim", onClick: dismissible ? close : undefined })) : null, _jsxs("div", { ref: sheetRef, role: modal ? 'dialog' : 'region', "aria-modal": modal ? true : undefined, "aria-labelledby": titleId, "aria-describedby": description ? descId : undefined, tabIndex: -1, "data-snap": snap, "data-mode": mode, onKeyDown: trapTab, style: { transform: `translateY(${Math.max(offset, -40)}px)` }, className: cn('fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col', 'rounded-t-2xl border border-b-0 border-hairline bg-sheet text-ink shadow-2xl', 'pb-[env(safe-area-inset-bottom)] outline-none', !dragging &&
196
+ 'transition-transform duration-200 ease-out motion-reduce:transition-none', maxWidthClassName, className), children: [_jsxs("div", { className: "touch-none select-none px-5 pt-2", onPointerDown: (e) => begin(e, false), ...dragHandlers, children: [_jsx("button", { type: "button", "aria-expanded": expanded, "aria-controls": bodyId, "aria-label": expanded ? (collapseLabel ?? strings.collapse) : (expandLabel ?? strings.expand), onClick: onHandleClick, className: "mx-auto flex h-6 w-16 cursor-grab items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-brand-700 active:cursor-grabbing", children: _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-10 rounded-full bg-hairline" }) }), _jsxs("div", { className: "flex items-start gap-3 pb-3 pt-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { id: titleId, className: "m-0 text-base font-medium text-brand-900", children: title }), description ? (_jsx("p", { id: descId, className: "m-0 mt-1 text-sm text-ink-muted", children: description })) : null] }), showCloseButton && dismissible ? (_jsx("button", { type: "button", "aria-label": closeLabel ?? strings.close, onClick: close, className: "-mr-1 flex size-8 shrink-0 items-center justify-center rounded-full text-ink-muted hover:bg-brand-700/8 focus-visible:outline-2 focus-visible:outline-brand-700", children: _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3.5 3.5l8 8m0-8l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }) })) : null] })] }), !expanded && peek ? _jsx("div", { className: "px-5 pb-4", children: peek }) : null, _jsx("div", { ref: bodyRef, id: bodyId, hidden: !expanded, className: "max-h-[80vh] overflow-y-auto overscroll-contain px-5 pb-4", onPointerDown: (e) => begin(e, true), ...dragHandlers, children: children }), footer ? (_jsx("div", { className: "flex justify-end gap-2 border-t border-hairline px-5 py-3", children: footer })) : null] })] }) })] }));
190
197
  }
@@ -0,0 +1,52 @@
1
+ import { type ReactNode } from 'react';
2
+ /** The viewport corner the card floats in. */
3
+ export type NotificationCardPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
4
+ /** Why the card closed. `programmatic` is a controlled `open` turned false by the host. */
5
+ export type NotificationCardDismissReason = 'close-button' | 'escape' | 'programmatic';
6
+ export interface NotificationCardProps {
7
+ /** Viewport corner. Default `bottom-right`. */
8
+ position?: NotificationCardPosition;
9
+ /** Controlled visibility. */
10
+ open?: boolean;
11
+ /** Initial visibility when uncontrolled. Default `true`. */
12
+ defaultOpen?: boolean;
13
+ /** Called whenever the card asks to open or close. */
14
+ onOpenChange?: (open: boolean) => void;
15
+ /** Called once per close, with what closed it. */
16
+ onDismiss?: (reason: NotificationCardDismissReason) => void;
17
+ /** Optional leading media: an icon, an app logo. */
18
+ icon?: ReactNode;
19
+ /** Labels the section via `aria-labelledby`. */
20
+ title: ReactNode;
21
+ /** Wired up via `aria-describedby`. */
22
+ description?: ReactNode;
23
+ /** Collapsible body. When present the card shows a toggle for it. */
24
+ children?: ReactNode;
25
+ /** Footer row: buttons, links. */
26
+ actions?: ReactNode;
27
+ /** Controlled expansion of the body. */
28
+ expanded?: boolean;
29
+ /** Initial expansion when uncontrolled. Default `false`. */
30
+ defaultExpanded?: boolean;
31
+ onExpandedChange?: (expanded: boolean) => void;
32
+ /** Toggle text while collapsed (otherwise the `expand` UI string). */
33
+ expandLabel?: string;
34
+ /** Toggle text while expanded (otherwise the `collapse` UI string). */
35
+ collapseLabel?: string;
36
+ /** Close button's accessible name (otherwise the `close` UI string). */
37
+ closeLabel?: string;
38
+ /** Portal target. Default: the nearest `<Theme>`, else rendered in place. */
39
+ container?: HTMLElement | null;
40
+ className?: string;
41
+ }
42
+ /**
43
+ * A persistent rich toast: floats in a viewport corner and stays until somebody closes it.
44
+ *
45
+ * Deliberately a labelled `section`, not `role="alert"` or a live region — it is the kind of
46
+ * card that is present on page load, and announcing it on every load would be noise. It is
47
+ * non-modal: no focus trap, and Escape closes it only while focus is inside it.
48
+ *
49
+ * It portals into the nearest `<Theme>` (wrapped in a nested `<Theme>`, which inherits the
50
+ * appearance) so dark mode reaches it; with no Theme above it, it renders in place.
51
+ */
52
+ export declare function NotificationCard({ position, open: openProp, defaultOpen, onOpenChange, onDismiss, icon, title, description, children, actions, expanded: expandedProp, defaultExpanded, onExpandedChange, expandLabel, collapseLabel, closeLabel, container, className, }: NotificationCardProps): import("react").JSX.Element;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useRef, useState, } from 'react';
3
+ import { useUiStrings } from '../../strings.js';
4
+ import { cn } from '../../utils/cn.js';
5
+ import { ThemePortal, useThemePortalTarget } from '../../utils/themePortal.js';
6
+ /** Longest the exit animation may take before the card unmounts anyway. */
7
+ const EXIT_MS = 250;
8
+ function useControllable(value, initial, onChange) {
9
+ const [inner, setInner] = useState(initial);
10
+ const controlled = value !== undefined;
11
+ const current = controlled ? value : inner;
12
+ const set = useCallback((next) => {
13
+ if (!controlled)
14
+ setInner(next);
15
+ onChange?.(next);
16
+ }, [controlled, onChange]);
17
+ return [current, set];
18
+ }
19
+ /**
20
+ * A persistent rich toast: floats in a viewport corner and stays until somebody closes it.
21
+ *
22
+ * Deliberately a labelled `section`, not `role="alert"` or a live region — it is the kind of
23
+ * card that is present on page load, and announcing it on every load would be noise. It is
24
+ * non-modal: no focus trap, and Escape closes it only while focus is inside it.
25
+ *
26
+ * It portals into the nearest `<Theme>` (wrapped in a nested `<Theme>`, which inherits the
27
+ * appearance) so dark mode reaches it; with no Theme above it, it renders in place.
28
+ */
29
+ export function NotificationCard({ position = 'bottom-right', open: openProp, defaultOpen = true, onOpenChange, onDismiss, icon, title, description, children, actions, expanded: expandedProp, defaultExpanded = false, onExpandedChange, expandLabel, collapseLabel, closeLabel, container, className, }) {
30
+ const strings = useUiStrings();
31
+ const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
32
+ const [expanded, setExpanded] = useControllable(expandedProp, defaultExpanded, onExpandedChange);
33
+ const { anchorRef, target, resolved } = useThemePortalTarget(container);
34
+ const id = useId();
35
+ const titleId = `${id}-title`;
36
+ const descId = `${id}-desc`;
37
+ const bodyId = `${id}-body`;
38
+ // Mounted while open and through the exit animation.
39
+ const [mounted, setMounted] = useState(open);
40
+ // What the card itself asked to close for; a close it did not ask for is the host's.
41
+ const reason = useRef(null);
42
+ const wasOpen = useRef(open);
43
+ const onDismissRef = useRef(onDismiss);
44
+ onDismissRef.current = onDismiss;
45
+ useEffect(() => {
46
+ const was = wasOpen.current;
47
+ wasOpen.current = open;
48
+ if (open) {
49
+ setMounted(true);
50
+ return;
51
+ }
52
+ if (!was)
53
+ return;
54
+ onDismissRef.current?.(reason.current ?? 'programmatic');
55
+ reason.current = null;
56
+ const t = setTimeout(() => setMounted(false), EXIT_MS);
57
+ return () => clearTimeout(t);
58
+ }, [open]);
59
+ const close = (why) => {
60
+ if (!open)
61
+ return;
62
+ reason.current = why;
63
+ setOpen(false);
64
+ };
65
+ const onKeyDown = (e) => {
66
+ if (e.key !== 'Escape')
67
+ return;
68
+ e.stopPropagation();
69
+ close('escape');
70
+ };
71
+ const anchor = _jsx("span", { ref: anchorRef, hidden: true, "data-notification-anchor": "" });
72
+ if (!mounted || !resolved)
73
+ return anchor;
74
+ return (_jsxs(_Fragment, { children: [anchor, _jsx(ThemePortal, { target: target, children: _jsxs("section", { "aria-labelledby": titleId, "aria-describedby": description ? descId : undefined, "data-state": open ? 'open' : 'closed', "data-position": position, onKeyDown: onKeyDown, onAnimationEnd: () => {
75
+ if (!open)
76
+ setMounted(false);
77
+ }, className: cn('ui-notification-card', className), children: [_jsxs("div", { className: "ui-notification-card__header", children: [icon ? _jsx("div", { className: "ui-notification-card__icon", children: icon }) : null, _jsxs("div", { className: "ui-notification-card__text", children: [_jsx("h2", { id: titleId, className: "ui-notification-card__title", children: title }), description ? (_jsx("p", { id: descId, className: "ui-notification-card__description", children: description })) : null] }), _jsx("button", { type: "button", "aria-label": closeLabel ?? strings.close, onClick: () => close('close-button'), className: "ui-notification-card__close", children: _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3.5 3.5l8 8m0-8l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }) })] }), children ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", "aria-expanded": expanded, "aria-controls": bodyId, onClick: () => setExpanded(!expanded), className: "ui-notification-card__toggle", children: [expanded ? (collapseLabel ?? strings.collapse) : (expandLabel ?? strings.expand), _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: expanded ? 'M4 9.5l3.5-3.5 3.5 3.5' : 'M4 6l3.5 3.5L11 6', stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), _jsx("div", { id: bodyId, hidden: !expanded, className: "ui-notification-card__body", children: children })] })) : null, actions ? _jsx("div", { className: "ui-notification-card__actions", children: actions }) : null] }) })] }));
78
+ }
@@ -0,0 +1 @@
1
+ export * from './NotificationCard.js';
@@ -0,0 +1 @@
1
+ export * from './NotificationCard.js';
@@ -10,6 +10,7 @@ export * from './Drawer/index.js';
10
10
  export * from './Icon/index.js';
11
11
  export * from './Input/index.js';
12
12
  export * from './Label/index.js';
13
+ export * from './NotificationCard/index.js';
13
14
  export * from './Popover/index.js';
14
15
  export * from './Progress/index.js';
15
16
  export * from './RichText/index.js';
@@ -10,6 +10,7 @@ export * from './Drawer/index.js';
10
10
  export * from './Icon/index.js';
11
11
  export * from './Input/index.js';
12
12
  export * from './Label/index.js';
13
+ export * from './NotificationCard/index.js';
13
14
  export * from './Popover/index.js';
14
15
  export * from './Progress/index.js';
15
16
  export * from './RichText/index.js';
package/dist/strings.d.ts CHANGED
@@ -28,11 +28,11 @@ export interface UiStrings {
28
28
  heading: string;
29
29
  bulletList: string;
30
30
  orderedList: string;
31
- /** Drawer: the close button's accessible name. */
31
+ /** Drawer and NotificationCard: the close button's accessible name. */
32
32
  close: string;
33
- /** Drawer: the handle's accessible name while collapsed. */
33
+ /** Drawer handle / NotificationCard toggle, while collapsed. */
34
34
  expand: string;
35
- /** Drawer: the handle's accessible name while expanded. */
35
+ /** Drawer handle / NotificationCard toggle, while expanded. */
36
36
  collapse: string;
37
37
  }
38
38
  /**
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Finds where a floating component may portal without leaving its theme.
4
+ *
5
+ * Radix Themes defines every colour variable on `.radix-themes` and flips light/dark with a
6
+ * class on that element, so a portal under `<body>` renders light whatever the app is in. The
7
+ * target is `container` when given, else the nearest `.radix-themes` above `anchorRef`, else
8
+ * nothing — meaning render in place.
9
+ */
10
+ export declare function useThemePortalTarget(container?: HTMLElement | null): {
11
+ anchorRef: import("react").RefObject<HTMLSpanElement | null>;
12
+ target: HTMLElement | null;
13
+ resolved: boolean;
14
+ };
15
+ /** Portals into `target` wrapped in a nested `<Theme>` (which inherits appearance and accent), or renders in place. */
16
+ export declare function ThemePortal({ target, children, }: {
17
+ target: HTMLElement | null;
18
+ children: ReactNode;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Theme } from '@radix-ui/themes';
3
+ import { useLayoutEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ /**
6
+ * Finds where a floating component may portal without leaving its theme.
7
+ *
8
+ * Radix Themes defines every colour variable on `.radix-themes` and flips light/dark with a
9
+ * class on that element, so a portal under `<body>` renders light whatever the app is in. The
10
+ * target is `container` when given, else the nearest `.radix-themes` above `anchorRef`, else
11
+ * nothing — meaning render in place.
12
+ */
13
+ export function useThemePortalTarget(container) {
14
+ const anchorRef = useRef(null);
15
+ const [themeRoot, setThemeRoot] = useState(null);
16
+ const [resolved, setResolved] = useState(false);
17
+ useLayoutEffect(() => {
18
+ setThemeRoot(anchorRef.current?.closest('.radix-themes') ?? null);
19
+ setResolved(true);
20
+ }, []);
21
+ return { anchorRef, target: container ?? themeRoot, resolved: resolved || !!container };
22
+ }
23
+ /** Portals into `target` wrapped in a nested `<Theme>` (which inherits appearance and accent), or renders in place. */
24
+ export function ThemePortal({ target, children, }) {
25
+ if (!target)
26
+ return _jsx(_Fragment, { children: children });
27
+ return createPortal(_jsx(Theme, { hasBackground: false, children: children }), target);
28
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.9.0",
4
- "description": "Upfront's house component layer: atoms over Radix Themes, the token bridge, and the accent logic.",
3
+ "version": "0.10.0",
4
+ "description": "A React + TypeScript component library built on Radix Themes: atoms, a Tailwind token bridge and theming.",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "license": "UNLICENSED",
package/tokens.css CHANGED
@@ -455,3 +455,64 @@
455
455
  pointer-events: none;
456
456
  color: var(--gray-a10);
457
457
  }
458
+
459
+ /**
460
+ * NotificationCard — a persistent rich toast. Only Radix Themes variables, so it follows the
461
+ * `.light`/`.dark` class on the nearest <Theme> rather than the OS. z-index sits below Radix
462
+ * Themes dialogs and the Drawer (50) and above page content.
463
+ */
464
+ .ui-notification-card {
465
+ position: fixed;
466
+ z-index: 40;
467
+ width: min(24rem, 100vw - 2rem);
468
+ max-height: calc(100vh - 2rem);
469
+ overflow-y: auto;
470
+ box-sizing: border-box;
471
+ display: flex;
472
+ flex-direction: column;
473
+ gap: var(--space-3);
474
+ padding: var(--space-4);
475
+ background: var(--color-panel-solid);
476
+ color: var(--gray-12);
477
+ border: 1px solid var(--gray-a6);
478
+ border-radius: var(--radius-4);
479
+ box-shadow: var(--shadow-5);
480
+ font-family: var(--default-font-family);
481
+ --ui-nc-from: translateY(calc(100% + 16px));
482
+ }
483
+ .ui-notification-card[data-position='bottom-right'] { bottom: 16px; right: 16px; }
484
+ .ui-notification-card[data-position='bottom-left'] { bottom: 16px; left: 16px; }
485
+ .ui-notification-card[data-position='top-right'] { top: 16px; right: 16px; --ui-nc-from: translateY(calc(-100% - 16px)); }
486
+ .ui-notification-card[data-position='top-left'] { top: 16px; left: 16px; --ui-nc-from: translateY(calc(-100% - 16px)); }
487
+ .ui-notification-card[data-state='open'] { animation: ui-nc-in 220ms ease-out; }
488
+ .ui-notification-card[data-state='closed'] { animation: ui-nc-out 160ms ease-in forwards; }
489
+ @keyframes ui-nc-in { from { transform: var(--ui-nc-from); opacity: 0; } }
490
+ @keyframes ui-nc-out { to { transform: var(--ui-nc-from); opacity: 0; } }
491
+ @media (prefers-reduced-motion: reduce) {
492
+ .ui-notification-card[data-state] { animation-duration: 1ms; }
493
+ }
494
+ .ui-notification-card__header { display: flex; align-items: flex-start; gap: var(--space-3); }
495
+ .ui-notification-card__icon { flex-shrink: 0; display: flex; }
496
+ .ui-notification-card__text { flex: 1; min-width: 0; }
497
+ .ui-notification-card__title { margin: 0; font-size: var(--font-size-3); font-weight: var(--font-weight-medium); line-height: var(--line-height-3); }
498
+ .ui-notification-card__description { margin: var(--space-1) 0 0; font-size: var(--font-size-2); line-height: var(--line-height-2); color: var(--gray-11); }
499
+ .ui-notification-card__close,
500
+ .ui-notification-card__toggle {
501
+ appearance: none; border: 0; background: transparent; color: var(--gray-11);
502
+ cursor: pointer; font: inherit; border-radius: var(--radius-2);
503
+ }
504
+ .ui-notification-card__close {
505
+ flex-shrink: 0; display: flex; align-items: center; justify-content: center;
506
+ width: var(--space-6); height: var(--space-6); margin: calc(var(--space-1) * -1);
507
+ }
508
+ .ui-notification-card__toggle {
509
+ align-self: flex-start; display: inline-flex; align-items: center; gap: var(--space-1);
510
+ padding: var(--space-1) var(--space-2); margin-left: calc(var(--space-2) * -1);
511
+ font-size: var(--font-size-2); color: var(--accent-11);
512
+ }
513
+ .ui-notification-card__close:hover,
514
+ .ui-notification-card__toggle:hover { background: var(--gray-a3); }
515
+ .ui-notification-card__close:focus-visible,
516
+ .ui-notification-card__toggle:focus-visible { outline: 2px solid var(--focus-8); outline-offset: 1px; }
517
+ .ui-notification-card__body { font-size: var(--font-size-2); color: var(--gray-12); }
518
+ .ui-notification-card__actions { display: flex; justify-content: flex-end; gap: var(--space-2); }