@kennycorrea/ui 0.9.0 → 0.9.1

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 — 2026-10-18
4
+
5
+ ### Fixed
6
+ - **`Drawer` renders inside the nearest `<Theme>`** (`.radix-themes`) instead of under
7
+ `<body>`. Outside a Theme none of the house tokens or the accent are defined, so in 0.9.0 the
8
+ sheet lost the app's appearance — a dark app got a light sheet. Falls back to `<body>` when
9
+ there is no Theme; `container` still overrides it.
10
+ - In `modal` mode, everything outside the sheet goes `inert` at every level up to `<body>`, not
11
+ only the sheet's siblings, now that the sheet sits inside the Theme rather than beside it.
12
+
3
13
  ## 0.9.0 — 2026-10-18
4
14
 
5
15
  ### Added
package/README.md CHANGED
@@ -343,5 +343,8 @@ above that (`maxWidthClassName` to change it), safe-area aware, themed from the
343
343
  `UiStringsProvider`.
344
344
  - Motion is a CSS transform, switched off under `prefers-reduced-motion`.
345
345
 
346
+ It portals into the nearest `<Theme>` so it inherits the appearance and accent, falling back to
347
+ `<body>`; `container` overrides that.
348
+
346
349
  Built by hand rather than on Radix Dialog because the non-modal half must not do what Dialog
347
350
  does to the rest of the page. Both examples are in `examples/Drawers.tsx`.
@@ -43,7 +43,7 @@ export interface DrawerProps {
43
43
  /** Caps the width on wider screens. Default `max-w-lg`; pass `max-w-none` for full width. */
44
44
  maxWidthClassName?: string;
45
45
  className?: string;
46
- /** Where the portal mounts. Default `document.body`. */
46
+ /** Where the portal mounts. Default: the nearest `<Theme>` (`.radix-themes`), else `document.body`. */
47
47
  container?: HTMLElement | null;
48
48
  }
49
49
  /**
@@ -59,4 +59,4 @@ export interface DrawerProps {
59
59
  * from inside the body only counts when the body is scrolled to its top and moving down, so the
60
60
  * content still scrolls normally.
61
61
  */
62
- export declare function Drawer({ mode, open: openProp, defaultOpen, onOpenChange, onClose, snap: snapProp, defaultSnap, onSnapChange, dismissible, title, description, peek, children, footer, showCloseButton, closeLabel, expandLabel, collapseLabel, maxWidthClassName, className, container, }: DrawerProps): import("react").ReactPortal | null;
62
+ export declare function Drawer({ mode, open: openProp, defaultOpen, onOpenChange, onClose, snap: snapProp, defaultSnap, onSnapChange, dismissible, title, description, peek, children, footer, showCloseButton, closeLabel, expandLabel, collapseLabel, maxWidthClassName, className, container, }: DrawerProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useId, useRef, useState, } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { useUiStrings } from '../../strings.js';
5
5
  import { cn } from '../../utils/cn.js';
@@ -53,21 +53,32 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
53
53
  setOpen(false);
54
54
  onClose?.();
55
55
  }, [setOpen, onClose]);
56
+ // Portal into the nearest <Theme> so the sheet inherits its appearance, accent and tokens;
57
+ // a portal under <body> would sit outside `.radix-themes`, where none of them are defined.
58
+ const anchorRef = useRef(null);
59
+ const [themeRoot, setThemeRoot] = useState(null);
60
+ useLayoutEffect(() => {
61
+ setThemeRoot(anchorRef.current?.closest('.radix-themes') ?? document.body);
62
+ }, []);
63
+ const target = container ?? themeRoot;
56
64
  // Modal: everything else on the page goes inert, focus moves in, and comes back on close.
57
65
  useEffect(() => {
58
- if (!open || !modal)
66
+ // The wrapper only exists once the target is known, so wait for it.
67
+ if (!open || !modal || !target)
59
68
  return;
60
69
  const wrapper = wrapperRef.current;
61
- const root = wrapper?.parentElement;
62
70
  const previous = document.activeElement;
63
71
  const made = [];
64
- if (root) {
65
- for (const el of Array.from(root.children)) {
66
- if (el !== wrapper && !el.hasAttribute('inert')) {
72
+ // Every sibling on the way up to <body>: the app inside the Theme, and anything beside it.
73
+ for (let node = wrapper ?? null; node && node !== document.body;) {
74
+ const parent = node.parentElement;
75
+ for (const el of Array.from(parent?.children ?? [])) {
76
+ if (el !== node && !el.hasAttribute('inert')) {
67
77
  el.setAttribute('inert', '');
68
78
  made.push(el);
69
79
  }
70
80
  }
81
+ node = parent;
71
82
  }
72
83
  const first = sheetRef.current?.querySelector(FOCUSABLE);
73
84
  (first ?? sheetRef.current)?.focus();
@@ -76,7 +87,7 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
76
87
  el.removeAttribute('inert');
77
88
  previous?.focus?.();
78
89
  };
79
- }, [open, modal]);
90
+ }, [open, modal, target]);
80
91
  useEffect(() => {
81
92
  if (!open || !modal || !dismissible)
82
93
  return;
@@ -176,15 +187,15 @@ export function Drawer({ mode = 'modal', open: openProp, defaultOpen = true, onO
176
187
  }
177
188
  setSnap(expanded ? 'collapsed' : 'expanded');
178
189
  };
179
- if (!open)
180
- return null;
181
- const target = container ?? (typeof document === 'undefined' ? null : document.body);
182
- if (!target)
183
- return null;
190
+ // A hidden marker stays in the React tree's DOM position, so the portal can find its Theme.
191
+ const anchor = _jsx("span", { ref: anchorRef, hidden: true, "data-drawer-anchor": "" });
192
+ if (!open || !target)
193
+ return anchor;
184
194
  const dragHandlers = {
185
195
  onPointerMove: move,
186
196
  onPointerUp: end,
187
197
  onPointerCancel: cancel,
188
198
  };
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);
199
+ return (_jsxs(_Fragment, { children: [anchor, 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 &&
200
+ '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
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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",