@gperezmz/bb-plugin-thread-glance 0.1.2

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/THIRD_PARTY_NOTICES.md +230 -0
  4. package/app.tsx +12 -0
  5. package/components/ui/alert-dialog.tsx +142 -0
  6. package/components/ui/button.tsx +59 -0
  7. package/components/ui/card.tsx +83 -0
  8. package/components/ui/coarse-pointer-sizing.ts +66 -0
  9. package/components/ui/context-menu.tsx +355 -0
  10. package/components/ui/dialog.tsx +416 -0
  11. package/components/ui/dropdown-menu.tsx +640 -0
  12. package/components/ui/hooks/use-compact-viewport.tsx +37 -0
  13. package/components/ui/hooks/use-media-query.ts +70 -0
  14. package/components/ui/hooks/use-pointer-coarse.ts +7 -0
  15. package/components/ui/hover-card.tsx +36 -0
  16. package/components/ui/icon-extended.tsx +333 -0
  17. package/components/ui/icon-registry.ts +189 -0
  18. package/components/ui/icon.tsx +444 -0
  19. package/components/ui/input.tsx +30 -0
  20. package/components/ui/menu-item-hover.tsx +93 -0
  21. package/components/ui/motion.ts +4 -0
  22. package/components/ui/overlay-trigger.ts +84 -0
  23. package/components/ui/popover.tsx +223 -0
  24. package/components/ui/responsive-overlay.tsx +823 -0
  25. package/components/ui/tooltip.tsx +63 -0
  26. package/dist/app.css +2 -0
  27. package/dist/app.js +66 -0
  28. package/dist/app.meta.json +11 -0
  29. package/dist/server.js +71 -0
  30. package/dist/server.js.map +6 -0
  31. package/dist/server.meta.json +11 -0
  32. package/features/thread-list/components/Dialogs.tsx +299 -0
  33. package/features/thread-list/components/FoldRows.tsx +147 -0
  34. package/features/thread-list/components/GroupSection.tsx +370 -0
  35. package/features/thread-list/components/ProviderBadge.tsx +57 -0
  36. package/features/thread-list/components/PullRequestBadge.tsx +50 -0
  37. package/features/thread-list/components/Rails.tsx +28 -0
  38. package/features/thread-list/components/RenameEditor.tsx +118 -0
  39. package/features/thread-list/components/RowMenu.tsx +168 -0
  40. package/features/thread-list/components/SplitMiniMap.tsx +55 -0
  41. package/features/thread-list/components/ThreadDetails.tsx +138 -0
  42. package/features/thread-list/components/ThreadList.tsx +855 -0
  43. package/features/thread-list/components/ThreadRowView.tsx +631 -0
  44. package/features/thread-list/components/Toolbar.tsx +296 -0
  45. package/features/thread-list/components/controller.ts +64 -0
  46. package/features/thread-list/components/glyphs.tsx +129 -0
  47. package/features/thread-list/components/row-card.ts +127 -0
  48. package/features/thread-list/data/storage.ts +18 -0
  49. package/features/thread-list/data/useAutoExpand.ts +53 -0
  50. package/features/thread-list/data/useClientPreferences.ts +20 -0
  51. package/features/thread-list/data/useNotes.ts +41 -0
  52. package/features/thread-list/data/useNow.ts +19 -0
  53. package/features/thread-list/data/usePreferences.ts +126 -0
  54. package/features/thread-list/data/useScheduled.ts +43 -0
  55. package/features/thread-list/data/useStamps.ts +68 -0
  56. package/features/thread-list/icons.ts +44 -0
  57. package/features/thread-list/index.ts +2 -0
  58. package/features/thread-list/model/attention.ts +89 -0
  59. package/features/thread-list/model/counters.ts +67 -0
  60. package/features/thread-list/model/details.ts +79 -0
  61. package/features/thread-list/model/drag.ts +104 -0
  62. package/features/thread-list/model/expansion.ts +71 -0
  63. package/features/thread-list/model/families.ts +297 -0
  64. package/features/thread-list/model/groups.ts +263 -0
  65. package/features/thread-list/model/labels.ts +50 -0
  66. package/features/thread-list/model/layout.ts +61 -0
  67. package/features/thread-list/model/menu.ts +78 -0
  68. package/features/thread-list/model/move.ts +46 -0
  69. package/features/thread-list/model/notes.ts +52 -0
  70. package/features/thread-list/model/provider-mark.ts +59 -0
  71. package/features/thread-list/model/share.ts +101 -0
  72. package/features/thread-list/model/sort.ts +78 -0
  73. package/features/thread-list/model/state.ts +330 -0
  74. package/features/thread-list/model/time.ts +107 -0
  75. package/features/thread-list/model/toggles.ts +99 -0
  76. package/features/thread-list/model/view.ts +669 -0
  77. package/features/thread-list/model/windowing.ts +27 -0
  78. package/features/thread-list/testing/fixtures.ts +162 -0
  79. package/hooks/useBrowserDimmingModal.ts +5 -0
  80. package/lib/portal-scope.ts +15 -0
  81. package/lib/utils.ts +17 -0
  82. package/package.json +96 -0
  83. package/server/cli.ts +107 -0
  84. package/server/failures.ts +136 -0
  85. package/server/import.ts +144 -0
  86. package/server/notes.ts +236 -0
  87. package/server/preference-store.ts +96 -0
  88. package/server/scheduled.ts +129 -0
  89. package/server/serial.ts +12 -0
  90. package/server/stamps.ts +142 -0
  91. package/server/startup.ts +121 -0
  92. package/server.ts +126 -0
  93. package/shared/contract.ts +73 -0
  94. package/shared/preferences.ts +241 -0
  95. package/shared/signals.ts +71 -0
  96. package/skills/thread-glance/SKILL.md +69 -0
  97. package/tsconfig.json +36 -0
  98. package/types/global-jsx.d.ts +10 -0
@@ -0,0 +1,823 @@
1
+ import * as React from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+
5
+ import {
6
+ blurActiveKeyboardInputBeforeOverlayOpen,
7
+ blurActiveKeyboardInputBeforeOverlayClose,
8
+ blurActiveKeyboardInputWithin,
9
+ getOverlayTriggerClassName,
10
+ preventOverlayTriggerSelection,
11
+ } from "./overlay-trigger.js";
12
+ import { useIsCompactViewport } from "./hooks/use-compact-viewport.js";
13
+ import { usePointerCoarse } from "./hooks/use-pointer-coarse.js";
14
+ import { usePortalScopeProps } from "../../lib/portal-scope.js";
15
+ import { cn } from "../../lib/utils.js";
16
+
17
+ export interface ResponsiveOverlayContextValue {
18
+ isCompactViewport: boolean;
19
+ open: boolean;
20
+ onOpenChange: (open: boolean) => void;
21
+ }
22
+
23
+ const RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS = 120;
24
+
25
+ export const COMPACT_SHEET_CONTENT_STYLE: React.CSSProperties = {
26
+ width: "auto",
27
+ minWidth: "auto",
28
+ maxWidth: "none",
29
+ };
30
+
31
+ const DRAWER_KEYBOARD_INSET_PROPERTY = "--bb-drawer-keyboard-inset";
32
+ const DRAWER_KEYBOARD_MIN_OVERLAP_PX = 80;
33
+
34
+ function resetDrawerKeyboardStyles(drawerElement: HTMLElement | null): void {
35
+ if (drawerElement === null) return;
36
+
37
+ drawerElement.style.height = "";
38
+ drawerElement.style.bottom = "";
39
+ drawerElement.style.removeProperty(DRAWER_KEYBOARD_INSET_PROPERTY);
40
+ }
41
+
42
+ export function measureDrawerKeyboardOverlap({
43
+ layoutViewportHeight,
44
+ visualViewportHeight,
45
+ visualViewportOffsetTop,
46
+ }: {
47
+ layoutViewportHeight: number;
48
+ visualViewportHeight: number;
49
+ visualViewportOffsetTop: number;
50
+ }): number {
51
+ const overlap = Math.round(
52
+ layoutViewportHeight - (visualViewportHeight + visualViewportOffsetTop),
53
+ );
54
+ return overlap >= DRAWER_KEYBOARD_MIN_OVERLAP_PX ? overlap : 0;
55
+ }
56
+
57
+ function useDrawerKeyboardInset(
58
+ panelRef: React.RefObject<HTMLElement | null>,
59
+ open: boolean,
60
+ ): void {
61
+ React.useEffect(() => {
62
+ const panel = panelRef.current;
63
+ const visualViewport = window.visualViewport;
64
+ if (panel === null || !open || !visualViewport) return;
65
+
66
+ let animationFrame: number | null = null;
67
+ const applyInset = () => {
68
+ animationFrame = null;
69
+ if (visualViewport.scale !== 1) {
70
+ resetDrawerKeyboardStyles(panel);
71
+ return;
72
+ }
73
+ const overlap = measureDrawerKeyboardOverlap({
74
+ layoutViewportHeight: panel.ownerDocument.documentElement.clientHeight,
75
+ visualViewportHeight: visualViewport.height,
76
+ visualViewportOffsetTop: visualViewport.offsetTop,
77
+ });
78
+ if (overlap === 0) {
79
+ panel.style.bottom = "";
80
+ panel.style.removeProperty(DRAWER_KEYBOARD_INSET_PROPERTY);
81
+ return;
82
+ }
83
+ panel.style.bottom = `${overlap}px`;
84
+ panel.style.setProperty(DRAWER_KEYBOARD_INSET_PROPERTY, `${overlap}px`);
85
+ };
86
+ const scheduleUpdate = () => {
87
+ if (animationFrame !== null) {
88
+ window.cancelAnimationFrame(animationFrame);
89
+ }
90
+ animationFrame = window.requestAnimationFrame(applyInset);
91
+ };
92
+
93
+ applyInset();
94
+ visualViewport.addEventListener("resize", scheduleUpdate);
95
+ visualViewport.addEventListener("scroll", scheduleUpdate);
96
+
97
+ return () => {
98
+ visualViewport.removeEventListener("resize", scheduleUpdate);
99
+ visualViewport.removeEventListener("scroll", scheduleUpdate);
100
+ if (animationFrame !== null) {
101
+ window.cancelAnimationFrame(animationFrame);
102
+ }
103
+ resetDrawerKeyboardStyles(panel);
104
+ };
105
+ }, [open, panelRef]);
106
+ }
107
+
108
+ export function useResponsiveOverlayBehavior() {
109
+ const presentation = useIsCompactViewport() ? "drawer" : "floating";
110
+ const isPointerCoarse = usePointerCoarse();
111
+
112
+ return {
113
+ presentation,
114
+ supportsHover: presentation === "floating" && !isPointerCoarse,
115
+ };
116
+ }
117
+
118
+ export function useResponsiveRoot(
119
+ controlledOpen: boolean | undefined,
120
+ controlledOnChange: ((open: boolean) => void) | undefined,
121
+ defaultOpen: boolean = false,
122
+ ): ResponsiveOverlayContextValue {
123
+ const { presentation } = useResponsiveOverlayBehavior();
124
+ const isCompactViewport = presentation === "drawer";
125
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
126
+ const isControlled = controlledOpen !== undefined;
127
+ const open = isControlled ? controlledOpen : internalOpen;
128
+
129
+ const onOpenChange = React.useCallback(
130
+ (next: boolean) => {
131
+ if (open && !next && isCompactViewport) {
132
+ blurActiveKeyboardInputBeforeOverlayClose();
133
+ }
134
+ if (!isControlled) {
135
+ setInternalOpen(next);
136
+ }
137
+ controlledOnChange?.(next);
138
+ },
139
+ [isCompactViewport, isControlled, controlledOnChange, open],
140
+ );
141
+
142
+ return React.useMemo(
143
+ () => ({ isCompactViewport, open, onOpenChange }),
144
+ [isCompactViewport, open, onOpenChange],
145
+ );
146
+ }
147
+
148
+ interface MobileTriggerProps {
149
+ asChild?: boolean;
150
+ open: boolean;
151
+ onOpenChange: (open: boolean) => void;
152
+ haspopup: "menu" | "dialog";
153
+ children: React.ReactNode;
154
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
155
+ }
156
+
157
+ export const MobileTrigger = React.forwardRef<
158
+ HTMLButtonElement,
159
+ MobileTriggerProps &
160
+ Omit<
161
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
162
+ keyof MobileTriggerProps
163
+ >
164
+ >(
165
+ (
166
+ {
167
+ asChild,
168
+ open,
169
+ onOpenChange,
170
+ haspopup,
171
+ onClick,
172
+ children,
173
+ className,
174
+ ...domProps
175
+ },
176
+ ref,
177
+ ) => {
178
+ const triggerClassName = getOverlayTriggerClassName(className);
179
+ const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
180
+ onClick?.(e);
181
+ if (!e.defaultPrevented) {
182
+ if (!open) {
183
+ blurActiveKeyboardInputBeforeOverlayOpen();
184
+ }
185
+ onOpenChange(!open);
186
+ }
187
+ };
188
+
189
+ const ariaProps = {
190
+ "aria-expanded": open,
191
+ "aria-haspopup": haspopup,
192
+ "data-state": open ? "open" : "closed",
193
+ } as const;
194
+
195
+ if (asChild) {
196
+ return (
197
+ <Slot
198
+ ref={ref}
199
+ onClick={handleClick}
200
+ onMouseDown={preventOverlayTriggerSelection}
201
+ className={triggerClassName}
202
+ {...ariaProps}
203
+ {...domProps}
204
+ >
205
+ {children}
206
+ </Slot>
207
+ );
208
+ }
209
+
210
+ return (
211
+ <button
212
+ ref={ref}
213
+ type="button"
214
+ onClick={handleClick}
215
+ onMouseDown={preventOverlayTriggerSelection}
216
+ className={triggerClassName}
217
+ {...ariaProps}
218
+ {...domProps}
219
+ >
220
+ {children}
221
+ </button>
222
+ );
223
+ },
224
+ );
225
+ MobileTrigger.displayName = "MobileTrigger";
226
+
227
+ const RADIX_CONTENT_PROP_NAMES = [
228
+ "side",
229
+ "sideOffset",
230
+ "align",
231
+ "alignOffset",
232
+ "collisionPadding",
233
+ "collisionBoundary",
234
+ "arrowPadding",
235
+ "sticky",
236
+ "hideWhenDetached",
237
+ "avoidCollisions",
238
+ "onOpenAutoFocus",
239
+ "onCloseAutoFocus",
240
+ "onEscapeKeyDown",
241
+ "onPointerDownOutside",
242
+ "onFocusOutside",
243
+ "onInteractOutside",
244
+ ] as const;
245
+
246
+ type RadixContentPropName = (typeof RADIX_CONTENT_PROP_NAMES)[number];
247
+
248
+ const RADIX_CONTENT_KEYS: ReadonlySet<string> = new Set(
249
+ RADIX_CONTENT_PROP_NAMES,
250
+ );
251
+
252
+ export function stripRadixContentProps<T extends Record<string, unknown>>(
253
+ props: T,
254
+ ): Omit<T, RadixContentPropName> {
255
+ const result = {} as Record<string, unknown>;
256
+ for (const key of Object.keys(props)) {
257
+ if (!RADIX_CONTENT_KEYS.has(key)) {
258
+ result[key] = props[key];
259
+ }
260
+ }
261
+ return result as Omit<T, RadixContentPropName>;
262
+ }
263
+
264
+ interface ResponsiveDrawerShellProps {
265
+ open: boolean;
266
+ onOpenChange: (open: boolean) => void;
267
+ closeOnBackdropClick?: boolean;
268
+ onAfterCloseAutoFocus?: () => void;
269
+ srLabel?: string;
270
+ labelledBy?: string;
271
+ describedBy?: string;
272
+ contentClassName?: string;
273
+ onContentAnimationEnd?: (open: boolean) => void;
274
+ children: React.ReactNode;
275
+ }
276
+
277
+ export function useResponsiveDrawerRealization({
278
+ open,
279
+ enabled = true,
280
+ }: {
281
+ open: boolean;
282
+ enabled?: boolean;
283
+ }): { isContentRealized: boolean; realizeContent: () => void } {
284
+ const [isContentRealized, setIsContentRealized] = React.useState(false);
285
+ const realizeContent = React.useCallback(
286
+ () => setIsContentRealized(true),
287
+ [],
288
+ );
289
+
290
+ React.useEffect(() => {
291
+ if (!enabled || !open || isContentRealized) {
292
+ return;
293
+ }
294
+
295
+ let firstFrame: number | null = null;
296
+ let secondFrame: number | null = null;
297
+ firstFrame = window.requestAnimationFrame(() => {
298
+ firstFrame = null;
299
+ secondFrame = window.requestAnimationFrame(() => {
300
+ secondFrame = null;
301
+ realizeContent();
302
+ });
303
+ });
304
+ const fallback = window.setTimeout(
305
+ realizeContent,
306
+ RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS,
307
+ );
308
+
309
+ return () => {
310
+ if (firstFrame !== null) {
311
+ window.cancelAnimationFrame(firstFrame);
312
+ }
313
+ if (secondFrame !== null) {
314
+ window.cancelAnimationFrame(secondFrame);
315
+ }
316
+ window.clearTimeout(fallback);
317
+ };
318
+ }, [enabled, isContentRealized, open, realizeContent]);
319
+
320
+ return {
321
+ isContentRealized: enabled && isContentRealized,
322
+ realizeContent,
323
+ };
324
+ }
325
+
326
+ export function ResponsiveDrawerShell({
327
+ open,
328
+ onOpenChange,
329
+ onAfterCloseAutoFocus,
330
+ closeOnBackdropClick = true,
331
+ srLabel,
332
+ labelledBy,
333
+ describedBy,
334
+ contentClassName,
335
+ onContentAnimationEnd,
336
+ children,
337
+ }: ResponsiveDrawerShellProps) {
338
+ const { isContentRealized } = useResponsiveDrawerRealization({ open });
339
+
340
+ if (!open && !isContentRealized) {
341
+ return null;
342
+ }
343
+
344
+ return (
345
+ <PersistentResponsiveDrawerShell
346
+ open={open}
347
+ onOpenChange={onOpenChange}
348
+ onAfterCloseAutoFocus={onAfterCloseAutoFocus}
349
+ closeOnBackdropClick={closeOnBackdropClick}
350
+ srLabel={srLabel}
351
+ labelledBy={labelledBy}
352
+ describedBy={describedBy}
353
+ contentClassName={contentClassName}
354
+ onContentAnimationEnd={onContentAnimationEnd}
355
+ >
356
+ {isContentRealized ? (
357
+ children
358
+ ) : (
359
+ <div
360
+ aria-hidden="true"
361
+ className="min-h-32"
362
+ data-responsive-drawer-placeholder=""
363
+ />
364
+ )}
365
+ </PersistentResponsiveDrawerShell>
366
+ );
367
+ }
368
+
369
+ interface PersistentResponsiveDrawerShellProps {
370
+ open: boolean;
371
+ onOpenChange: (open: boolean) => void;
372
+ closeOnBackdropClick?: boolean;
373
+ onAfterCloseAutoFocus?: () => void;
374
+ srLabel?: string;
375
+ labelledBy?: string;
376
+ describedBy?: string;
377
+ contentClassName?: string;
378
+ motionDurationMs?: number;
379
+ onContentAnimationEnd?: (open: boolean) => void;
380
+ children: React.ReactNode;
381
+ }
382
+
383
+ const PERSISTENT_DRAWER_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
384
+ const PERSISTENT_DRAWER_CLOSE_RATIO = 0.25;
385
+ const PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC = 450;
386
+ const PERSISTENT_DRAWER_FOCUSABLE_SELECTOR = [
387
+ "a[href]",
388
+ "button:not([disabled])",
389
+ "input:not([disabled])",
390
+ "select:not([disabled])",
391
+ "textarea:not([disabled])",
392
+ '[tabindex]:not([tabindex="-1"])',
393
+ ].join(",");
394
+
395
+ type PersistentDrawerStackEntry = {
396
+ panel: () => HTMLElement | null;
397
+ requestClose: () => void;
398
+ };
399
+
400
+ type PersistentDrawerStack = {
401
+ entries: PersistentDrawerStackEntry[];
402
+ handleKeyDown: (event: KeyboardEvent) => void;
403
+ };
404
+
405
+ const persistentDrawerStacks = new WeakMap<Document, PersistentDrawerStack>();
406
+
407
+ function getDrawerFocusableElements(panel: HTMLElement): HTMLElement[] {
408
+ return Array.from(
409
+ panel.querySelectorAll<HTMLElement>(PERSISTENT_DRAWER_FOCUSABLE_SELECTOR),
410
+ ).filter(
411
+ (element) => element.closest('[aria-hidden="true"], [inert]') === null,
412
+ );
413
+ }
414
+
415
+ function activeElementIsInAnotherOverlay(
416
+ activeElement: Element | null,
417
+ panel: HTMLElement,
418
+ ): boolean {
419
+ const overlay = activeElement?.closest<HTMLElement>(
420
+ "[data-bb-portaled-overlay]",
421
+ );
422
+ return overlay !== null && overlay !== undefined && overlay !== panel;
423
+ }
424
+
425
+ function handleDrawerTab(event: KeyboardEvent, panel: HTMLElement): void {
426
+ const activeElement = panel.ownerDocument.activeElement;
427
+ if (
428
+ !panel.contains(activeElement) &&
429
+ activeElementIsInAnotherOverlay(activeElement, panel)
430
+ ) {
431
+ return;
432
+ }
433
+
434
+ const focusable = getDrawerFocusableElements(panel);
435
+ event.preventDefault();
436
+ if (focusable.length === 0) {
437
+ panel.focus({ preventScroll: true });
438
+ return;
439
+ }
440
+
441
+ const first = focusable[0];
442
+ const last = focusable[focusable.length - 1];
443
+ if (event.shiftKey) {
444
+ if (
445
+ !panel.contains(activeElement) ||
446
+ activeElement === panel ||
447
+ activeElement === first
448
+ ) {
449
+ last?.focus({ preventScroll: true });
450
+ return;
451
+ }
452
+ const index = focusable.indexOf(activeElement as HTMLElement);
453
+ focusable[Math.max(0, index - 1)]?.focus({ preventScroll: true });
454
+ return;
455
+ }
456
+
457
+ if (
458
+ !panel.contains(activeElement) ||
459
+ activeElement === panel ||
460
+ activeElement === last
461
+ ) {
462
+ first?.focus({ preventScroll: true });
463
+ return;
464
+ }
465
+ const index = focusable.indexOf(activeElement as HTMLElement);
466
+ focusable[Math.min(focusable.length - 1, index + 1)]?.focus({
467
+ preventScroll: true,
468
+ });
469
+ }
470
+
471
+ function registerOpenDrawer(
472
+ ownerDocument: Document,
473
+ entry: PersistentDrawerStackEntry,
474
+ ): () => void {
475
+ let stack = persistentDrawerStacks.get(ownerDocument);
476
+ if (stack === undefined) {
477
+ const entries: PersistentDrawerStackEntry[] = [];
478
+ const handleKeyDown = (event: KeyboardEvent) => {
479
+ if (event.defaultPrevented) {
480
+ return;
481
+ }
482
+ const topEntry = entries[entries.length - 1];
483
+ const panel = topEntry?.panel() ?? null;
484
+ if (topEntry === undefined || panel === null) {
485
+ return;
486
+ }
487
+ if (event.key === "Escape") {
488
+ event.preventDefault();
489
+ topEntry.requestClose();
490
+ } else if (event.key === "Tab") {
491
+ handleDrawerTab(event, panel);
492
+ }
493
+ };
494
+ stack = { entries, handleKeyDown };
495
+ persistentDrawerStacks.set(ownerDocument, stack);
496
+ ownerDocument.addEventListener("keydown", handleKeyDown);
497
+ }
498
+ stack.entries.push(entry);
499
+
500
+ return () => {
501
+ const currentStack = persistentDrawerStacks.get(ownerDocument);
502
+ if (currentStack === undefined) {
503
+ return;
504
+ }
505
+ const index = currentStack.entries.indexOf(entry);
506
+ if (index >= 0) {
507
+ currentStack.entries.splice(index, 1);
508
+ }
509
+ if (currentStack.entries.length === 0) {
510
+ ownerDocument.removeEventListener("keydown", currentStack.handleKeyDown);
511
+ persistentDrawerStacks.delete(ownerDocument);
512
+ }
513
+ };
514
+ }
515
+
516
+ interface UsePersistentOverlayFocusArgs {
517
+ onAfterCloseAutoFocus?: () => void;
518
+ onBeforeCloseAutoFocus?: () => void;
519
+ open: boolean;
520
+ panelRef: React.RefObject<HTMLElement | null>;
521
+ requestClose: () => void;
522
+ }
523
+
524
+ export function usePersistentOverlayFocus({
525
+ onAfterCloseAutoFocus,
526
+ onBeforeCloseAutoFocus,
527
+ open,
528
+ panelRef,
529
+ requestClose,
530
+ }: UsePersistentOverlayFocusArgs): void {
531
+ const returnFocusRef = React.useRef<HTMLElement | null>(null);
532
+
533
+ React.useLayoutEffect(() => {
534
+ if (!open) return;
535
+ const panel = panelRef.current;
536
+ if (panel === null) return;
537
+ const ownerDocument = panel.ownerDocument;
538
+ const previousFocus = ownerDocument.activeElement;
539
+ returnFocusRef.current =
540
+ previousFocus instanceof HTMLElement &&
541
+ previousFocus !== ownerDocument.body
542
+ ? previousFocus
543
+ : null;
544
+ const unregister = registerOpenDrawer(ownerDocument, {
545
+ panel: () => panelRef.current,
546
+ requestClose,
547
+ });
548
+ panel.focus({ preventScroll: true });
549
+ return unregister;
550
+ }, [open, panelRef, requestClose]);
551
+
552
+ const previousOpenRef = React.useRef(open);
553
+ React.useLayoutEffect(() => {
554
+ let cancelDeferredFocus: (() => void) | undefined;
555
+ if (previousOpenRef.current && !open) {
556
+ onBeforeCloseAutoFocus?.();
557
+ const returnFocus = returnFocusRef.current;
558
+ if (
559
+ returnFocus?.isConnected &&
560
+ returnFocus.closest('[aria-hidden="true"], [inert]') === null
561
+ ) {
562
+ returnFocus.focus({ preventScroll: true });
563
+ if (returnFocus.ownerDocument.activeElement !== returnFocus) {
564
+ const ownerWindow = returnFocus.ownerDocument.defaultView;
565
+ if (ownerWindow !== null) {
566
+ const frame = ownerWindow.requestAnimationFrame(() => {
567
+ if (
568
+ returnFocus.isConnected &&
569
+ returnFocus.closest('[aria-hidden="true"], [inert]') === null
570
+ ) {
571
+ returnFocus.focus({ preventScroll: true });
572
+ }
573
+ onAfterCloseAutoFocus?.();
574
+ });
575
+ cancelDeferredFocus = () => ownerWindow.cancelAnimationFrame(frame);
576
+ }
577
+ }
578
+ }
579
+ returnFocusRef.current = null;
580
+ if (cancelDeferredFocus === undefined) {
581
+ onAfterCloseAutoFocus?.();
582
+ }
583
+ }
584
+ previousOpenRef.current = open;
585
+ return cancelDeferredFocus;
586
+ }, [onAfterCloseAutoFocus, onBeforeCloseAutoFocus, open]);
587
+ }
588
+
589
+ type PersistentDrawerDrag = {
590
+ pointerId: number;
591
+ startY: number;
592
+ lastY: number;
593
+ lastTimeMs: number;
594
+ velocityY: number;
595
+ height: number;
596
+ };
597
+
598
+ export function PersistentResponsiveDrawerShell({
599
+ open,
600
+ onOpenChange,
601
+ onAfterCloseAutoFocus,
602
+ closeOnBackdropClick = true,
603
+ srLabel,
604
+ labelledBy,
605
+ describedBy,
606
+ contentClassName,
607
+ motionDurationMs = 220,
608
+ onContentAnimationEnd,
609
+ children,
610
+ }: PersistentResponsiveDrawerShellProps) {
611
+ const panelRef = React.useRef<HTMLDivElement>(null);
612
+ const backdropRef = React.useRef<HTMLDivElement>(null);
613
+ const dragRef = React.useRef<PersistentDrawerDrag | null>(null);
614
+ const settledStateRef = React.useRef<boolean | null>(null);
615
+ const labelId = React.useId();
616
+ const portalScopeProps = usePortalScopeProps();
617
+ const transition = `transform ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;
618
+ const backdropTransition = `opacity ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;
619
+ const onOpenChangeRef = React.useRef(onOpenChange);
620
+ React.useLayoutEffect(() => {
621
+ onOpenChangeRef.current = onOpenChange;
622
+ }, [onOpenChange]);
623
+ const requestClose = React.useCallback(() => {
624
+ blurActiveKeyboardInputWithin(panelRef.current);
625
+ resetDrawerKeyboardStyles(panelRef.current);
626
+ onOpenChangeRef.current(false);
627
+ }, []);
628
+ const prepareCloseAutoFocus = React.useCallback(() => {
629
+ blurActiveKeyboardInputWithin(panelRef.current);
630
+ resetDrawerKeyboardStyles(panelRef.current);
631
+ }, []);
632
+
633
+ usePersistentOverlayFocus({
634
+ onAfterCloseAutoFocus,
635
+ onBeforeCloseAutoFocus: prepareCloseAutoFocus,
636
+ open,
637
+ panelRef,
638
+ requestClose,
639
+ });
640
+
641
+ useDrawerKeyboardInset(panelRef, open);
642
+
643
+ const reportSettled = React.useCallback(
644
+ (settledOpen: boolean) => {
645
+ if (settledStateRef.current === settledOpen) {
646
+ return;
647
+ }
648
+ settledStateRef.current = settledOpen;
649
+ onContentAnimationEnd?.(settledOpen);
650
+ },
651
+ [onContentAnimationEnd],
652
+ );
653
+
654
+ React.useEffect(() => {
655
+ settledStateRef.current = null;
656
+ const timeout = window.setTimeout(
657
+ () => reportSettled(open),
658
+ motionDurationMs + 50,
659
+ );
660
+ return () => window.clearTimeout(timeout);
661
+ }, [motionDurationMs, open, reportSettled]);
662
+
663
+ const setDragPosition = React.useCallback(
664
+ (offsetY: number, height: number, animate: boolean) => {
665
+ const panel = panelRef.current;
666
+ const backdrop = backdropRef.current;
667
+ if (panel === null || backdrop === null) {
668
+ return;
669
+ }
670
+ panel.style.transition = animate ? transition : "none";
671
+ panel.style.transform = `translate3d(0, ${offsetY}px, 0)`;
672
+ backdrop.style.transition = animate ? backdropTransition : "none";
673
+ backdrop.style.opacity = String(
674
+ Math.max(0, Math.min(1, 1 - offsetY / height)),
675
+ );
676
+ },
677
+ [backdropTransition, transition],
678
+ );
679
+
680
+ const handleDragStart = React.useCallback(
681
+ (event: React.PointerEvent<HTMLDivElement>) => {
682
+ if (!open || event.button !== 0) {
683
+ return;
684
+ }
685
+ event.currentTarget.setPointerCapture(event.pointerId);
686
+ const nowMs = Date.now();
687
+ const height = Math.max(panelRef.current?.clientHeight ?? 0, 1);
688
+ dragRef.current = {
689
+ pointerId: event.pointerId,
690
+ startY: event.clientY,
691
+ lastY: event.clientY,
692
+ lastTimeMs: nowMs,
693
+ velocityY: 0,
694
+ height,
695
+ };
696
+ setDragPosition(0, height, false);
697
+ event.preventDefault();
698
+ },
699
+ [open, setDragPosition],
700
+ );
701
+
702
+ const handleDragMove = React.useCallback(
703
+ (event: React.PointerEvent<HTMLDivElement>) => {
704
+ const drag = dragRef.current;
705
+ if (drag === null || drag.pointerId !== event.pointerId) {
706
+ return;
707
+ }
708
+ const nowMs = Date.now();
709
+ const elapsedMs = nowMs - drag.lastTimeMs;
710
+ if (elapsedMs > 0) {
711
+ drag.velocityY = ((event.clientY - drag.lastY) / elapsedMs) * 1000;
712
+ drag.lastY = event.clientY;
713
+ drag.lastTimeMs = nowMs;
714
+ }
715
+ setDragPosition(
716
+ Math.max(0, event.clientY - drag.startY),
717
+ drag.height,
718
+ false,
719
+ );
720
+ event.preventDefault();
721
+ },
722
+ [setDragPosition],
723
+ );
724
+
725
+ const finishDrag = React.useCallback(
726
+ (event: React.PointerEvent<HTMLDivElement>, cancelled: boolean) => {
727
+ const drag = dragRef.current;
728
+ if (drag === null || drag.pointerId !== event.pointerId) {
729
+ return;
730
+ }
731
+ dragRef.current = null;
732
+ const offsetY = Math.max(0, event.clientY - drag.startY);
733
+ const shouldClose =
734
+ !cancelled &&
735
+ (offsetY >= drag.height * PERSISTENT_DRAWER_CLOSE_RATIO ||
736
+ drag.velocityY >= PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC);
737
+ if (shouldClose) {
738
+ setDragPosition(drag.height, drag.height, true);
739
+ requestClose();
740
+ } else {
741
+ setDragPosition(0, drag.height, true);
742
+ }
743
+ event.preventDefault();
744
+ },
745
+ [requestClose, setDragPosition],
746
+ );
747
+
748
+ const portalTarget = typeof document === "undefined" ? null : document.body;
749
+ if (portalTarget === null) {
750
+ return null;
751
+ }
752
+
753
+ return createPortal(
754
+ <>
755
+ <div
756
+ ref={backdropRef}
757
+ {...portalScopeProps}
758
+ aria-hidden="true"
759
+ data-persistent-drawer-backdrop=""
760
+ data-state={open ? "open" : "closed"}
761
+ className="fixed inset-0 z-50 bg-black/40"
762
+ style={{
763
+ opacity: open ? 1 : 0,
764
+ pointerEvents: open ? "auto" : "none",
765
+ transition: backdropTransition,
766
+ }}
767
+ onClick={closeOnBackdropClick ? requestClose : undefined}
768
+ onTouchMove={(event) => event.preventDefault()}
769
+ />
770
+ <div
771
+ ref={panelRef}
772
+ {...portalScopeProps}
773
+ aria-hidden={!open}
774
+ aria-labelledby={
775
+ labelledBy ?? (srLabel === undefined ? undefined : labelId)
776
+ }
777
+ aria-describedby={describedBy}
778
+ aria-modal={open || undefined}
779
+ data-bb-portaled-overlay=""
780
+ data-persistent-drawer-content=""
781
+ data-state={open ? "open" : "closed"}
782
+ inert={!open}
783
+ role="dialog"
784
+ tabIndex={-1}
785
+ className={cn(
786
+ "fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[calc(92dvh-var(--bb-drawer-keyboard-inset,0px))] flex-col rounded-t-xl border bg-background outline-none",
787
+ contentClassName,
788
+ )}
789
+ style={{
790
+ transform: open ? "translate3d(0, 0, 0)" : "translate3d(0, 100%, 0)",
791
+ transition,
792
+ willChange: open ? "transform" : undefined,
793
+ }}
794
+ onTransitionEnd={(event) => {
795
+ if (
796
+ event.currentTarget === event.target &&
797
+ event.propertyName === "transform"
798
+ ) {
799
+ reportSettled(open);
800
+ }
801
+ }}
802
+ >
803
+ <div
804
+ data-persistent-drawer-handle=""
805
+ className="mx-auto flex h-8 w-16 shrink-0 touch-none cursor-grab items-center justify-center active:cursor-grabbing"
806
+ onPointerDown={handleDragStart}
807
+ onPointerMove={handleDragMove}
808
+ onPointerUp={(event) => finishDrag(event, false)}
809
+ onPointerCancel={(event) => finishDrag(event, true)}
810
+ >
811
+ <div className="h-1 w-10 rounded-full bg-muted-foreground/20" />
812
+ </div>
813
+ {srLabel === undefined ? null : (
814
+ <h2 id={labelId} className="sr-only">
815
+ {srLabel}
816
+ </h2>
817
+ )}
818
+ {children}
819
+ </div>
820
+ </>,
821
+ portalTarget,
822
+ );
823
+ }