@kennycorrea/ui 0.8.1 → 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,20 @@
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
+
3
18
  ## 0.8.1 — 2026-10-11
4
19
 
5
20
  ### Fixed
package/README.md CHANGED
@@ -310,3 +310,38 @@ is in `examples/BudgetCycleSlider.tsx`.
310
310
 
311
311
  ⚠ `--ui-slider-thumb-size` mirrors Themes' size-2 thumb (`--space-2 + --space-1`). The fill
312
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`.
@@ -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';
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.8.1",
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",