@zyncat/ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +2 -2
  2. package/dist/alert.js +1 -1
  3. package/dist/button.css +10 -2
  4. package/dist/button.d.ts +2 -6
  5. package/dist/button.js +1 -1
  6. package/dist/{chunk-U5QQZC3U.js → chunk-2F5PITAO.js} +2 -6
  7. package/dist/chunk-2F5PITAO.js.map +1 -0
  8. package/dist/{chunk-EQ32F2QJ.js → chunk-62P3KD57.js} +5 -5
  9. package/dist/chunk-62P3KD57.js.map +1 -0
  10. package/dist/{chunk-W5YM2FV4.js → chunk-FTOAUJL5.js} +9 -6
  11. package/dist/chunk-FTOAUJL5.js.map +1 -0
  12. package/dist/{chunk-FLO4A6BI.js → chunk-K5X2GCXU.js} +54 -52
  13. package/dist/chunk-K5X2GCXU.js.map +1 -0
  14. package/dist/{chunk-Q5B7JTSR.js → chunk-OMHQ4U4J.js} +8 -8
  15. package/dist/chunk-OMHQ4U4J.js.map +1 -0
  16. package/dist/{chunk-GY7JOPUQ.js → chunk-RCONNA7I.js} +5 -5
  17. package/dist/chunk-RCONNA7I.js.map +1 -0
  18. package/dist/{chunk-GLT5IKQY.js → chunk-UXONUUPI.js} +9 -5
  19. package/dist/chunk-UXONUUPI.js.map +1 -0
  20. package/dist/{chunk-7RNRLIKT.js → chunk-Y7AJQVWJ.js} +3 -3
  21. package/dist/{chunk-7RNRLIKT.js.map → chunk-Y7AJQVWJ.js.map} +1 -1
  22. package/dist/{chunk-LDC3RIYA.js → chunk-ZCOUPPTI.js} +3 -3
  23. package/dist/{chunk-LDC3RIYA.js.map → chunk-ZCOUPPTI.js.map} +1 -1
  24. package/dist/date-field.js +6 -6
  25. package/dist/date-range-field.js +7 -7
  26. package/dist/datetime-field.js +6 -6
  27. package/dist/dialog.css +3 -0
  28. package/dist/dialog.d.ts +4 -1
  29. package/dist/dialog.js +7 -5
  30. package/dist/dialog.js.map +1 -1
  31. package/dist/modal.css +9 -0
  32. package/dist/modal.d.ts +35 -0
  33. package/dist/modal.js +63 -0
  34. package/dist/modal.js.map +1 -0
  35. package/dist/motion-devtools.js +1 -1
  36. package/dist/multi-select.js +4 -4
  37. package/dist/overlay.css +3 -0
  38. package/dist/pagination.js +1 -1
  39. package/dist/popover.d.ts +10 -3
  40. package/dist/popover.js +4 -4
  41. package/dist/select.js +4 -4
  42. package/dist/sheet.d.ts +4 -1
  43. package/dist/sheet.js +4 -4
  44. package/dist/table.js +1 -1
  45. package/dist/toast.js +1 -1
  46. package/llms.txt +31 -7
  47. package/package.json +16 -11
  48. package/src/components/composites/dialog/Dialog.tsx +30 -23
  49. package/src/components/composites/dialog/dialog.css +3 -0
  50. package/src/components/composites/modal/Modal.tsx +95 -0
  51. package/src/components/composites/modal/modal.css +9 -0
  52. package/src/components/composites/popover/Popover.tsx +12 -3
  53. package/src/components/composites/select/core/trigger.tsx +8 -12
  54. package/src/components/composites/sheet/Sheet.tsx +10 -1
  55. package/src/components/internal/overlay/layer.tsx +3 -3
  56. package/src/components/internal/overlay/modal.tsx +45 -36
  57. package/src/components/internal/overlay/overlay.css +3 -0
  58. package/src/components/internal/overlay/position.ts +8 -3
  59. package/src/components/primitives/button/Button.tsx +0 -16
  60. package/src/components/primitives/button/button.css +10 -2
  61. package/dist/chunk-EQ32F2QJ.js.map +0 -1
  62. package/dist/chunk-FLO4A6BI.js.map +0 -1
  63. package/dist/chunk-GLT5IKQY.js.map +0 -1
  64. package/dist/chunk-GY7JOPUQ.js.map +0 -1
  65. package/dist/chunk-Q5B7JTSR.js.map +0 -1
  66. package/dist/chunk-U5QQZC3U.js.map +0 -1
  67. package/dist/chunk-W5YM2FV4.js.map +0 -1
@@ -2,7 +2,7 @@
2
2
  import { animate } from './chunk-YGCVWGUG.js';
3
3
  import { Icon } from './chunk-3SBCBYB7.js';
4
4
  import { UIMotion } from './chunk-S5ZTZANS.js';
5
- import { Button } from './chunk-U5QQZC3U.js';
5
+ import { Button } from './chunk-2F5PITAO.js';
6
6
  import { cx } from './chunk-QGNC7LH5.js';
7
7
  import './pagination.css';
8
8
  import { useRef, useEffect } from 'react';
package/dist/popover.d.ts CHANGED
@@ -4,6 +4,10 @@ import { D as DisableableAnimation } from './timing-7JA_zQHP.js';
4
4
  import { D as DataAttributes } from './dom-props-C0jrllBw.js';
5
5
  import './motion-scale-Canq4stZ.js';
6
6
 
7
+ interface VirtualAnchor {
8
+ getBoundingClientRect(): DOMRect;
9
+ }
10
+
7
11
  interface PopoverProps {
8
12
  /** Controlled open state. Omit to stay uncontrolled. */
9
13
  open?: boolean;
@@ -11,8 +15,11 @@ interface PopoverProps {
11
15
  defaultOpen?: boolean;
12
16
  /** Fires whenever the open state changes. Pair with `open` for controlled use. */
13
17
  onOpenChange?: (open: boolean) => void;
14
- /** Cloned to toggle the panel; required as the anchor. */
18
+ /** Cloned to toggle the panel, and used as the anchor unless `anchor` is set. */
15
19
  trigger?: ReactElement | null;
20
+ /** Anchor to an arbitrary rect instead of the trigger - any `{ getBoundingClientRect() }`, which an element also satisfies.
21
+ * Drive `open` yourself; pass a new object to re-place a moving anchor. */
22
+ anchor?: VirtualAnchor | null;
16
23
  /** Preferred side; flips to the opposite side when cramped. Default 'bottom'. */
17
24
  side?: 'top' | 'bottom' | 'left' | 'right';
18
25
  /** Cross-axis alignment against the trigger. Default 'start'. */
@@ -30,6 +37,6 @@ interface PopoverProps {
30
37
  /** The ENTIRE surface - paint AND semantics. Drive dismissal with `open`/`onOpenChange`. */
31
38
  children: ReactNode;
32
39
  }
33
- declare function Popover({ open: controlledOpen, defaultOpen, onOpenChange, trigger, side, align, arrow, dismissible, id, htmlProps, animation, children, }: PopoverProps): react.JSX.Element;
40
+ declare function Popover({ open: controlledOpen, defaultOpen, onOpenChange, trigger, anchor, side, align, arrow, dismissible, id, htmlProps, animation, children, }: PopoverProps): react.JSX.Element;
34
41
 
35
- export { Popover, type PopoverProps };
42
+ export { Popover, type PopoverProps, type VirtualAnchor };
package/dist/popover.js CHANGED
@@ -1,7 +1,7 @@
1
- 'use client';export { Popover } from './chunk-W5YM2FV4.js';
2
- import './chunk-EQ32F2QJ.js';
3
- import './chunk-LDC3RIYA.js';
4
- import './chunk-GY7JOPUQ.js';
1
+ 'use client';export { Popover } from './chunk-FTOAUJL5.js';
2
+ import './chunk-62P3KD57.js';
3
+ import './chunk-ZCOUPPTI.js';
4
+ import './chunk-RCONNA7I.js';
5
5
  import './chunk-UE63OOVG.js';
6
6
  import './chunk-NEF5XAHG.js';
7
7
  import './chunk-356HZMT4.js';
package/dist/select.js CHANGED
@@ -1,6 +1,6 @@
1
- 'use client';import { useListbox, SelectTrigger, ListboxPanel } from './chunk-Q5B7JTSR.js';
2
- import './chunk-EQ32F2QJ.js';
3
- import './chunk-GY7JOPUQ.js';
1
+ 'use client';import { useListbox, SelectTrigger, ListboxPanel } from './chunk-OMHQ4U4J.js';
2
+ import './chunk-62P3KD57.js';
3
+ import './chunk-RCONNA7I.js';
4
4
  import './chunk-UE63OOVG.js';
5
5
  import './chunk-WM5J5TDI.js';
6
6
  import './chunk-NEF5XAHG.js';
@@ -11,7 +11,7 @@ import './chunk-2ERBBFZS.js';
11
11
  import './chunk-3SBCBYB7.js';
12
12
  import { useControllable } from './chunk-MMRIQNEN.js';
13
13
  import './chunk-S5ZTZANS.js';
14
- import './chunk-U5QQZC3U.js';
14
+ import './chunk-2F5PITAO.js';
15
15
  import './chunk-7SZCJ7WI.js';
16
16
  import './chunk-VTJTGVZZ.js';
17
17
  import { cx } from './chunk-QGNC7LH5.js';
package/dist/sheet.d.ts CHANGED
@@ -17,6 +17,9 @@ interface SheetProps {
17
17
  side?: 'right' | 'bottom';
18
18
  /** Scrim/Esc dismissal + drag-to-edge. Default true. */
19
19
  dismissible?: boolean;
20
+ /** Dock inside this element instead of the viewport - it must be positioned (`position: relative` or similar).
21
+ * Scrim, scroll lock and inert scope to it; the rest of the page stays interactive. */
22
+ container?: HTMLElement | null;
20
23
  /** Base id for the panel; drives the trigger's `aria-controls`. Auto-generated when omitted. */
21
24
  id?: string;
22
25
  /** Standard attributes (className, style, data-*, ...) forwarded to the sheet panel. */
@@ -26,6 +29,6 @@ interface SheetProps {
26
29
  /** The ENTIRE surface - paint AND semantics. Drive dismissal with `open`/`onOpenChange`. */
27
30
  children: ReactNode;
28
31
  }
29
- declare function Sheet({ open: controlledOpen, defaultOpen, onOpenChange, trigger, side, dismissible, id, htmlProps, animation, children, }: SheetProps): react.JSX.Element;
32
+ declare function Sheet({ open: controlledOpen, defaultOpen, onOpenChange, trigger, side, dismissible, container, id, htmlProps, animation, children, }: SheetProps): react.JSX.Element;
30
33
 
31
34
  export { Sheet, type SheetProps };
package/dist/sheet.js CHANGED
@@ -1,7 +1,7 @@
1
- 'use client';export { Sheet } from './chunk-GLT5IKQY.js';
2
- import './chunk-FLO4A6BI.js';
3
- import './chunk-LDC3RIYA.js';
4
- import './chunk-GY7JOPUQ.js';
1
+ 'use client';export { Sheet } from './chunk-UXONUUPI.js';
2
+ import './chunk-K5X2GCXU.js';
3
+ import './chunk-ZCOUPPTI.js';
4
+ import './chunk-RCONNA7I.js';
5
5
  import './chunk-NEF5XAHG.js';
6
6
  import './chunk-356HZMT4.js';
7
7
  import './chunk-XWUWYSCT.js';
package/dist/table.js CHANGED
@@ -5,7 +5,7 @@ import './chunk-YGCVWGUG.js';
5
5
  import { Icon } from './chunk-3SBCBYB7.js';
6
6
  import { Odometer } from './chunk-CONSWNIV.js';
7
7
  import { UIMotion } from './chunk-S5ZTZANS.js';
8
- import { Button } from './chunk-U5QQZC3U.js';
8
+ import { Button } from './chunk-2F5PITAO.js';
9
9
  import { Checkbox } from './chunk-QDK66S4G.js';
10
10
  import './chunk-NNXJCLTV.js';
11
11
  import { cx } from './chunk-QGNC7LH5.js';
package/dist/toast.js CHANGED
@@ -8,7 +8,7 @@ import { animate, startDrag } from './chunk-YGCVWGUG.js';
8
8
  import { Icon } from './chunk-3SBCBYB7.js';
9
9
  import { fireGlint } from './chunk-S7B34VGN.js';
10
10
  import { UIMotion } from './chunk-S5ZTZANS.js';
11
- import { Button } from './chunk-U5QQZC3U.js';
11
+ import { Button } from './chunk-2F5PITAO.js';
12
12
  import { cx } from './chunk-QGNC7LH5.js';
13
13
  import './toast.css';
14
14
  import { useEffect, useSyncExternalStore, useState, useRef, useLayoutEffect } from 'react';
package/llms.txt CHANGED
@@ -27,8 +27,9 @@ Agents: prefer the bundled MCP server over scanning this file or node_modules -
27
27
  ============================================================ PRIMITIVES
28
28
 
29
29
  Button - @zyncat/ui/button
30
- One control for every click; exactly one primary per view. variant primary|secondary|ghost|danger|link, size sm|md|lg, iconLeft/iconRight (ReactNode), loading, disabled.
31
- <Button variant="primary" iconLeft={<Icon name="plus" size="sm" />}>Schedule post</Button>
30
+ One control for every click; exactly one primary per view. variant primary|secondary|ghost|danger|link, size sm|md|lg|icon, loading, disabled. Icons are plain children - Button lays them out in a centered row with gap, no iconLeft/iconRight slots.
31
+ <Button variant="primary"><Icon name="plus" size="sm" />Schedule post</Button>
32
+ <Button size="icon" aria-label="Close"><Icon name="x" size="sm" /></Button>
32
33
 
33
34
  Collapse - @zyncat/ui/collapse
34
35
  Layout-transition primitive; eases height open/closed, never teleports. Closed content also
@@ -168,16 +169,39 @@ Tooltip - @zyncat/ui/tooltip
168
169
  <Tooltip content="Save changes" shortcut="⌘S"><Button>Save</Button></Tooltip>
169
170
 
170
171
  Dialog - @zyncat/ui/dialog
171
- Styled modal: scrim, focus trap, scroll lock. open/onOpenChange, tone, icon, title, description, footer: (close) => node.
172
+ DEFAULT CHOICE for modals - the styled surface: header (icon/title/description/close), scrolling body,
173
+ footer actions, on top of scrim + focus trap + scroll lock. Reach for Modal only if this chrome is wrong.
174
+ open/onOpenChange, tone, icon, title, description, footer: (close) => node. Header is omitted entirely
175
+ when there is no icon/title/description and dismissible={false}.
176
+ container mounts it inside a positioned element instead of the viewport; scrim, scroll lock and
177
+ inert then scope to that box and the rest of the page stays live.
172
178
  <Dialog open={o} onOpenChange={setO} tone="danger" title="Delete this project?" footer={(close) => <>...</>}>...</Dialog>
173
179
 
180
+ Modal - @zyncat/ui/modal
181
+ The SAME modality as Dialog (scrim, focus trap, scroll lock, inert, Esc, motion) with ZERO paint - no
182
+ header, no body padding, no width cap, no border/radius/shadow. You supply the whole surface AND its
183
+ semantics (role="dialog" + a label).
184
+ Use it ONLY when Dialog's chrome is the problem: lightbox / image viewer, full-bleed media, video,
185
+ a custom layout that owns its own edges. If the content is a title + text + buttons, use Dialog instead.
186
+ htmlProps paints the panel (centered by default; width+height 100% = full-bleed), layerProps reaches the
187
+ box behind it (set --bg-overlay to retint the scrim). Also takes trigger, dismissible, container, animation.
188
+ <Modal open={!!src} onOpenChange={() => setSrc(null)} htmlProps={{ className: 'lightbox' }} layerProps={{ style: { '--bg-overlay': 'rgb(0 0 0 / 0.92)' } }}>
189
+ <div role="dialog" aria-label="Image viewer"><img src={src} alt="" /></div>
190
+ </Modal>
191
+
174
192
  Popover - @zyncat/ui/popover
175
- Headless anchored panel, non-modal. side, align, arrow, trigger (node), asChild; children: ({ close }) => node.
176
- <Popover side="bottom" align="start" trigger={<Button>Actions</Button>}>{({ close }) => <menu>...</menu>}</Popover>
193
+ Headless anchored panel, non-modal. side, align, arrow, trigger (node), dismissible.
194
+ Anchor without a trigger: pass anchor - anything with getBoundingClientRect (a DOMRect source, an
195
+ element) - and drive open yourself. Pass a new object to re-place a moving anchor.
196
+ <Popover side="bottom" align="start" trigger={<Button>Actions</Button>}><menu>...</menu></Popover>
197
+ <Popover open={!!spot} onOpenChange={dismiss} anchor={spot} side="right"><menu>...</menu></Popover>
177
198
 
178
199
  Sheet - @zyncat/ui/sheet
179
- Edge-docked modal panel, drag-to-dismiss. side right|bottom, trigger (node), asChild; children: ({ close }) => node.
180
- <Sheet side="right" trigger={<Button>Filters</Button>}>{({ close }) => <div role="dialog">...</div>}</Sheet>
200
+ Edge-docked modal panel, drag-to-dismiss. side right|bottom, trigger (node), dismissible.
201
+ container docks it inside a positioned element instead of the viewport; scrim, scroll lock and
202
+ inert then scope to that box and the rest of the page stays live.
203
+ <Sheet side="right" trigger={<Button>Filters</Button>}><div role="dialog">...</div></Sheet>
204
+ <Sheet side="bottom" container={panel} open={o} onOpenChange={setO}><div role="dialog">...</div></Sheet>
181
205
 
182
206
  ============================================================ DEV TOOLS
183
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zyncat/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "A React 19 design system: accessible, animated UI components built on a small, closed CSS design-token vocabulary. No Tailwind, no CSS-in-JS.",
5
5
  "keywords": [
6
6
  "react",
@@ -181,6 +181,11 @@
181
181
  "types": "./dist/dialog.d.ts",
182
182
  "import": "./dist/dialog.js"
183
183
  },
184
+ "./modal": {
185
+ "source": "./src/components/composites/modal/Modal.tsx",
186
+ "types": "./dist/modal.d.ts",
187
+ "import": "./dist/modal.js"
188
+ },
184
189
  "./tooltip": {
185
190
  "source": "./src/components/composites/tooltip/Tooltip.tsx",
186
191
  "types": "./dist/tooltip.d.ts",
@@ -230,15 +235,6 @@
230
235
  "next.mjs",
231
236
  "next.d.mts"
232
237
  ],
233
- "scripts": {
234
- "build": "tsup",
235
- "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json",
236
- "check:css": "node scripts/check-css-graph.mjs",
237
- "check:llms": "node scripts/check-llms.mjs",
238
- "format": "prettier --write .",
239
- "format:check": "prettier --check .",
240
- "prepublishOnly": "pnpm build"
241
- },
242
238
  "peerDependencies": {
243
239
  "react": "^19",
244
240
  "react-dom": "^19"
@@ -254,5 +250,14 @@
254
250
  "react-dom": "^19.2.7",
255
251
  "tsup": "^8.5.0",
256
252
  "typescript": "~6.0.3"
253
+ },
254
+ "scripts": {
255
+ "build": "NODE_OPTIONS=--max-old-space-size=8192 tsup",
256
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.node.json",
257
+ "check:css": "node scripts/check-css-graph.mjs",
258
+ "check:llms": "node scripts/check-llms.mjs",
259
+ "format": "prettier --write .",
260
+ "format:check": "prettier --check .",
261
+ "dev": "cd playground && pnpm dev"
257
262
  }
258
- }
263
+ }
@@ -40,6 +40,9 @@ export interface DialogProps {
40
40
  icon?: ReactNode;
41
41
  /** Close button + backdrop/Esc dismissal. Default true. */
42
42
  dismissible?: boolean;
43
+ /** Center inside this element instead of the viewport - it must be positioned (`position: relative` or similar).
44
+ * Scrim, scroll lock and inert scope to it; the rest of the page stays interactive. */
45
+ container?: HTMLElement | null;
43
46
  /** Action row - a node, or a render fn `(close) => node` so uncontrolled dialogs can dismiss. */
44
47
  footer?: ReactNode | ((close: () => void) => ReactNode);
45
48
  /** Dialog body - scrolls when tall; its scroll edges flag the header lift + footer divider. */
@@ -100,30 +103,32 @@ function DialogSurface({
100
103
  aria-labelledby={title ? titleId : undefined}
101
104
  aria-describedby={description ? descId : undefined}
102
105
  >
103
- <header className="dialog__header">
104
- {icon && (
105
- <span className="dialog__icon">
106
- <IconSlot size="md">{icon}</IconSlot>
107
- </span>
108
- )}
109
- <div className="dialog__heading">
110
- {title && (
111
- <h2 className="dialog__title" id={titleId}>
112
- {title}
113
- </h2>
106
+ {(icon || title || description || dismissible) && (
107
+ <header className="dialog__header">
108
+ {icon && (
109
+ <span className="dialog__icon">
110
+ <IconSlot size="md">{icon}</IconSlot>
111
+ </span>
114
112
  )}
115
- {description && (
116
- <p className="dialog__desc" id={descId}>
117
- {description}
118
- </p>
113
+ <div className="dialog__heading">
114
+ {title && (
115
+ <h2 className="dialog__title" id={titleId}>
116
+ {title}
117
+ </h2>
118
+ )}
119
+ {description && (
120
+ <p className="dialog__desc" id={descId}>
121
+ {description}
122
+ </p>
123
+ )}
124
+ </div>
125
+ {dismissible && (
126
+ <button type="button" className="dialog__close" aria-label="Close dialog" onClick={requestClose}>
127
+ <Icon name="x" size="sm" weight="bold" />
128
+ </button>
119
129
  )}
120
- </div>
121
- {dismissible && (
122
- <button type="button" className="dialog__close" aria-label="Close dialog" onClick={requestClose}>
123
- <Icon name="x" size="sm" weight="bold" />
124
- </button>
125
- )}
126
- </header>
130
+ </header>
131
+ )}
127
132
 
128
133
  <div className="dialog__body" ref={bodyRef}>
129
134
  {children}
@@ -145,6 +150,7 @@ export function Dialog({
145
150
  tone = 'default',
146
151
  icon = null,
147
152
  dismissible = true,
153
+ container = null,
148
154
  footer = null,
149
155
  children,
150
156
  id,
@@ -162,7 +168,7 @@ export function Dialog({
162
168
  return (
163
169
  <Fragment>
164
170
  {ovCloneTrigger(trigger, { open, onPress: () => setOpen(true), panelId, haspopup: 'dialog', triggerRef })}
165
- <OverlayPortal>
171
+ <OverlayPortal container={container}>
166
172
  <Presence>
167
173
  {open && (
168
174
  <ModalShell
@@ -173,6 +179,7 @@ export function Dialog({
173
179
  panelClass="overlay-panel--dialog"
174
180
  panelId={panelId}
175
181
  dismissible={dismissible}
182
+ container={container}
176
183
  requestClose={close}
177
184
  >
178
185
  <DialogSurface
@@ -136,6 +136,9 @@
136
136
  color: var(--text-body);
137
137
  font: var(--type-body);
138
138
  }
139
+ .dialog__body:first-child {
140
+ padding-top: var(--space-5);
141
+ }
139
142
  .dialog:has(.dialog__icon) .dialog__body {
140
143
  padding-left: calc(var(--space-5) + 2.25rem + var(--space-3));
141
144
  }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import './modal.css';
4
+ import { Fragment, useId, useRef, type HTMLAttributes, type ReactElement, type ReactNode } from 'react';
5
+ import { Presence } from '../../../motion/presence';
6
+ import { resolveMotionTiming } from '../../../motion/motion-timing';
7
+ import type { DisableableAnimation } from '../../../motion/timing';
8
+ import { useControllable } from '../../internal/hooks/use-controllable';
9
+ import { ovCloneTrigger, OverlayPortal } from '../../internal/overlay/layer';
10
+ import { ModalShell } from '../../internal/overlay/modal';
11
+ import type { DataAttributes } from '../../../dom-props';
12
+
13
+ const MODAL_TIMING = {
14
+ open: { duration: 'slow', ease: 'entrance' },
15
+ close: { duration: 'base', ease: 'standard' },
16
+ } as const;
17
+
18
+ export interface ModalProps {
19
+ /** Controlled open state. Omit to stay uncontrolled. */
20
+ open?: boolean;
21
+ /** Initial state when uncontrolled. Default false. */
22
+ defaultOpen?: boolean;
23
+ /** Fires whenever the open state changes. Pair with `open` for controlled use. */
24
+ onOpenChange?: (open: boolean) => void;
25
+
26
+ /** Cloned to open the modal on click. */
27
+ trigger?: ReactElement | null;
28
+
29
+ /** Scrim/Esc dismissal. Default true. */
30
+ dismissible?: boolean;
31
+
32
+ /** Mount inside this element instead of the viewport - it must be positioned (`position: relative` or similar).
33
+ * Scrim, scroll lock and inert scope to it; the rest of the page stays interactive. */
34
+ container?: HTMLElement | null;
35
+
36
+ /** Base id for the panel; drives the trigger's `aria-controls`. Auto-generated when omitted. */
37
+ id?: string;
38
+ /** Standard attributes (className, style, data-*, ...) forwarded to the panel - this is where your paint goes.
39
+ * Centered by default; `width: 100%; height: 100%` makes it full-bleed. */
40
+ htmlProps?: HTMLAttributes<HTMLDivElement> & DataAttributes;
41
+ /** Standard attributes forwarded to the layer behind the panel - set `--bg-overlay` here to retint the scrim. */
42
+ layerProps?: HTMLAttributes<HTMLDivElement> & DataAttributes;
43
+ /** Open/close timing - motion tokens only, or `null` to disable. @default open 'slow'/'entrance', close 'base'/'standard' */
44
+ animation?: DisableableAnimation;
45
+ /** The ENTIRE surface - paint AND semantics (`role="dialog"`, a label). Nothing is painted for you. */
46
+ children: ReactNode;
47
+ }
48
+
49
+ export function Modal({
50
+ open: controlledOpen,
51
+ defaultOpen = false,
52
+ onOpenChange,
53
+ trigger = null,
54
+ dismissible = true,
55
+ container = null,
56
+ id,
57
+ htmlProps,
58
+ layerProps,
59
+ animation,
60
+ children,
61
+ }: ModalProps) {
62
+ const [open, setOpen] = useControllable(controlledOpen, defaultOpen, onOpenChange);
63
+ const triggerRef = useRef<HTMLElement>(null);
64
+ const autoId = useId();
65
+ const panelId = id || 'modal-' + autoId;
66
+ const close = () => setOpen(false);
67
+ const timings = resolveMotionTiming(animation, MODAL_TIMING);
68
+
69
+ return (
70
+ <Fragment>
71
+ {ovCloneTrigger(trigger, { open, onPress: () => setOpen(true), panelId, haspopup: 'dialog', triggerRef })}
72
+ <OverlayPortal container={container}>
73
+ <Presence>
74
+ {open && (
75
+ <ModalShell
76
+ key="modal"
77
+ animate={{ opacity: [0, 1], scale: [0.98, 1], timing: timings.open }}
78
+ exit={{ opacity: [0], scale: [0.98], timing: timings.close }}
79
+ layerClass="overlay-layer--modal"
80
+ panelClass="overlay-panel--modal"
81
+ panelId={panelId}
82
+ dismissible={dismissible}
83
+ container={container}
84
+ requestClose={close}
85
+ panelProps={htmlProps}
86
+ layerProps={layerProps}
87
+ >
88
+ {children}
89
+ </ModalShell>
90
+ )}
91
+ </Presence>
92
+ </OverlayPortal>
93
+ </Fragment>
94
+ );
95
+ }
@@ -0,0 +1,9 @@
1
+ /* modal.css - the unpainted modal surface: the shell layer centers the panel and
2
+ constrains it to the layer box. Everything else is the consumer's paint. */
3
+ .overlay-layer--modal {
4
+ place-items: center;
5
+ }
6
+ .overlay-panel--modal {
7
+ max-width: 100%;
8
+ max-height: 100%;
9
+ }
@@ -9,10 +9,12 @@ import type { DisableableAnimation } from '../../../motion/timing';
9
9
  import { useControllable } from '../../internal/hooks/use-controllable';
10
10
  import { ovCloneTrigger, useOverlayEntry, useOutsidePress, OverlayPortal } from '../../internal/overlay/layer';
11
11
  import { useReturnFocus } from '../../internal/overlay/focus';
12
- import { useAnchorPosition } from '../../internal/overlay/position';
12
+ import { useAnchorPosition, type VirtualAnchor } from '../../internal/overlay/position';
13
13
  import type { DataAttributes } from '../../../dom-props';
14
14
  import { cx } from '../../internal/utils/cx';
15
15
 
16
+ export type { VirtualAnchor };
17
+
16
18
  const POPOVER_TIMING = {
17
19
  open: { duration: 'base', ease: 'entrance' },
18
20
  close: { duration: 'fast', ease: 'exit' },
@@ -26,8 +28,11 @@ export interface PopoverProps {
26
28
  /** Fires whenever the open state changes. Pair with `open` for controlled use. */
27
29
  onOpenChange?: (open: boolean) => void;
28
30
 
29
- /** Cloned to toggle the panel; required as the anchor. */
31
+ /** Cloned to toggle the panel, and used as the anchor unless `anchor` is set. */
30
32
  trigger?: ReactElement | null;
33
+ /** Anchor to an arbitrary rect instead of the trigger - any `{ getBoundingClientRect() }`, which an element also satisfies.
34
+ * Drive `open` yourself; pass a new object to re-place a moving anchor. */
35
+ anchor?: VirtualAnchor | null;
31
36
 
32
37
  /** Preferred side; flips to the opposite side when cramped. Default 'bottom'. */
33
38
  side?: 'top' | 'bottom' | 'left' | 'right';
@@ -56,6 +61,7 @@ function PopoverPanel({
56
61
  arrow,
57
62
  dismissible,
58
63
  requestClose,
64
+ anchor,
59
65
  triggerRef,
60
66
  htmlProps,
61
67
  animate,
@@ -68,6 +74,7 @@ function PopoverPanel({
68
74
  arrow: boolean;
69
75
  dismissible: boolean;
70
76
  requestClose: () => void;
77
+ anchor?: VirtualAnchor | null;
71
78
  triggerRef: RefObject<HTMLElement>;
72
79
  htmlProps?: HTMLAttributes<HTMLDivElement> & DataAttributes;
73
80
  children: ReactNode;
@@ -76,7 +83,7 @@ function PopoverPanel({
76
83
  const entry = useOverlayEntry({ nodeRef: panelRef, dismissible, requestClose });
77
84
  useMotion(panelRef, { animate, exit });
78
85
  useReturnFocus(panelRef);
79
- useAnchorPosition({ side, align, arrow, triggerRef, panelRef });
86
+ useAnchorPosition({ side, align, arrow, anchor, triggerRef, panelRef });
80
87
  useOutsidePress({ entry, refs: [triggerRef, panelRef], enabled: dismissible, onPress: requestClose });
81
88
  return (
82
89
  <div
@@ -96,6 +103,7 @@ export function Popover({
96
103
  defaultOpen = false,
97
104
  onOpenChange,
98
105
  trigger = null,
106
+ anchor = null,
99
107
  side = 'bottom',
100
108
  align = 'start',
101
109
  arrow = false,
@@ -128,6 +136,7 @@ export function Popover({
128
136
  arrow={arrow}
129
137
  dismissible={dismissible}
130
138
  requestClose={close}
139
+ anchor={anchor}
131
140
  triggerRef={triggerRef}
132
141
  htmlProps={htmlProps}
133
142
  >
@@ -51,24 +51,20 @@ export function SelectTrigger({
51
51
  ...htmlProps,
52
52
  }}
53
53
  onClick={() => (open ? requestClose() : show())}
54
- iconRight={
55
- <span className="select__caret">
56
- <Icon name="caret-down" size="sm" />
57
- </span>
58
- }
59
- iconLeft={
60
- leading && (
61
- <span className="select__leading">
62
- <IconSlot size="sm">{leading}</IconSlot>
63
- </span>
64
- )
65
- }
66
54
  {...rest}
67
55
  >
56
+ {leading && (
57
+ <span className="select__leading">
58
+ <IconSlot size="sm">{leading}</IconSlot>
59
+ </span>
60
+ )}
68
61
  <span className="select__value" data-placeholder={isPlaceholder ? 'true' : undefined}>
69
62
  {text}
70
63
  </span>
71
64
  {count != null && count > 0 && <span className="select__count">+{count}</span>}
65
+ <span className="select__caret">
66
+ <Icon name="caret-down" size="sm" />
67
+ </span>
72
68
  </Button>
73
69
  );
74
70
  }
@@ -35,6 +35,10 @@ export interface SheetProps {
35
35
  /** Scrim/Esc dismissal + drag-to-edge. Default true. */
36
36
  dismissible?: boolean;
37
37
 
38
+ /** Dock inside this element instead of the viewport - it must be positioned (`position: relative` or similar).
39
+ * Scrim, scroll lock and inert scope to it; the rest of the page stays interactive. */
40
+ container?: HTMLElement | null;
41
+
38
42
  /** Base id for the panel; drives the trigger's `aria-controls`. Auto-generated when omitted. */
39
43
  id?: string;
40
44
  /** Standard attributes (className, style, data-*, ...) forwarded to the sheet panel. */
@@ -54,6 +58,7 @@ function SheetShell({
54
58
  side,
55
59
  panelId,
56
60
  dismissible,
61
+ container,
57
62
  requestClose,
58
63
  htmlProps,
59
64
  animate,
@@ -63,6 +68,7 @@ function SheetShell({
63
68
  side: 'right' | 'bottom';
64
69
  panelId: string;
65
70
  dismissible: boolean;
71
+ container?: HTMLElement | null;
66
72
  requestClose: () => void;
67
73
  htmlProps?: HTMLAttributes<HTMLDivElement> & DataAttributes;
68
74
  children: ReactNode;
@@ -77,6 +83,7 @@ function SheetShell({
77
83
  panelClass={'overlay-panel--sheet-' + side}
78
84
  panelId={panelId}
79
85
  dismissible={dismissible}
86
+ container={container}
80
87
  requestClose={requestClose}
81
88
  panelRef={panelRef}
82
89
  panelProps={{ ...htmlProps, ...dragProps }}
@@ -93,6 +100,7 @@ export function Sheet({
93
100
  trigger = null,
94
101
  side = 'right',
95
102
  dismissible = true,
103
+ container = null,
96
104
  id,
97
105
  htmlProps,
98
106
  animation,
@@ -108,7 +116,7 @@ export function Sheet({
108
116
  return (
109
117
  <Fragment>
110
118
  {ovCloneTrigger(trigger, { open, onPress: () => setOpen(true), panelId, haspopup: 'dialog', triggerRef })}
111
- <OverlayPortal>
119
+ <OverlayPortal container={container}>
112
120
  <Presence>
113
121
  {open && (
114
122
  <SheetShell
@@ -122,6 +130,7 @@ export function Sheet({
122
130
  side={side}
123
131
  panelId={panelId}
124
132
  dismissible={dismissible}
133
+ container={container}
125
134
  requestClose={close}
126
135
  htmlProps={htmlProps}
127
136
  >
@@ -129,7 +129,7 @@ function ovCloneTrigger(
129
129
  } as Partial<typeof trigger.props>);
130
130
  }
131
131
 
132
- function OverlayPortal({ children }: { children: ReactNode }) {
132
+ function OverlayPortal({ container, children }: { container?: HTMLElement | null; children: ReactNode }) {
133
133
  const hostRef = useRef<HTMLDivElement | null>(null);
134
134
  if (typeof document !== 'undefined' && !hostRef.current) {
135
135
  hostRef.current = document.createElement('div');
@@ -138,9 +138,9 @@ function OverlayPortal({ children }: { children: ReactNode }) {
138
138
  useLayoutEffect(() => {
139
139
  const el = hostRef.current;
140
140
  if (!el) return;
141
- document.body.appendChild(el);
141
+ (container ?? document.body).appendChild(el);
142
142
  return () => el.remove();
143
- }, []);
143
+ }, [container]);
144
144
  if (!hostRef.current) return null;
145
145
  return createPortal(children, hostRef.current);
146
146
  }