@kennycorrea/ui 0.9.1 → 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 +12 -0
- package/README.md +136 -290
- package/dist/atoms/Drawer/Drawer.d.ts +1 -1
- package/dist/atoms/Drawer/Drawer.js +9 -13
- package/dist/atoms/NotificationCard/NotificationCard.d.ts +52 -0
- package/dist/atoms/NotificationCard/NotificationCard.js +78 -0
- package/dist/atoms/NotificationCard/index.d.ts +1 -0
- package/dist/atoms/NotificationCard/index.js +1 -0
- package/dist/atoms/index.d.ts +1 -0
- package/dist/atoms/index.js +1 -0
- package/dist/strings.d.ts +3 -3
- package/dist/utils/themePortal.d.ts +19 -0
- package/dist/utils/themePortal.js +28 -0
- package/package.json +2 -2
- package/tokens.css +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.9.1 — 2026-10-18
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,350 +1,196 @@
|
|
|
1
1
|
# @kennycorrea/ui
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
### Dark mode and portals
|
|
155
70
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
resolving ES modules imported from …/@kennycorrea/ui/dist/index.js
|
|
85
|
+
## Strings
|
|
170
86
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
Mount `<Toaster />` once inside `<Theme>` and call `toast()` from anywhere.
|
|
185
96
|
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
### Drawer
|
|
265
154
|
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
174
|
+
### Slider
|
|
285
175
|
|
|
286
|
-
|
|
287
|
-
|
|
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="
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
## Slider fill origin and marks — 0.5.0
|
|
185
|
+
## Development
|
|
298
186
|
|
|
299
|
-
```
|
|
300
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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`.
|
|
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: the nearest `<Theme>` (`.radix-themes`), else
|
|
46
|
+
/** Where the portal mounts. Default: the nearest `<Theme>` (`.radix-themes`), else rendered in place. */
|
|
47
47
|
container?: HTMLElement | null;
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useId,
|
|
3
|
-
import { createPortal } from 'react-dom';
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState, } from 'react';
|
|
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. */
|
|
@@ -55,16 +55,12 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
|
|
|
55
55
|
}, [setOpen, onClose]);
|
|
56
56
|
// Portal into the nearest <Theme> so the sheet inherits its appearance, accent and tokens;
|
|
57
57
|
// a portal under <body> would sit outside `.radix-themes`, where none of them are defined.
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
useLayoutEffect(() => {
|
|
61
|
-
setThemeRoot(anchorRef.current?.closest('.radix-themes') ?? document.body);
|
|
62
|
-
}, []);
|
|
63
|
-
const target = container ?? themeRoot;
|
|
58
|
+
// With no Theme above it the sheet renders in place rather than escaping to <body>.
|
|
59
|
+
const { anchorRef, target, resolved } = useThemePortalTarget(container);
|
|
64
60
|
// Modal: everything else on the page goes inert, focus moves in, and comes back on close.
|
|
65
61
|
useEffect(() => {
|
|
66
62
|
// The wrapper only exists once the target is known, so wait for it.
|
|
67
|
-
if (!open || !modal || !
|
|
63
|
+
if (!open || !modal || !resolved)
|
|
68
64
|
return;
|
|
69
65
|
const wrapper = wrapperRef.current;
|
|
70
66
|
const previous = document.activeElement;
|
|
@@ -87,7 +83,7 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
|
|
|
87
83
|
el.removeAttribute('inert');
|
|
88
84
|
previous?.focus?.();
|
|
89
85
|
};
|
|
90
|
-
}, [open, modal,
|
|
86
|
+
}, [open, modal, resolved]);
|
|
91
87
|
useEffect(() => {
|
|
92
88
|
if (!open || !modal || !dismissible)
|
|
93
89
|
return;
|
|
@@ -189,13 +185,13 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
|
|
|
189
185
|
};
|
|
190
186
|
// A hidden marker stays in the React tree's DOM position, so the portal can find its Theme.
|
|
191
187
|
const anchor = _jsx("span", { ref: anchorRef, hidden: true, "data-drawer-anchor": "" });
|
|
192
|
-
if (!open || !
|
|
188
|
+
if (!open || !resolved)
|
|
193
189
|
return anchor;
|
|
194
190
|
const dragHandlers = {
|
|
195
191
|
onPointerMove: move,
|
|
196
192
|
onPointerUp: end,
|
|
197
193
|
onPointerCancel: cancel,
|
|
198
194
|
};
|
|
199
|
-
return (_jsxs(_Fragment, { children: [anchor,
|
|
200
|
-
|
|
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] })] }) })] }));
|
|
201
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';
|
package/dist/atoms/index.d.ts
CHANGED
|
@@ -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/atoms/index.js
CHANGED
|
@@ -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
|
|
33
|
+
/** Drawer handle / NotificationCard toggle, while collapsed. */
|
|
34
34
|
expand: string;
|
|
35
|
-
/** Drawer
|
|
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.
|
|
4
|
-
"description": "
|
|
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); }
|