@kennycorrea/ui 0.8.0 → 0.9.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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-10-18
4
+
5
+ ### Added
6
+ - **`Drawer`**, a bottom sheet with `collapsed` and `expanded` snap points, in two modes:
7
+ `modal` (scrim, focus trapped, page `inert`, Escape and scrim close) and `non-modal` (a
8
+ floating banner; the page stays usable). Drag the header with touch or mouse to change snap,
9
+ or past collapsed to close when `dismissible`. The handle is a real button with
10
+ `aria-expanded`/`aria-controls`. Controlled or uncontrolled `open` and `snap`.
11
+ - **`UiStrings`: `close`, `expand`, `collapse`**, used by the Drawer's close button and handle.
12
+ English defaults; the `closeLabel`, `expandLabel` and `collapseLabel` props override them.
13
+ - `examples/Drawers.tsx`: a non-modal promo banner and a modal form sheet.
14
+
15
+ ### Not verified
16
+ - Only exercised in jsdom with simulated pointer events, not on a real touch device.
17
+
18
+ ## 0.8.1 — 2026-10-11
19
+
20
+ ### Fixed
21
+ - **`Badge` text passes WCAG AA in both appearances.** Soft badges wrote their text in step a11
22
+ on an a3 wash, which fails 4.5:1 in light for `success` (jade, 4.27:1), `warning` (amber,
23
+ 4.25:1), blue and several accents a business can pick for `brand`. Every variant is now
24
+ `highContrast` (step 12), which clears 9.8:1 on every Radix scale in light and dark. `Toast`
25
+ already passed (lowest: amber action text at 4.61:1) and is unchanged.
26
+
27
+ ### Added
28
+ - **`test/contrast.test.tsx`**: every `Badge` variant (`brand` against every Radix accent) and
29
+ every `Toast` type, light and dark, computed from Radix's own token files. It fails if
30
+ `highContrast` is dropped. Not axe: jsdom applies no stylesheet, so axe's colour-contrast rule
31
+ can only report "incomplete" there; this checks the same pairs it would check in a browser.
32
+ - **A dev-only warning when a needed colour scale is missing.** The first `Badge` or `Toaster`
33
+ to mount checks for `--{gray,jade,amber,red,blue}-9` and names the missing
34
+ `@radix-ui/themes/tokens/colors/*.css` imports.
35
+
36
+ ### Docs
37
+ - README: the colour scales the atoms need, and their order relative to `base.css`.
38
+
3
39
  ## 0.8.0 — 2026-10-09
4
40
 
5
41
  ### Added
package/README.md CHANGED
@@ -63,6 +63,29 @@ And in the app's own `index.css`:
63
63
  every utility class used *inside* the atoms — `w-full`, `inline-flex` — is missing from the
64
64
  generated CSS. The components come out subtly unstyled and nothing appears in the console.
65
65
 
66
+ ### Colour scales — required
67
+
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:
72
+
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';
83
+ ```
84
+
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.
88
+
66
89
  ## Theming — 0.2.0
67
90
 
68
91
  **A product configures its own accents, default and storage namespace.** Until 0.2.0 all three
@@ -287,3 +310,38 @@ is in `examples/BudgetCycleSlider.tsx`.
287
310
 
288
311
  ⚠ `--ui-slider-thumb-size` mirrors Themes' size-2 thumb (`--space-2 + --space-1`). The fill
289
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
+ ```
332
+
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`.
@@ -1,7 +1,14 @@
1
1
  import { Badge as ThemedBadge } from '@radix-ui/themes';
2
- import type { ComponentPropsWithoutRef } from 'react';
2
+ import type { ComponentPropsWithoutRef, Ref } from 'react';
3
3
  export type BadgeVariant = 'neutral' | 'brand' | 'success' | 'warning' | 'danger';
4
4
  export interface BadgeProps extends Omit<ComponentPropsWithoutRef<typeof ThemedBadge>, 'color' | 'variant'> {
5
5
  variant?: BadgeVariant;
6
+ ref?: Ref<HTMLSpanElement>;
6
7
  }
7
- export declare function Badge({ variant, ...props }: BadgeProps): import("react").JSX.Element;
8
+ /**
9
+ * **Always `highContrast`**, which writes the text in step 12 instead of a11. Soft badges are
10
+ * 12px text on an a3 wash, and a11 there fails WCAG AA in light for jade (4.27:1), amber
11
+ * (4.25:1), blue and several accents a business can pick for `brand`. Step 12 clears 9.8:1
12
+ * on every scale in both appearances; `test/contrast.test.tsx` holds it there.
13
+ */
14
+ export declare function Badge({ variant, ref, ...props }: BadgeProps): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Badge as ThemedBadge } from '@radix-ui/themes';
3
+ import { warnMissingScales } from '../../utils/scales.js';
3
4
  /**
4
5
  * The five meanings this library names, in Themes' colour vocabulary.
5
6
  *
@@ -18,6 +19,18 @@ const COLORS = {
18
19
  warning: 'amber',
19
20
  danger: 'red',
20
21
  };
21
- export function Badge({ variant = 'neutral', ...props }) {
22
- return _jsx(ThemedBadge, { color: COLORS[variant], variant: "soft", radius: "full", ...props });
22
+ /**
23
+ * **Always `highContrast`**, which writes the text in step 12 instead of a11. Soft badges are
24
+ * 12px text on an a3 wash, and a11 there fails WCAG AA in light for jade (4.27:1), amber
25
+ * (4.25:1), blue and several accents a business can pick for `brand`. Step 12 clears 9.8:1
26
+ * on every scale in both appearances; `test/contrast.test.tsx` holds it there.
27
+ */
28
+ export function Badge({ variant = 'neutral', ref, ...props }) {
29
+ return (_jsx(ThemedBadge, { ref: (el) => {
30
+ warnMissingScales(el);
31
+ if (typeof ref === 'function')
32
+ ref(el);
33
+ else if (ref)
34
+ ref.current = el;
35
+ }, color: COLORS[variant], variant: "soft", radius: "full", highContrast: true, ...props }));
23
36
  }
@@ -0,0 +1,62 @@
1
+ import { type ReactNode } from 'react';
2
+ /** The two resting heights of a drawer. */
3
+ export type DrawerSnap = 'collapsed' | 'expanded';
4
+ /** `modal` traps focus behind a scrim; `non-modal` floats over a page that stays usable. */
5
+ export type DrawerMode = 'modal' | 'non-modal';
6
+ export interface DrawerProps {
7
+ /** `modal` (scrim, focus trap, inert page) or `non-modal` (floating banner). Default `modal`. */
8
+ mode?: DrawerMode;
9
+ /** Controlled visibility. Leave undefined to let the drawer own it. */
10
+ open?: boolean;
11
+ /** Initial visibility when uncontrolled. Default `true`. */
12
+ defaultOpen?: boolean;
13
+ /** Called whenever the drawer asks to open or close. */
14
+ onOpenChange?: (open: boolean) => void;
15
+ /** Called once when the drawer closes, after `onOpenChange(false)`. */
16
+ onClose?: () => void;
17
+ /** Controlled snap point. */
18
+ snap?: DrawerSnap;
19
+ /** Initial snap point when uncontrolled. Default `collapsed`. */
20
+ defaultSnap?: DrawerSnap;
21
+ /** Called whenever the drawer asks to change snap point. */
22
+ onSnapChange?: (snap: DrawerSnap) => void;
23
+ /** Whether dragging past collapsed (or the close button, Escape, the scrim) may close it. Default `true`. */
24
+ dismissible?: boolean;
25
+ /** Labels the region via `aria-labelledby`. */
26
+ title: ReactNode;
27
+ /** Optional, wired up via `aria-describedby`. */
28
+ description?: ReactNode;
29
+ /** Shown while collapsed, below the header. */
30
+ peek?: ReactNode;
31
+ /** The body, shown when expanded; scrolls inside the sheet. */
32
+ children?: ReactNode;
33
+ /** Action row pinned below the body. Shown in both snaps. */
34
+ footer?: ReactNode;
35
+ /** Render a close button in the header. Default `false`. */
36
+ showCloseButton?: boolean;
37
+ /** Overrides the close button's accessible name (otherwise the `close` UI string). */
38
+ closeLabel?: string;
39
+ /** Overrides the handle's accessible name while collapsed (otherwise the `expand` UI string). */
40
+ expandLabel?: string;
41
+ /** Overrides the handle's accessible name while expanded (otherwise the `collapse` UI string). */
42
+ collapseLabel?: string;
43
+ /** Caps the width on wider screens. Default `max-w-lg`; pass `max-w-none` for full width. */
44
+ maxWidthClassName?: string;
45
+ className?: string;
46
+ /** Where the portal mounts. Default `document.body`. */
47
+ container?: HTMLElement | null;
48
+ }
49
+ /**
50
+ * A bottom sheet with two snap points and a drag handle.
51
+ *
52
+ * Built by hand rather than on Radix Dialog because half of it is non-modal: Dialog's focus
53
+ * trap and `aria-hidden` on the rest of the page are exactly what a floating banner must not do.
54
+ * In `modal` mode the same guarantees are provided here — the page's other top-level nodes are
55
+ * made `inert` and Tab cycles inside the sheet.
56
+ *
57
+ * Dragging follows the pointer with a transform and, on release, settles on the snap point the
58
+ * gesture points at: past {@link DISTANCE} pixels or faster than {@link VELOCITY} px/ms. A drag
59
+ * from inside the body only counts when the body is scrolled to its top and moving down, so the
60
+ * content still scrolls normally.
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;
@@ -0,0 +1,190 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useRef, useState, } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { useUiStrings } from '../../strings.js';
5
+ import { cn } from '../../utils/cn.js';
6
+ /** Pixels of travel that commit a drag regardless of speed. */
7
+ const DISTANCE = 60;
8
+ /** Pixels per millisecond that commit a flick regardless of distance. */
9
+ const VELOCITY = 0.5;
10
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
11
+ function useControllable(value, initial, onChange) {
12
+ const [inner, setInner] = useState(initial);
13
+ const controlled = value !== undefined;
14
+ const current = controlled ? value : inner;
15
+ const set = useCallback((next) => {
16
+ if (!controlled)
17
+ setInner(next);
18
+ onChange?.(next);
19
+ }, [controlled, onChange]);
20
+ return [current, set];
21
+ }
22
+ /**
23
+ * A bottom sheet with two snap points and a drag handle.
24
+ *
25
+ * Built by hand rather than on Radix Dialog because half of it is non-modal: Dialog's focus
26
+ * trap and `aria-hidden` on the rest of the page are exactly what a floating banner must not do.
27
+ * In `modal` mode the same guarantees are provided here — the page's other top-level nodes are
28
+ * made `inert` and Tab cycles inside the sheet.
29
+ *
30
+ * Dragging follows the pointer with a transform and, on release, settles on the snap point the
31
+ * gesture points at: past {@link DISTANCE} pixels or faster than {@link VELOCITY} px/ms. A drag
32
+ * from inside the body only counts when the body is scrolled to its top and moving down, so the
33
+ * content still scrolls normally.
34
+ */
35
+ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onOpenChange, onClose, snap: snapProp, defaultSnap = 'collapsed', onSnapChange, dismissible = true, title, description, peek, children, footer, showCloseButton = false, closeLabel, expandLabel, collapseLabel, maxWidthClassName = 'max-w-lg', className, container, }) {
36
+ const strings = useUiStrings();
37
+ const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
38
+ const [snap, setSnap] = useControllable(snapProp, defaultSnap, onSnapChange);
39
+ const [offset, setOffset] = useState(0);
40
+ const [dragging, setDragging] = useState(false);
41
+ const drag = useRef(null);
42
+ const suppressClick = useRef(false);
43
+ const sheetRef = useRef(null);
44
+ const bodyRef = useRef(null);
45
+ const wrapperRef = useRef(null);
46
+ const id = useId();
47
+ const titleId = `${id}-title`;
48
+ const descId = `${id}-desc`;
49
+ const bodyId = `${id}-body`;
50
+ const modal = mode === 'modal';
51
+ const expanded = snap === 'expanded';
52
+ const close = useCallback(() => {
53
+ setOpen(false);
54
+ onClose?.();
55
+ }, [setOpen, onClose]);
56
+ // Modal: everything else on the page goes inert, focus moves in, and comes back on close.
57
+ useEffect(() => {
58
+ if (!open || !modal)
59
+ return;
60
+ const wrapper = wrapperRef.current;
61
+ const root = wrapper?.parentElement;
62
+ const previous = document.activeElement;
63
+ const made = [];
64
+ if (root) {
65
+ for (const el of Array.from(root.children)) {
66
+ if (el !== wrapper && !el.hasAttribute('inert')) {
67
+ el.setAttribute('inert', '');
68
+ made.push(el);
69
+ }
70
+ }
71
+ }
72
+ const first = sheetRef.current?.querySelector(FOCUSABLE);
73
+ (first ?? sheetRef.current)?.focus();
74
+ return () => {
75
+ for (const el of made)
76
+ el.removeAttribute('inert');
77
+ previous?.focus?.();
78
+ };
79
+ }, [open, modal]);
80
+ useEffect(() => {
81
+ if (!open || !modal || !dismissible)
82
+ return;
83
+ const onKey = (e) => {
84
+ if (e.key === 'Escape')
85
+ close();
86
+ };
87
+ document.addEventListener('keydown', onKey);
88
+ return () => document.removeEventListener('keydown', onKey);
89
+ }, [open, modal, dismissible, close]);
90
+ const trapTab = (e) => {
91
+ if (!modal || e.key !== 'Tab' || !sheetRef.current)
92
+ return;
93
+ const items = Array.from(sheetRef.current.querySelectorAll(FOCUSABLE));
94
+ if (items.length === 0)
95
+ return;
96
+ const first = items[0];
97
+ const last = items[items.length - 1];
98
+ if (e.shiftKey && document.activeElement === first) {
99
+ e.preventDefault();
100
+ last.focus();
101
+ }
102
+ else if (!e.shiftKey && document.activeElement === last) {
103
+ e.preventDefault();
104
+ first.focus();
105
+ }
106
+ };
107
+ const begin = (e, fromBody) => {
108
+ if (e.button !== 0)
109
+ return;
110
+ if (fromBody && (!expanded || (bodyRef.current?.scrollTop ?? 0) > 0))
111
+ return;
112
+ drag.current = {
113
+ pointerId: e.pointerId,
114
+ startY: e.clientY,
115
+ lastY: e.clientY,
116
+ lastT: e.timeStamp,
117
+ velocity: 0,
118
+ moved: false,
119
+ fromBody,
120
+ };
121
+ };
122
+ const move = (e) => {
123
+ const d = drag.current;
124
+ if (!d || d.pointerId !== e.pointerId)
125
+ return;
126
+ const dy = e.clientY - d.startY;
127
+ if (!d.moved) {
128
+ if (Math.abs(dy) < 4)
129
+ return;
130
+ // Scrolling up inside the body is the content's business, not ours.
131
+ if (d.fromBody && dy < 0) {
132
+ drag.current = null;
133
+ return;
134
+ }
135
+ d.moved = true;
136
+ setDragging(true);
137
+ e.currentTarget.setPointerCapture?.(e.pointerId);
138
+ }
139
+ // At least one frame, so two events in the same tick do not read as an infinite flick.
140
+ const dt = Math.max(16, e.timeStamp - d.lastT);
141
+ d.velocity = (e.clientY - d.lastY) / dt;
142
+ d.lastY = e.clientY;
143
+ d.lastT = e.timeStamp;
144
+ // Past the top snap the sheet resists rather than detaching.
145
+ setOffset(dy < 0 && expanded ? dy / 4 : dy);
146
+ };
147
+ const end = (e) => {
148
+ const d = drag.current;
149
+ if (!d || d.pointerId !== e.pointerId)
150
+ return;
151
+ drag.current = null;
152
+ if (!d.moved)
153
+ return;
154
+ suppressClick.current = true;
155
+ const dy = e.clientY - d.startY;
156
+ const up = dy < -DISTANCE || d.velocity < -VELOCITY;
157
+ const down = dy > DISTANCE || d.velocity > VELOCITY;
158
+ setDragging(false);
159
+ setOffset(0);
160
+ if (up && !expanded)
161
+ setSnap('expanded');
162
+ else if (down && expanded)
163
+ setSnap('collapsed');
164
+ else if (down && dismissible)
165
+ close();
166
+ };
167
+ const cancel = () => {
168
+ drag.current = null;
169
+ setDragging(false);
170
+ setOffset(0);
171
+ };
172
+ const onHandleClick = () => {
173
+ if (suppressClick.current) {
174
+ suppressClick.current = false;
175
+ return;
176
+ }
177
+ setSnap(expanded ? 'collapsed' : 'expanded');
178
+ };
179
+ if (!open)
180
+ return null;
181
+ const target = container ?? (typeof document === 'undefined' ? null : document.body);
182
+ if (!target)
183
+ return null;
184
+ const dragHandlers = {
185
+ onPointerMove: move,
186
+ onPointerUp: end,
187
+ onPointerCancel: cancel,
188
+ };
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);
190
+ }
@@ -0,0 +1 @@
1
+ export * from './Drawer.js';
@@ -0,0 +1 @@
1
+ export * from './Drawer.js';
@@ -3,6 +3,7 @@ import * as RadixToast from '@radix-ui/react-toast';
3
3
  import { useSyncExternalStore } from 'react';
4
4
  import { useUiStrings } from '../../strings.js';
5
5
  import { cn } from '../../utils/cn.js';
6
+ import { warnMissingScales } from '../../utils/scales.js';
6
7
  import { Icon } from '../Icon/Icon.js';
7
8
  /**
8
9
  * A module-level store, so `toast()` can be called from anywhere — an event handler, a
@@ -92,5 +93,5 @@ export function Toaster({ duration = 5000, position = 'bottom-right', label }) {
92
93
  if (item.action?.keepOpen)
93
94
  event.preventDefault();
94
95
  }, className: cn('shrink-0 rounded-md px-2 py-1 text-sm font-medium', 'text-[var(--accent-11)] hover:bg-[var(--accent-a3)]'), children: item.action.label })) : null, _jsx(RadixToast.Close, { "aria-label": strings.dismiss, className: "shrink-0 rounded-md p-1 text-ink-muted hover:bg-[var(--gray-a3)]", children: _jsx(Icon, { name: "close", className: "size-4" }) })] }, item.id));
95
- }), _jsx(RadixToast.Viewport, { className: cn('fixed z-50 m-0 flex w-full max-w-sm list-none flex-col gap-2 p-4 outline-none', POSITIONS[position]) })] }));
96
+ }), _jsx(RadixToast.Viewport, { ref: warnMissingScales, className: cn('fixed z-50 m-0 flex w-full max-w-sm list-none flex-col gap-2 p-4 outline-none', POSITIONS[position]) })] }));
96
97
  }
@@ -6,6 +6,7 @@ export * from './Card/index.js';
6
6
  export * from './Checkbox/index.js';
7
7
  export * from './ContextMenu/index.js';
8
8
  export * from './Dialog/index.js';
9
+ export * from './Drawer/index.js';
9
10
  export * from './Icon/index.js';
10
11
  export * from './Input/index.js';
11
12
  export * from './Label/index.js';
@@ -6,6 +6,7 @@ export * from './Card/index.js';
6
6
  export * from './Checkbox/index.js';
7
7
  export * from './ContextMenu/index.js';
8
8
  export * from './Dialog/index.js';
9
+ export * from './Drawer/index.js';
9
10
  export * from './Icon/index.js';
10
11
  export * from './Input/index.js';
11
12
  export * from './Label/index.js';
package/dist/strings.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface UiStrings {
28
28
  heading: string;
29
29
  bulletList: string;
30
30
  orderedList: string;
31
+ /** Drawer: the close button's accessible name. */
32
+ close: string;
33
+ /** Drawer: the handle's accessible name while collapsed. */
34
+ expand: string;
35
+ /** Drawer: the handle's accessible name while expanded. */
36
+ collapse: string;
31
37
  }
32
38
  /**
33
39
  * Feeds the atoms their strings, once, near the root.
package/dist/strings.js CHANGED
@@ -11,6 +11,9 @@ const DEFAULTS = {
11
11
  heading: 'Heading',
12
12
  bulletList: 'Bulleted list',
13
13
  orderedList: 'Numbered list',
14
+ close: 'Close',
15
+ expand: 'Expand',
16
+ collapse: 'Collapse',
14
17
  };
15
18
  const StringsContext = createContext(DEFAULTS);
16
19
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The Radix colour scales the atoms name. A consumer that imports only gray and its accent
3
+ * gets badges and toasts with no colour at all, and nothing says so — the variables are simply
4
+ * undefined. This is that something, in development only.
5
+ */
6
+ export declare const REQUIRED_SCALES: readonly ["gray", "jade", "amber", "red", "blue"];
7
+ export declare function warnMissingScales(el: Element | null): void;
8
+ /** Test seam. */
9
+ export declare function resetScaleCheck(): void;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The Radix colour scales the atoms name. A consumer that imports only gray and its accent
3
+ * gets badges and toasts with no colour at all, and nothing says so — the variables are simply
4
+ * undefined. This is that something, in development only.
5
+ */
6
+ export const REQUIRED_SCALES = ['gray', 'jade', 'amber', 'red', 'blue'];
7
+ const isDev = globalThis.process?.env?.NODE_ENV !==
8
+ 'production';
9
+ let checked = false;
10
+ export function warnMissingScales(el) {
11
+ if (checked || !el || typeof getComputedStyle === 'undefined')
12
+ return;
13
+ if (!isDev)
14
+ return;
15
+ const style = getComputedStyle(el);
16
+ const missing = REQUIRED_SCALES.filter((s) => !style.getPropertyValue(`--${s}-9`).trim());
17
+ // Everything missing means no stylesheet yet (or jsdom) — not a choice; check again later.
18
+ if (missing.length === REQUIRED_SCALES.length)
19
+ return;
20
+ checked = true;
21
+ if (missing.length === 0)
22
+ return;
23
+ console.warn(`@kennycorrea/ui: missing Radix colour scale(s) ${missing.join(', ')}. Import ` +
24
+ `${missing.map((s) => `@radix-ui/themes/tokens/colors/${s}.css`).join(', ')} after ` +
25
+ '@radix-ui/themes/tokens/base.css, or Badge and Toast render colourless.');
26
+ }
27
+ /** Test seam. */
28
+ export function resetScaleCheck() {
29
+ checked = false;
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Upfront's house component layer: atoms over Radix Themes, the token bridge, and the accent logic.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -50,6 +50,7 @@
50
50
  "@radix-ui/themes": "^3.3.0",
51
51
  "@testing-library/react": "^16.3.3",
52
52
  "@testing-library/user-event": "^14.6.7",
53
+ "@types/node": "^24.19.2",
53
54
  "@types/react": "^19.2.18",
54
55
  "@types/react-dom": "^19.2.7",
55
56
  "jsdom": "^30.1.2",