panelui-web 0.6.0 → 0.8.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.
@@ -32,6 +32,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
32
32
  * and closes with its `open` prop, folding under the card, so a band can come
33
33
  * and go without the page jumping by more than its own height.
34
34
  *
35
+ * ## Modes
36
+ *
37
+ * A prompt that changes with what the app is doing names its states with
38
+ * `mode`, and each band says which of them it belongs to with its own
39
+ * `mode`. The bands that match open and the others fold, in one movement,
40
+ * so the card moves once, by the difference between them. A band's own
41
+ * `open` still wins over its mode.
42
+ *
35
43
  * ## Sending
36
44
  *
37
45
  * Enter sends and Shift+Enter starts a new line; Enter while a character is
@@ -120,8 +128,10 @@ const promptVariants = tv({
120
128
  submitRing: 'pointer-events-none absolute -inset-[3px] size-[calc(100%+6px)] text-primary transition-opacity duration-200',
121
129
  statusText: 'min-w-0 truncate bg-[linear-gradient(90deg,var(--muted-foreground)_35%,var(--foreground)_50%,var(--muted-foreground)_65%)] bg-[length:250%_100%] bg-clip-text text-transparent',
122
130
  statusSpinner: 'ms-auto size-4 animate-spin text-muted-foreground motion-reduce:animate-none',
131
+ // The strip folds like a band: its tuck under the card moves with it.
132
+ contextFold: 'mx-4 grid transition-[grid-template-rows,margin,opacity] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
123
133
  context: [
124
- 'relative mx-4 -mt-5 flex items-center gap-x-4 gap-y-1 rounded-b-[1rem] border bg-muted px-3.5 pb-2 pt-6.5 text-xs text-muted-foreground',
134
+ 'relative flex items-center gap-x-4 gap-y-1 rounded-b-[1rem] border bg-muted px-3.5 pb-2 pt-6.5 text-xs text-muted-foreground',
125
135
  'flex-wrap [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3.5',
126
136
  ],
127
137
  contextItem: 'inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap',
@@ -209,7 +219,7 @@ function attach(file) {
209
219
  preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
210
220
  };
211
221
  }
212
- function PromptRoot({ value: controlledValue, defaultValue = '', onValueChange, onSubmit, status = 'idle', onStop, disabled = false, defaultFiles, accept, maxFiles, onReject, className, children, ...props }) {
222
+ function PromptRoot({ value: controlledValue, defaultValue = '', onValueChange, onSubmit, status = 'idle', onStop, disabled = false, defaultFiles, accept, maxFiles, onReject, mode, className, children, ...props }) {
213
223
  const [internalValue, setInternalValue] = useState(defaultValue);
214
224
  const value = controlledValue ?? internalValue;
215
225
  const [attachments, setAttachments] = useState(() => (defaultFiles ?? []).map(attach));
@@ -274,6 +284,7 @@ function PromptRoot({ value: controlledValue, defaultValue = '', onValueChange,
274
284
  removeAttachment,
275
285
  openFileDialog,
276
286
  status,
287
+ mode,
277
288
  busy,
278
289
  disabled,
279
290
  empty,
@@ -287,12 +298,12 @@ function PromptRoot({ value: controlledValue, defaultValue = '', onValueChange,
287
298
  keyHandler,
288
299
  listbox,
289
300
  setListbox,
290
- }), [value, setValue, attachments, addFiles, removeAttachment, openFileDialog, status, busy, disabled, empty, submit, stop, onStop, dragging, fieldId, listbox]);
301
+ }), [value, setValue, attachments, addFiles, removeAttachment, openFileDialog, status, mode, busy, disabled, empty, submit, stop, onStop, dragging, fieldId, listbox]);
291
302
  const send = (event) => {
292
303
  event.preventDefault();
293
304
  submit();
294
305
  };
295
- return (_jsx(PromptStore.Provider, { value: context, children: _jsxs("form", { "data-slot": "prompt", "data-status": status, "aria-busy": busy || undefined, className: promptVariants().root({ className }), onSubmit: send, ...props, children: [children, _jsx("input", { ref: fileInput, type: "file", multiple: maxFiles !== 1, accept: accept, tabIndex: -1, "aria-hidden": "true", className: "hidden", onChange: (event) => {
306
+ return (_jsx(PromptStore.Provider, { value: context, children: _jsxs("form", { "data-slot": "prompt", "data-status": status, "data-mode": mode, "aria-busy": busy || undefined, className: promptVariants().root({ className }), onSubmit: send, ...props, children: [children, _jsx("input", { ref: fileInput, type: "file", multiple: maxFiles !== 1, accept: accept, tabIndex: -1, "aria-hidden": "true", className: "hidden", onChange: (event) => {
296
307
  if (event.target.files)
297
308
  addFiles(event.target.files);
298
309
  // Cleared, so choosing the same file again still counts as a change.
@@ -311,9 +322,22 @@ function Band({ edge, open, slot, className, children, ...props }) {
311
322
  }, [open]);
312
323
  return (_jsx("div", { "data-slot": slot, "data-open": open ? '' : undefined, className: slots.band(), children: _jsx("div", { ref: clip, className: slots.bandClip(), children: _jsx("div", { className: slots.bandInner({ className }), ...props, children: children }) }) }));
313
324
  }
325
+ /**
326
+ * Whether a band is open: its own `open` if given, else whether the
327
+ * prompt's mode is one of the band's, else what the band does by default.
328
+ */
329
+ function useBandOpen(part, open, mode, fallback) {
330
+ const prompt = usePrompt(part);
331
+ if (open !== undefined)
332
+ return open;
333
+ if (mode !== undefined)
334
+ return prompt.mode !== undefined && (Array.isArray(mode) ? mode : [mode]).includes(prompt.mode);
335
+ return fallback(prompt);
336
+ }
314
337
  /** A band above the field, for something to do before or while writing: connect an account, pick a project. */
315
- function PromptBanner({ open = true, ...props }) {
316
- return _jsx(Band, { edge: "top", open: open, slot: "prompt-banner", ...props });
338
+ function PromptBanner({ open, mode, ...props }) {
339
+ const shown = useBandOpen('Prompt.Banner', open, mode, () => true);
340
+ return _jsx(Band, { edge: "top", open: shown, slot: "prompt-banner", ...props });
317
341
  }
318
342
  PromptBanner.displayName = 'Prompt.Banner';
319
343
  /** The banner's button, at its end. */
@@ -340,17 +364,22 @@ function useShimmer(active) {
340
364
  return ref;
341
365
  }
342
366
  /** A band under the field saying what the answer is doing, with the words lit by a moving sweep. */
343
- function PromptStatus({ open, icon, spinner = true, children, ...props }) {
344
- const prompt = usePrompt('Prompt.Status');
345
- const shown = open ?? prompt.busy;
367
+ function PromptStatus({ open, mode, icon, spinner = true, children, ...props }) {
368
+ const shown = useBandOpen('Prompt.Status', open, mode, (prompt) => prompt.busy);
346
369
  const slots = promptVariants();
347
370
  const text = useShimmer(shown);
348
371
  return (_jsxs(Band, { edge: "bottom", open: shown, slot: "prompt-status", role: "status", ...props, children: [icon, _jsx("span", { ref: text, className: slots.statusText(), children: children }), spinner ? (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", className: slots.statusSpinner(), children: _jsx("path", { d: "M21 12a9 9 0 1 1-6.22-8.56" }) })) : null] }));
349
372
  }
350
373
  PromptStatus.displayName = 'Prompt.Status';
351
374
  /** A strip under the field, inset from its edges, for where the message is going: a branch, a folder, a ticket. */
352
- function PromptContext({ className, ...props }) {
353
- return _jsx("div", { "data-slot": "prompt-context", className: promptVariants().context({ className }), ...props });
375
+ function PromptContext({ open, mode, className, ...props }) {
376
+ const shown = useBandOpen('Prompt.Context', open, mode, () => true);
377
+ const slots = promptVariants();
378
+ const clip = useRef(null);
379
+ useEffect(() => {
380
+ clip.current?.toggleAttribute('inert', !shown);
381
+ }, [shown]);
382
+ return (_jsx("div", { "data-slot": "prompt-context", "data-open": shown ? '' : undefined, className: slots.contextFold({ className: shown ? '-mt-5 grid-rows-[1fr] opacity-100' : 'mt-0 grid-rows-[0fr] opacity-0' }), children: _jsx("div", { ref: clip, className: "min-h-0 overflow-hidden", children: _jsx("div", { className: slots.context({ className }), ...props }) }) }));
354
383
  }
355
384
  PromptContext.displayName = 'Prompt.Context';
356
385
  /** One fact in the context strip, with an icon before it. */
@@ -567,7 +596,8 @@ function ProgressRing({ done, total }) {
567
596
  * fills as they are done, opening into the list. The list scrolls past
568
597
  * `maxHeight`, and keeps the task in progress in view.
569
598
  */
570
- function PromptTasks({ open = true, title, expanded, defaultExpanded = false, onExpandedChange, maxHeight = '10rem', className, children, ...props }) {
599
+ function PromptTasks({ open, mode, title, expanded, defaultExpanded = false, onExpandedChange, maxHeight = '10rem', className, children, ...props }) {
600
+ const shown = useBandOpen('Prompt.Tasks', open, mode, () => true);
571
601
  const slots = promptVariants();
572
602
  const [inner, setInner] = useState(defaultExpanded);
573
603
  const isExpanded = expanded ?? inner;
@@ -593,7 +623,7 @@ function PromptTasks({ open = true, title, expanded, defaultExpanded = false, on
593
623
  setInner(!isExpanded);
594
624
  onExpandedChange?.(!isExpanded);
595
625
  };
596
- return (_jsxs(Band, { edge: "top", open: open, slot: "prompt-tasks", className: ['flex-col items-stretch gap-0 px-0 pt-0.5', className].filter(Boolean).join(' '), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isExpanded, "aria-controls": listId, onClick: toggle, className: slots.tasksHeader(), children: [_jsx(ProgressRing, { done: done, total: tasks.length }), _jsx("span", { className: slots.tasksTitle(), children: title }), _jsxs("span", { className: slots.tasksCount(), children: [done, "/", tasks.length] }), _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: slots.tasksChevron({ className: isExpanded ? 'rotate-180' : undefined }), children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("div", { className: slots.tasksBody({ className: isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' }), children: _jsx("div", { className: "min-h-0 overflow-hidden", children: _jsx("ol", { ref: list, id: listId, "aria-label": "Tasks", className: slots.tasksList(), style: { maxHeight }, children: children }) }) })] }));
626
+ return (_jsxs(Band, { edge: "top", open: shown, slot: "prompt-tasks", className: ['flex-col items-stretch gap-0 px-0 pt-0.5', className].filter(Boolean).join(' '), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isExpanded, "aria-controls": listId, onClick: toggle, className: slots.tasksHeader(), children: [_jsx(ProgressRing, { done: done, total: tasks.length }), _jsx("span", { className: slots.tasksTitle(), children: title }), _jsxs("span", { className: slots.tasksCount(), children: [done, "/", tasks.length] }), _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: slots.tasksChevron({ className: isExpanded ? 'rotate-180' : undefined }), children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("div", { className: slots.tasksBody({ className: isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' }), children: _jsx("div", { className: "min-h-0 overflow-hidden", children: _jsx("ol", { ref: list, id: listId, "aria-label": "Tasks", className: slots.tasksList(), style: { maxHeight }, children: children }) }) })] }));
597
627
  }
598
628
  PromptTasks.displayName = 'Prompt.Tasks';
599
629
  /** One task in `Prompt.Tasks`. */
@@ -161,6 +161,50 @@ declare function SheetClose({ render, icon, children, ...props }: SheetCloseProp
161
161
  declare namespace SheetClose {
162
162
  var displayName: string;
163
163
  }
164
+ export interface SheetSlidesProps extends Omit<ComponentProps<'div'>, 'onChange'> {
165
+ /**
166
+ * `true` makes it a deck: each slide fills the height, and a scroll
167
+ * glides to the next one. `false` makes it a numbered column of smaller
168
+ * slides that scrolls freely. Default `true`.
169
+ */
170
+ snap?: boolean;
171
+ /** The current slide, counted from 0, when the caller holds it. Setting it scrolls that slide into view. */
172
+ index?: number;
173
+ /** The slide to start on, when the caller does not hold it. Default `0`. */
174
+ defaultIndex?: number;
175
+ /**
176
+ * Called with the new index when the current slide changes: scrolled to
177
+ * in a deck, pressed in a column. In a column, passing it makes each
178
+ * slide a button.
179
+ */
180
+ onIndexChange?: (index: number) => void;
181
+ /** Show "3 / 8" under the current slide. Default: on in a deck. */
182
+ counter?: boolean;
183
+ /** The shape of a slide in a column, as a CSS aspect ratio. Default `16 / 9`. */
184
+ aspect?: string;
185
+ }
186
+ /**
187
+ * A column of rounded slides in place of `Sheet.Body`: a deck that moves one
188
+ * slide at a time, or with `snap={false}`, a numbered column to choose from.
189
+ */
190
+ declare function SheetSlides({ snap, index, defaultIndex, onIndexChange, counter, aspect, className, style, children, 'aria-label': label, ...props }: SheetSlidesProps): import("react").JSX.Element;
191
+ declare namespace SheetSlides {
192
+ var displayName: string;
193
+ }
194
+ export interface SheetSlideProps extends ComponentProps<'div'> {
195
+ /**
196
+ * What a screen reader calls it, after its place: "3 of 8, Harbour at
197
+ * dawn". An image's `alt` usually says it already.
198
+ */
199
+ label?: string;
200
+ /** Its shape in a column, overriding `Sheet.Slides`' `aspect`. */
201
+ aspect?: string;
202
+ }
203
+ /** One slide: a rounded frame that fills with an image, a video or anything drawn. */
204
+ declare function SheetSlide({ label, aspect, className, style, children, ...props }: SheetSlideProps): import("react").JSX.Element;
205
+ declare namespace SheetSlide {
206
+ var displayName: string;
207
+ }
164
208
  export interface SheetStageProps extends ComponentProps<'div'> {
165
209
  }
166
210
  /**
@@ -183,5 +227,7 @@ export declare const Sheet: typeof SheetRoot & {
183
227
  Footer: typeof SheetFooter;
184
228
  Close: typeof SheetClose;
185
229
  Stage: typeof SheetStage;
230
+ Slides: typeof SheetSlides;
231
+ Slide: typeof SheetSlide;
186
232
  };
187
- export { SheetTrigger, SheetNext, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, SheetClose, SheetStage, };
233
+ export { SheetTrigger, SheetNext, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, SheetClose, SheetStage, SheetSlides, SheetSlide, };
@@ -45,10 +45,20 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
45
45
  *
46
46
  * Every sheet follows a drag and leaves on a flick toward its own edge. The
47
47
  * grabber on a top or bottom sheet says so before anybody tries.
48
+ *
49
+ * ## Slides
50
+ *
51
+ * `Sheet.Slides` takes the place of the body with a column of rounded
52
+ * slides, for photos or a deck. By default it is a deck: each slide fills
53
+ * the sheet's height, a turn of the wheel or a swipe of the trackpad glides
54
+ * to the next slide, an arrow key moves one, and a counter under it says
55
+ * which. Without `snap` it is a numbered column of smaller slides that
56
+ * scrolls freely, the way a presentation's slide panel does, and pressing
57
+ * one chooses it.
48
58
  */
49
59
  import { Drawer } from '@base-ui/react/drawer';
50
60
  import { ScrollArea } from '@base-ui/react/scroll-area';
51
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
61
+ import { Children, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
52
62
  import { tv } from 'tailwind-variants';
53
63
  /*
54
64
  * One curve for every movement: the sheet arriving, the one behind it stepping
@@ -120,6 +130,32 @@ const sheetVariants = tv({
120
130
  scrollbar: 'my-3 me-1 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',
121
131
  thumb: 'relative flex-1 rounded-full bg-foreground/20',
122
132
  footer: 'flex shrink-0 flex-col gap-2 px-6 pt-3 pb-6',
133
+ /*
134
+ * Slides. As a deck the viewport is a size container, so a slide can be
135
+ * exactly its height less the padding, which is what lets one flick land
136
+ * on one slide. The minimum height is for a sheet that sizes itself to
137
+ * its content, where a deck would otherwise have no height to fill.
138
+ */
139
+ slides: 'relative flex min-h-0 flex-1 flex-col',
140
+ // Focus is shown on the current slide, not as a square round the scroller.
141
+ slidesViewport: 'group/slides min-h-0 flex-1 overscroll-contain outline-none',
142
+ slidesContent: 'flex flex-col gap-3 px-3',
143
+ // 20px is the sheet's 32 less the 12px it is set in by: one corner, two curves.
144
+ slide: [
145
+ 'relative shrink-0 overflow-hidden rounded-[20px] bg-muted',
146
+ '[&>img]:size-full [&>img]:object-cover [&>video]:size-full [&>video]:object-cover',
147
+ // A hairline over the picture, so a photo keeps an edge on a sheet of its own colour.
148
+ "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-foreground/[0.08] after:content-['']",
149
+ 'group-focus-visible/slides:data-current:ring-2 group-focus-visible/slides:data-current:ring-ring',
150
+ ],
151
+ slideRow: 'grid grid-cols-[1.25rem_minmax(0,1fr)] items-start gap-2.5',
152
+ slideNumber: 'pt-1 text-end text-xs font-medium tabular-nums text-muted-foreground',
153
+ slideButton: [
154
+ 'group/slide block w-full cursor-pointer rounded-[14px] text-start outline-none transition-shadow duration-150',
155
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover',
156
+ ],
157
+ // No blur behind it: a filter over a scrolling picture repaints on every frame.
158
+ counter: 'pointer-events-none absolute inset-x-0 bottom-5 mx-auto w-fit rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium tabular-nums text-white',
123
159
  /*
124
160
  * The page behind steps back by 3%, not more: enough to read as a layer
125
161
  * underneath, not so much that the page looks like it shrank. It keeps its
@@ -414,6 +450,217 @@ function SheetClose({ render, icon = false, children, ...props }) {
414
450
  return (_jsx(Drawer.Close, { render: render, ...props, children: content }));
415
451
  }
416
452
  SheetClose.displayName = 'Sheet.Close';
453
+ const SlidesContext = createContext(null);
454
+ /** The rounded frame of a slide: the slide itself in a deck, inside its row in a column. */
455
+ const frameOf = (slide) => slide.querySelector('[data-slide-frame]') ?? slide;
456
+ const SlideIndexContext = createContext(0);
457
+ function reduceMotion() {
458
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
459
+ }
460
+ /**
461
+ * A column of rounded slides in place of `Sheet.Body`: a deck that moves one
462
+ * slide at a time, or with `snap={false}`, a numbered column to choose from.
463
+ */
464
+ function SheetSlides({ snap = true, index, defaultIndex = 0, onIndexChange, counter, aspect = '16 / 9', className, style, children, 'aria-label': label = 'Slides', ...props }) {
465
+ const slots = sheetVariants();
466
+ const viewport = useRef(null);
467
+ const slides = Children.toArray(children);
468
+ const count = slides.length;
469
+ const [internal, setInternal] = useState(defaultIndex);
470
+ const current = Math.min(Math.max(index ?? internal, 0), Math.max(count - 1, 0));
471
+ const live = useRef({ current, onIndexChange, controlled: index !== undefined });
472
+ live.current = { current, onIndexChange, controlled: index !== undefined };
473
+ const slideAt = (at) => viewport.current?.querySelectorAll('[data-slot=sheet-slide]')[at] ?? null;
474
+ // Scrolls the slides alone. `scrollIntoView` would move every scroller
475
+ // above them too, the page included when a sheet is drawn inline.
476
+ const reveal = useCallback((at, smooth) => {
477
+ const root = viewport.current;
478
+ const slide = slideAt(at);
479
+ if (!root || !slide)
480
+ return;
481
+ const box = root.getBoundingClientRect();
482
+ const frame = frameOf(slide).getBoundingClientRect();
483
+ const above = frame.top - box.top;
484
+ const below = frame.bottom - box.bottom;
485
+ const by = snap ? above - (box.height - frame.height) / 2 : above < 0 ? above - 8 : below > 0 ? below + 8 : 0;
486
+ if (by === 0)
487
+ return;
488
+ const behavior = smooth && !reduceMotion() ? 'smooth' : 'auto';
489
+ if (!snap) {
490
+ root.scrollBy({ top: by, behavior });
491
+ return;
492
+ }
493
+ // A deck stops at every slide on the way, which is right for a hand
494
+ // and wrong for Home or End. Snapping is off for the length of a jump.
495
+ root.style.scrollSnapType = 'none';
496
+ root.scrollBy({ top: by, behavior });
497
+ let restored = false;
498
+ const restore = () => {
499
+ if (restored)
500
+ return;
501
+ restored = true;
502
+ root.style.scrollSnapType = '';
503
+ };
504
+ if (behavior === 'smooth' && 'onscrollend' in window) {
505
+ root.addEventListener('scrollend', restore, { once: true });
506
+ window.setTimeout(restore, 1200);
507
+ }
508
+ else {
509
+ requestAnimationFrame(restore);
510
+ }
511
+ }, [snap]);
512
+ // The slide last brought into view. One that became current by being
513
+ // scrolled to is already in view, and must not be scrolled to again: that
514
+ // would pull a deck back to it in the middle of a longer scroll.
515
+ const shown = useRef(null);
516
+ const settle = useCallback((next) => {
517
+ shown.current = next;
518
+ if (next === live.current.current)
519
+ return;
520
+ if (!live.current.controlled)
521
+ setInternal(next);
522
+ live.current.onIndexChange?.(next);
523
+ }, []);
524
+ // A deck's current slide is the one most in view.
525
+ useEffect(() => {
526
+ const root = viewport.current;
527
+ if (!snap || !root || typeof IntersectionObserver === 'undefined')
528
+ return;
529
+ const observer = new IntersectionObserver((entries) => {
530
+ for (const entry of entries) {
531
+ if (entry.isIntersecting)
532
+ settle(Number(entry.target.dataset.index));
533
+ }
534
+ }, { root, threshold: 0.6 });
535
+ root.querySelectorAll('[data-slot=sheet-slide]').forEach((slide) => observer.observe(slide));
536
+ return () => observer.disconnect();
537
+ }, [snap, count, settle]);
538
+ // Opens on the current slide, and follows `index` when it is set from outside.
539
+ useEffect(() => {
540
+ const first = shown.current === null;
541
+ if (!first && shown.current === current)
542
+ return;
543
+ shown.current = current;
544
+ if (first && current === 0)
545
+ return;
546
+ const box = viewport.current?.getBoundingClientRect();
547
+ const slide = slideAt(current);
548
+ const frame = slide ? frameOf(slide).getBoundingClientRect() : undefined;
549
+ const visible = box && frame && frame.top >= box.top - 1 && frame.bottom <= box.bottom + 1;
550
+ // A deck that scrolled there by itself is already on it.
551
+ if (!visible || (first && snap))
552
+ reveal(current, !first);
553
+ }, [current, reveal, snap]);
554
+ const go = (next) => {
555
+ const at = Math.min(Math.max(next, 0), count - 1);
556
+ if (snap)
557
+ reveal(at, true);
558
+ else {
559
+ settle(at);
560
+ reveal(at, true);
561
+ slideAt(at)?.querySelector('button')?.focus();
562
+ }
563
+ };
564
+ const goNow = useRef(go);
565
+ goNow.current = go;
566
+ /*
567
+ * A wheel or a trackpad turns a deck one slide per gesture. Left to the
568
+ * browser, a slow scroll drifts part of the way, eases off and springs
569
+ * back, and a fast one runs past several slides. Here a deliberate scroll
570
+ * glides to the next slide, and the rest of the same gesture, the
571
+ * trackpad's momentum included, is absorbed rather than fought. A touch
572
+ * screen keeps the browser's own snapping, which follows the finger.
573
+ */
574
+ useEffect(() => {
575
+ const root = viewport.current;
576
+ if (!snap || !root)
577
+ return;
578
+ let pending = 0;
579
+ let lastWheel = 0;
580
+ let lockedUntil = 0;
581
+ const onWheel = (event) => {
582
+ // Sideways scrolling and pinch-zoom are not turns.
583
+ if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))
584
+ return;
585
+ event.preventDefault();
586
+ const now = performance.now();
587
+ const gap = now - lastWheel;
588
+ lastWheel = now;
589
+ if (now < lockedUntil) {
590
+ // Still the same gesture: keep it from turning a second slide.
591
+ lockedUntil = Math.max(lockedUntil, now + 140);
592
+ return;
593
+ }
594
+ if (gap > 200)
595
+ pending = 0;
596
+ pending += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
597
+ if (Math.abs(pending) < 24)
598
+ return;
599
+ const step = Math.sign(pending);
600
+ pending = 0;
601
+ lockedUntil = now + 500;
602
+ goNow.current(live.current.current + step);
603
+ };
604
+ root.addEventListener('wheel', onWheel, { passive: false });
605
+ return () => root.removeEventListener('wheel', onWheel);
606
+ }, [snap]);
607
+ const keys = (event) => {
608
+ const step = event.key === 'ArrowDown' || event.key === 'PageDown' || (event.key === ' ' && !event.shiftKey)
609
+ ? 1
610
+ : event.key === 'ArrowUp' || event.key === 'PageUp' || (event.key === ' ' && event.shiftKey)
611
+ ? -1
612
+ : 0;
613
+ if (step !== 0) {
614
+ event.preventDefault();
615
+ go(current + step);
616
+ }
617
+ else if (event.key === 'Home' || event.key === 'End') {
618
+ event.preventDefault();
619
+ go(event.key === 'Home' ? 0 : count - 1);
620
+ }
621
+ };
622
+ const context = useMemo(() => ({ snap, count, current, choose: settle, pressable: !snap && onIndexChange !== undefined }), [snap, count, current, settle, onIndexChange]);
623
+ return (_jsx(SlidesContext.Provider, { value: context, children: _jsxs(ScrollArea.Root, { "data-slot": "sheet-slides", "data-snap": snap ? '' : undefined, className: slots.slides({ className }), style: { ...style, ['--slide-aspect']: aspect }, ...props, children: [_jsx(ScrollArea.Viewport, { ref: viewport, tabIndex: 0, "aria-roledescription": "carousel", "aria-label": label, onKeyDown: keys, className: slots.slidesViewport({
624
+ className: snap
625
+ ? 'min-h-[min(60dvh,32rem)] snap-y snap-mandatory [container-type:size]'
626
+ : undefined,
627
+ }), children: _jsx(ScrollArea.Content, { className: slots.slidesContent({ className: snap ? 'py-1.5' : 'gap-2 pb-3 pt-1' }), children: slides.map((slide, at) => (_jsx(SlideIndexContext.Provider, { value: at, children: slide }, at))) }) }), _jsx(ScrollArea.Scrollbar, { orientation: "vertical", className: slots.scrollbar(), children: _jsx(ScrollArea.Thumb, { className: slots.thumb() }) }), (counter ?? snap) && count > 1 ? (_jsxs("div", { "data-slot": "sheet-slides-counter", "aria-hidden": "true", className: slots.counter(), children: [current + 1, " / ", count] })) : null] }) }));
628
+ }
629
+ SheetSlides.displayName = 'Sheet.Slides';
630
+ /** One slide: a rounded frame that fills with an image, a video or anything drawn. */
631
+ function SheetSlide({ label, aspect, className, style, children, ...props }) {
632
+ const slides = useContext(SlidesContext);
633
+ if (!slides)
634
+ throw new Error('Sheet.Slide must be used inside <Sheet.Slides>.');
635
+ const at = useContext(SlideIndexContext);
636
+ const slots = sheetVariants();
637
+ const place = `${at + 1} of ${slides.count}`;
638
+ const selected = at === slides.current;
639
+ const name = label ? `${place}, ${label}` : place;
640
+ const frameClass = slots.slide({
641
+ className: [
642
+ slides.snap
643
+ ? 'h-[calc(100cqh-0.75rem)] snap-center'
644
+ : // In a column a slide sits on the sheet's own colour, so it takes the
645
+ // theme's border as a real edge, and a shadow to stand off the sheet.
646
+ 'aspect-(--slide-aspect) rounded-[14px] shadow-xs ring-1 ring-border transition-shadow duration-150 after:hidden',
647
+ !slides.snap && !selected && slides.pressable && 'group-hover/slide:ring-foreground/25',
648
+ // Chosen in a column: a ring in the info colour, the theme's colour for
649
+ // something switched on, clear of the slide's edge.
650
+ !slides.snap && selected && 'shadow-none ring-2 ring-[var(--info)] ring-offset-2 ring-offset-popover',
651
+ className,
652
+ ],
653
+ });
654
+ const frameStyle = aspect ? { ...style, aspectRatio: aspect } : style;
655
+ // In a deck the slide is the frame, so the observer that finds the current
656
+ // one has a box to watch.
657
+ if (slides.snap) {
658
+ return (_jsx("div", { "data-slot": "sheet-slide", "data-slide-frame": "", "data-index": at, "data-current": selected ? '' : undefined, role: "group", "aria-roledescription": "slide", "aria-label": name, className: frameClass, style: frameStyle, ...props, children: children }));
659
+ }
660
+ const frame = (_jsx("div", { "data-slide-frame": "", "data-current": selected ? '' : undefined, className: frameClass, style: frameStyle, children: children }));
661
+ return (_jsxs("div", { "data-slot": "sheet-slide", "data-index": at, "data-selected": selected ? '' : undefined, className: slots.slideRow(), ...props, children: [_jsx("span", { "aria-hidden": "true", className: slots.slideNumber({ className: selected ? 'text-[var(--info)]' : undefined }), children: at + 1 }), slides.pressable ? (_jsx("button", { type: "button", "aria-label": label ? name : `Slide ${place}`, "aria-current": selected ? 'true' : undefined, className: slots.slideButton(), onClick: () => slides.choose(at), children: frame })) : (_jsx("div", { role: "group", "aria-roledescription": "slide", "aria-label": name, children: frame }))] }));
662
+ }
663
+ SheetSlide.displayName = 'Sheet.Slide';
417
664
  /**
418
665
  * Wrap the app — or the part of the page a sheet belongs to — in this, and
419
666
  * the page steps back slightly while a sheet is open. Sheets opened anywhere
@@ -435,5 +682,7 @@ export const Sheet = Object.assign(SheetRoot, {
435
682
  Footer: SheetFooter,
436
683
  Close: SheetClose,
437
684
  Stage: SheetStage,
685
+ Slides: SheetSlides,
686
+ Slide: SheetSlide,
438
687
  });
439
- export { SheetTrigger, SheetNext, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, SheetClose, SheetStage, };
688
+ export { SheetTrigger, SheetNext, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, SheetClose, SheetStage, SheetSlides, SheetSlide, };