@sentry/junior-dashboard 0.260.0 → 0.262.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 (37) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +96 -74
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +7 -6
  6. package/src/client/components/BottomSheet.tsx +32 -0
  7. package/src/client/components/Button.tsx +2 -1
  8. package/src/client/components/Drawer.tsx +23 -109
  9. package/src/client/components/README.md +14 -4
  10. package/src/client/components/SegmentedTabs.tsx +23 -4
  11. package/src/client/components/Select.tsx +41 -7
  12. package/src/client/components/Sheet.LICENSE +21 -0
  13. package/src/client/components/Sheet.tsx +141 -0
  14. package/src/client/components/controls/DirectorySortSelect.tsx +6 -4
  15. package/src/client/conversations/{ConversationDetailsDrawer.tsx → ConversationDetails.tsx} +19 -38
  16. package/src/client/conversations/ConversationDetailsFrame.tsx +91 -0
  17. package/src/client/conversations/ConversationEventLog.tsx +0 -1
  18. package/src/client/conversations/ConversationHeader.tsx +20 -28
  19. package/src/client/conversations/ConversationHeaderActions.tsx +5 -1
  20. package/src/client/conversations/ConversationPage.tsx +49 -36
  21. package/src/client/conversations/ConversationWorkspace.tsx +9 -12
  22. package/src/client/conversations/ConversationWorkspaceLoading.tsx +1 -4
  23. package/src/client/conversations/ForkConversationButton.tsx +0 -1
  24. package/src/client/conversations/README.md +18 -2
  25. package/src/client/conversations/SubagentTranscriptDrawer.tsx +0 -1
  26. package/src/client/conversations/TelemetryMetrics.tsx +2 -2
  27. package/src/client/conversations/TranscriptSubagentView.tsx +1 -1
  28. package/src/client/conversations/TranscriptTurnContextView.tsx +0 -1
  29. package/src/client/debouncedValue.ts +14 -0
  30. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +0 -1
  31. package/src/client/pages/automations/AutomationFilters.tsx +4 -3
  32. package/src/client/pages/code/RepositoryTabs.tsx +3 -2
  33. package/src/client/pages/dev/ComponentsPage.tsx +20 -2
  34. package/src/client/pages/dev/SegmentedFixtures.tsx +8 -7
  35. package/src/client/pages/dev/SelectFixtures.tsx +62 -0
  36. package/src/client/pages/memory/MemoryDetailsDrawer.tsx +0 -1
  37. package/LICENSE +0 -201
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Sheet, SheetBody, SheetContent } from "./Sheet";
4
+
5
+ /** Show details in a Base UI sheet with a swipe handle and no header controls. */
6
+ export function BottomSheet(props: {
7
+ children: ReactNode;
8
+ label: string;
9
+ onClose(): void;
10
+ }) {
11
+ return (
12
+ <Sheet onClose={props.onClose} side="bottom">
13
+ <SheetContent label={props.label} side="bottom">
14
+ <div className="rounded-t-2xl px-4 pt-1 md:px-5">
15
+ <div
16
+ aria-hidden="true"
17
+ data-drawer-handle=""
18
+ className="flex touch-none cursor-grab justify-center py-3 active:cursor-grabbing"
19
+ >
20
+ <span className="h-1 w-9 rounded-full bg-dashboard-border-interactive" />
21
+ </div>
22
+ </div>
23
+ <SheetBody
24
+ data-base-ui-swipe-ignore=""
25
+ className="min-h-0 overflow-y-auto overscroll-contain"
26
+ >
27
+ {props.children}
28
+ </SheetBody>
29
+ </SheetContent>
30
+ </Sheet>
31
+ );
32
+ }
@@ -1,4 +1,4 @@
1
- import type { ButtonHTMLAttributes } from "react";
1
+ import type { ButtonHTMLAttributes, Ref } from "react";
2
2
  import { Link, type LinkProps } from "react-router";
3
3
 
4
4
  import { cn } from "../styles";
@@ -8,6 +8,7 @@ type ButtonTone = "default" | "danger" | "primary";
8
8
  type ToggleButtonVariant = "pill" | "segment" | "text";
9
9
 
10
10
  export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
11
+ ref?: Ref<HTMLButtonElement>;
11
12
  size?: ButtonSize;
12
13
  tone?: ButtonTone;
13
14
  };
@@ -1,118 +1,29 @@
1
1
  import { X } from "lucide-react";
2
- import { useEffect, useRef, type ReactNode } from "react";
2
+ import { useRef, type ReactNode } from "react";
3
3
 
4
- import {
5
- acquireBodyScrollLock,
6
- releaseBodyScrollLock,
7
- } from "../bodyScrollLock";
8
4
  import { cn } from "../styles";
9
5
  import { Button } from "./Button";
6
+ import { Sheet, SheetBody, SheetClose, SheetContent } from "./Sheet";
10
7
 
11
- const focusableSelector = [
12
- "a[href]",
13
- "button:not([disabled])",
14
- "input:not([disabled]):not([type='hidden'])",
15
- "select:not([disabled])",
16
- "textarea:not([disabled])",
17
- "summary",
18
- "[tabindex]:not([tabindex='-1'])",
19
- ].join(",");
20
-
21
- /** Render an accessible modal drawer and own its focus and scroll lifecycle. */
8
+ /** Render a side drawer with shared modal focus and dismissal behavior. */
22
9
  export function Drawer(props: {
23
10
  actions?: ReactNode;
24
11
  children: ReactNode;
25
12
  closeLabel: string;
26
- dismissLabel: string;
27
13
  header: ReactNode;
28
14
  onClose(): void;
29
15
  openKey: string;
30
16
  titleId: string;
31
17
  width?: "default" | "wide" | "narrow";
32
18
  }) {
33
- const dialogRef = useRef<HTMLDivElement>(null);
34
- const onCloseRef = useRef(props.onClose);
35
- const previousFocusRef = useRef<HTMLElement | undefined>(undefined);
36
- onCloseRef.current = props.onClose;
37
-
38
- useEffect(() => {
39
- previousFocusRef.current =
40
- document.activeElement instanceof HTMLElement
41
- ? document.activeElement
42
- : undefined;
43
- acquireBodyScrollLock();
44
- const focusFrame = requestAnimationFrame(() => {
45
- dialogRef.current
46
- ?.querySelector<HTMLElement>("[data-drawer-close]")
47
- ?.focus();
48
- });
49
- const onKeyDown = (event: KeyboardEvent) => {
50
- // An event detail drawer can open above a child conversation drawer.
51
- // Only the last mounted drawer owns Escape and the focus trap.
52
- const drawers = document.querySelectorAll("[data-dashboard-drawer]");
53
- if (drawers[drawers.length - 1] !== dialogRef.current) return;
54
- if (event.key === "Escape") {
55
- event.preventDefault();
56
- onCloseRef.current();
57
- return;
58
- }
59
- if (event.key !== "Tab") return;
60
-
61
- const dialog = dialogRef.current;
62
- const focusable = Array.from(
63
- dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? [],
64
- ).filter(
65
- (element) =>
66
- element.tabIndex >= 0 && element.getClientRects().length > 0,
67
- );
68
- const first = focusable.at(0);
69
- const last = focusable.at(-1);
70
- if (!dialog || !first || !last) {
71
- event.preventDefault();
72
- return;
73
- }
74
- const active = document.activeElement;
75
- if (event.shiftKey && (active === first || !dialog.contains(active))) {
76
- event.preventDefault();
77
- last.focus();
78
- } else if (
79
- !event.shiftKey &&
80
- (active === last || !dialog.contains(active))
81
- ) {
82
- event.preventDefault();
83
- first.focus();
84
- }
85
- };
86
- window.addEventListener("keydown", onKeyDown);
87
- return () => {
88
- cancelAnimationFrame(focusFrame);
89
- window.removeEventListener("keydown", onKeyDown);
90
- releaseBodyScrollLock();
91
- const previousFocus = previousFocusRef.current;
92
- previousFocusRef.current = undefined;
93
- if (previousFocus?.isConnected) previousFocus.focus();
94
- };
95
- }, [props.openKey]);
96
-
19
+ const closeRef = useRef<HTMLButtonElement>(null);
97
20
  return (
98
- <div
99
- aria-labelledby={props.titleId}
100
- aria-modal="true"
101
- data-dashboard-drawer=""
102
- className="fixed inset-0 z-50"
103
- ref={dialogRef}
104
- role="dialog"
105
- >
106
- <button
107
- aria-label={props.dismissLabel}
108
- className="absolute inset-0 cursor-default bg-dashboard-overlay"
109
- onClick={props.onClose}
110
- tabIndex={-1}
111
- type="button"
112
- />
113
- <aside
21
+ <Sheet key={props.openKey} onClose={props.onClose} side="right">
22
+ <SheetContent
23
+ initialFocusRef={closeRef}
24
+ titleId={props.titleId}
25
+ side="right"
114
26
  className={cn(
115
- "absolute top-0 right-0 grid h-full w-full grid-rows-[auto_minmax(0,1fr)] bg-dashboard-bg-elevated shadow-[-20px_0_60px_rgba(0,0,0,0.45)] md:border-l md:border-dashboard-border-strong",
116
27
  props.width === "wide"
117
28
  ? "md:w-[min(760px,94vw)]"
118
29
  : props.width === "narrow"
@@ -124,21 +35,24 @@ export function Drawer(props: {
124
35
  <div className="min-w-0">{props.header}</div>
125
36
  <div className="flex items-start gap-1.5">
126
37
  {props.actions}
127
- <Button
128
- aria-label={props.closeLabel}
129
- data-drawer-close
130
- onClick={props.onClose}
131
- size="icon"
132
- title="Close"
38
+ <SheetClose
39
+ render={
40
+ <Button
41
+ ref={closeRef}
42
+ aria-label={props.closeLabel}
43
+ size="icon"
44
+ title="Close"
45
+ />
46
+ }
133
47
  >
134
48
  <X aria-hidden="true" size={15} strokeWidth={2.25} />
135
- </Button>
49
+ </SheetClose>
136
50
  </div>
137
51
  </header>
138
- <div className="min-h-0 overflow-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-5">
52
+ <SheetBody className="min-h-0 overflow-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-5">
139
53
  {props.children}
140
- </div>
141
- </aside>
142
- </div>
54
+ </SheetBody>
55
+ </SheetContent>
56
+ </Sheet>
143
57
  );
144
58
  }
@@ -73,14 +73,21 @@ token so each theme can redefine it.
73
73
  and `SearchInput`. Use `TextInput size="comfortable"` for larger mobile
74
74
  controls. Use `TextArea prose` for instructions, not code. `Field error`
75
75
  renders an error at `<htmlFor>-error`. The owning control must set
76
- `aria-invalid` and `aria-describedby`. `Select` matches native text controls.
76
+ `aria-invalid` and `aria-describedby`. `Select` owns the native control and its
77
+ inset arrow. Use its `filter` and `sort` variants for compact controls.
77
78
  - **Status.** Use `StatusDot` for dense table or list markers. Use `StatusChip`
78
79
  when the label must stay visible. `StatusChip` is a small dot and
79
80
  sentence-case text, with no border or fill. It is not a control.
80
81
  - **Messages.** Use `Notice` for compact transient messages and `FormNotice`
81
82
  for wrapping form recovery text.
82
- - **Overlays.** `Drawer` owns dismissal, focus trapping, focus return, scroll
83
- locking, and panel layout. Context panels use it, not a separate modal.
83
+ - **Overlays.** `Sheet` is a local adaptation of shadcn/ui's Base UI Sheet.
84
+ It uses Base UI Drawer for focus, dismissal, and native swipe support. The
85
+ app's shared scroll lock keeps it in sync with the mobile conversation shell.
86
+ `SheetContent` owns side placement, backdrop fade, and slide transitions.
87
+ `Drawer` adds a visible header and close control. `BottomSheet` adds a swipe
88
+ handle and keeps content scrolling separate from gestures. Both skip motion
89
+ when reduced motion is set. Docked conversation details use a non-modal aside
90
+ so the chat stays usable. The copied component's license is in `Sheet.LICENSE`.
84
91
  `Tooltip` and `IconButtonTooltip` own hover text.
85
92
  - **Identity.** `ActorAvatar`, `ParticipantAvatarStack`, and `ObjectIcon`.
86
93
  `ObjectIcon` owns native Ticket, Code change, Automation, Deployment, and
@@ -102,7 +109,10 @@ levels stay distinct.
102
109
  the row width while their labels fit. Rows that do not fit scroll sideways.
103
110
  - **Panel switches** use `SegmentedTabs`, which draws a pill track. Pass the
104
111
  selected value, items, change handler, and panel content. It owns tab and
105
- panel labels and keyboard focus.
112
+ panel labels and keyboard focus. Pass `scroll` to keep the tabs fixed while
113
+ the panel content scrolls. That mode fills its frame and owns the content
114
+ padding, so its scrollbar stays at the frame edge. `BottomSheet` content owns
115
+ its own padding.
106
116
 
107
117
  Keep collection filters in `FilterTabList`.
108
118
 
@@ -12,7 +12,7 @@ const segmentedTabsInsetClass = "gap-1 p-1";
12
12
  /** Style one tab or link in the segmented pill track. */
13
13
  export function segmentedTabClass(selected: boolean): string {
14
14
  return cn(
15
- "min-h-9 cursor-pointer rounded-md border-0 px-3 py-2 font-sans text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
15
+ "min-h-9 cursor-pointer rounded-md border-0 px-1.5 py-2 font-sans text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
16
16
  selected
17
17
  ? "bg-dashboard-fill-strong text-dashboard-text"
18
18
  : "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
@@ -23,6 +23,8 @@ export function segmentedTabClass(selected: boolean): string {
23
23
  export function SegmentedTabs<const Value extends string>(props: {
24
24
  children: ReactNode;
25
25
  label: string;
26
+ /** Fill the frame with fixed tabs and padded, scrolling content. */
27
+ scroll?: boolean;
26
28
  /** `count` is the number of entries in the panel. Zero and unknown counts are hidden. */
27
29
  items: readonly { count?: number; label: string; value: Value }[];
28
30
  onChange(value: NoInfer<Value>): void;
@@ -31,10 +33,22 @@ export function SegmentedTabs<const Value extends string>(props: {
31
33
  const id = useId();
32
34
 
33
35
  return (
34
- <div className="grid min-w-0 gap-4">
36
+ <div
37
+ className={cn(
38
+ "grid min-w-0",
39
+ props.scroll
40
+ ? "h-full min-h-0 grid-rows-[auto_minmax(0,1fr)]"
41
+ : "gap-4",
42
+ )}
43
+ >
35
44
  <div
36
45
  aria-label={props.label}
37
- className={cn("flex", segmentedTabsFrameClass, segmentedTabsInsetClass)}
46
+ className={cn(
47
+ "flex min-w-0",
48
+ segmentedTabsFrameClass,
49
+ segmentedTabsInsetClass,
50
+ props.scroll && "m-4",
51
+ )}
38
52
  role="tablist"
39
53
  >
40
54
  {props.items.map((item, index) => (
@@ -88,7 +102,12 @@ export function SegmentedTabs<const Value extends string>(props: {
88
102
  </div>
89
103
  <div
90
104
  aria-labelledby={`${id}-${props.value}`}
91
- className="min-w-0 rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
105
+ className={cn(
106
+ "min-w-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
107
+ props.scroll
108
+ ? "min-h-0 overflow-y-auto overscroll-contain px-4 pb-[max(1rem,env(safe-area-inset-bottom))] focus-visible:-outline-offset-2"
109
+ : "rounded-md",
110
+ )}
92
111
  id={`${id}-panel`}
93
112
  role="tabpanel"
94
113
  tabIndex={0}
@@ -1,18 +1,52 @@
1
1
  import type { SelectHTMLAttributes } from "react";
2
+ import { ChevronDown } from "lucide-react";
2
3
  import { cn } from "../styles";
3
4
 
4
- /** Match native form choices to the dashboard text controls. */
5
+ const selectClassName = {
6
+ form: "min-h-11 rounded-md border border-dashboard-border-emphasis bg-dashboard-ink pl-3 py-2 text-base text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 sm:min-h-10 sm:text-sm",
7
+ filter:
8
+ "h-9 rounded-lg border border-dashboard-border-emphasis bg-dashboard-control pl-2 text-sm text-dashboard-text transition-colors hover:border-dashboard-border-interactive focus:border-dashboard-focus focus:outline-none focus:ring-1 focus:ring-dashboard-focus/25",
9
+ sort: "h-full bg-dashboard-control pl-2 font-sans text-xs text-dashboard-text-muted outline-none",
10
+ };
11
+
12
+ /** Keep native choices and position their arrow consistently across the dashboard. */
5
13
  export function Select({
6
14
  className,
15
+ variant = "form",
7
16
  ...props
8
- }: SelectHTMLAttributes<HTMLSelectElement>) {
17
+ }: SelectHTMLAttributes<HTMLSelectElement> & {
18
+ variant?: keyof typeof selectClassName;
19
+ }) {
20
+ const showArrow = !props.multiple && (props.size ?? 1) <= 1;
21
+
9
22
  return (
10
- <select
11
- {...props}
23
+ <span
12
24
  className={cn(
13
- "block min-h-11 w-full min-w-0 rounded-md border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-base text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-10 sm:text-sm",
14
- className,
25
+ "relative block w-full min-w-0",
26
+ variant === "sort" && "h-full",
15
27
  )}
16
- />
28
+ >
29
+ <select
30
+ {...props}
31
+ className={cn(
32
+ "block w-full min-w-0 disabled:cursor-not-allowed disabled:opacity-60 [color-scheme:var(--dashboard-color-scheme,dark)]",
33
+ selectClassName[variant],
34
+ showArrow
35
+ ? "appearance-none pr-9 forced-colors:appearance-auto"
36
+ : "pr-3",
37
+ className,
38
+ )}
39
+ />
40
+ {showArrow ? (
41
+ <ChevronDown
42
+ aria-hidden="true"
43
+ className={cn(
44
+ "pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-dashboard-text-muted forced-colors:hidden",
45
+ props.disabled && "opacity-60",
46
+ )}
47
+ size={16}
48
+ />
49
+ ) : null}
50
+ </span>
17
51
  );
18
52
  }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 shadcn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ // Adapted from shadcn/ui's Base UI Sheet. Drawer adds native swipe support.
2
+ // https://ui.shadcn.com/docs/components/base/sheet
3
+ import { Drawer as SheetPrimitive } from "@base-ui/react/drawer";
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ type ReactNode,
12
+ type RefObject,
13
+ } from "react";
14
+
15
+ import {
16
+ acquireBodyScrollLock,
17
+ releaseBodyScrollLock,
18
+ } from "../bodyScrollLock";
19
+ import { cn } from "../styles";
20
+
21
+ const SheetContext = createContext<{
22
+ closingRef: RefObject<boolean>;
23
+ popupRef: RefObject<HTMLDivElement | null>;
24
+ topmost: boolean;
25
+ } | null>(null);
26
+
27
+ // Separate roots can overlap when a resize mounts a sheet below an open drawer.
28
+ // Only the last modal in document order may trap focus or handle dismissal.
29
+ function subscribeToSheets(onChange: () => void) {
30
+ const observer = new MutationObserver(onChange);
31
+ observer.observe(document.body, { childList: true, subtree: true });
32
+ return () => observer.disconnect();
33
+ }
34
+
35
+ /** Keep modal state until Base UI finishes its close transition. */
36
+ export function Sheet(props: {
37
+ children: ReactNode;
38
+ onClose(): void;
39
+ side: "right" | "bottom";
40
+ }) {
41
+ const [open, setOpen] = useState(false);
42
+ const closingRef = useRef(false);
43
+ const popupRef = useRef<HTMLDivElement>(null);
44
+ const topmost = useSyncExternalStore(
45
+ subscribeToSheets,
46
+ () => {
47
+ const modals = document.querySelectorAll("[data-dashboard-modal]");
48
+ const last = modals[modals.length - 1];
49
+ return popupRef.current ? last === popupRef.current : !last;
50
+ },
51
+ () => true,
52
+ );
53
+ useEffect(() => {
54
+ // The mobile conversation shell uses this same document scroll lock.
55
+ acquireBodyScrollLock();
56
+ setOpen(true);
57
+ return releaseBodyScrollLock;
58
+ }, []);
59
+
60
+ return (
61
+ <SheetContext value={{ closingRef, popupRef, topmost }}>
62
+ <SheetPrimitive.Root
63
+ open={open}
64
+ modal={topmost ? "trap-focus" : false}
65
+ disablePointerDismissal={!topmost}
66
+ swipeDirection={props.side === "bottom" ? "down" : "right"}
67
+ onOpenChange={(open, details) => {
68
+ if (!open && !topmost) {
69
+ details.cancel();
70
+ return;
71
+ }
72
+ closingRef.current = !open;
73
+ setOpen(open);
74
+ }}
75
+ onOpenChangeComplete={(open) => {
76
+ if (!open && closingRef.current) props.onClose();
77
+ }}
78
+ >
79
+ {props.children}
80
+ </SheetPrimitive.Root>
81
+ </SheetContext>
82
+ );
83
+ }
84
+
85
+ /** Close the enclosing sheet through its transition and focus lifecycle. */
86
+ export const SheetClose = SheetPrimitive.Close;
87
+
88
+ /** Render sheet chrome, a fading backdrop, and Base UI's focus boundary. */
89
+ export function SheetContent(
90
+ props: {
91
+ children: ReactNode;
92
+ className?: string;
93
+ initialFocusRef?: RefObject<HTMLElement | null>;
94
+ side: "right" | "bottom";
95
+ } & ({ label: string; titleId?: never } | { label?: never; titleId: string }),
96
+ ) {
97
+ const sheet = useContext(SheetContext);
98
+ const popupRef = sheet?.popupRef;
99
+ const portalRef = useRef<HTMLDivElement>(null);
100
+ return (
101
+ <>
102
+ <div ref={portalRef} className="contents" />
103
+ <SheetPrimitive.Portal container={portalRef}>
104
+ <SheetPrimitive.Backdrop className="fixed inset-0 z-50 bg-dashboard-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-180 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[swiping]:transition-none motion-reduce:transition-none" />
105
+ <SheetPrimitive.Viewport className="pointer-events-none fixed inset-0 z-50">
106
+ <SheetPrimitive.Popup
107
+ ref={popupRef}
108
+ aria-modal={sheet?.topmost ? true : undefined}
109
+ aria-label={props.label}
110
+ aria-labelledby={props.titleId}
111
+ data-dashboard-modal=""
112
+ data-base-ui-swipe-ignore={props.side === "right" ? "" : undefined}
113
+ finalFocus={() =>
114
+ Boolean(sheet?.closingRef.current && sheet.topmost)
115
+ }
116
+ initialFocus={() => {
117
+ // A resize can mount a sheet beneath an existing event dialog.
118
+ const modals = document.querySelectorAll(
119
+ "[data-dashboard-modal]",
120
+ );
121
+ if (modals[modals.length - 1] !== popupRef?.current) return false;
122
+ return props.initialFocusRef?.current ?? true;
123
+ }}
124
+ className={cn(
125
+ "pointer-events-auto grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] bg-dashboard-bg-elevated outline-none transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.2,0.8,0.2,1)] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[swiping]:transition-none motion-reduce:transition-none",
126
+ props.side === "bottom"
127
+ ? "absolute inset-x-0 bottom-0 mx-auto h-[85dvh] max-h-full w-full max-w-2xl [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl border border-b-0 border-dashboard-border-strong shadow-[0_-12px_40px_rgba(0,0,0,0.25)] data-[ending-style]:[transform:translateY(100%)] data-[starting-style]:[transform:translateY(2.5rem)]"
128
+ : "absolute right-0 top-0 h-full w-full shadow-[-12px_0_40px_rgba(0,0,0,0.25)] data-[ending-style]:[transform:translateX(2.5rem)] data-[starting-style]:[transform:translateX(2.5rem)] md:border-l md:border-dashboard-border-strong",
129
+ props.className,
130
+ )}
131
+ >
132
+ {props.children}
133
+ </SheetPrimitive.Popup>
134
+ </SheetPrimitive.Viewport>
135
+ </SheetPrimitive.Portal>
136
+ </>
137
+ );
138
+ }
139
+
140
+ /** Keep text selection and scrolling separate from the swipe handle. */
141
+ export const SheetBody = SheetPrimitive.Content;
@@ -1,9 +1,11 @@
1
+ import { Select } from "../Select";
2
+
1
3
  export type DirectorySortOption = {
2
4
  label: string;
3
5
  value: string;
4
6
  };
5
7
 
6
- /** Render a native directory sort control with an explicit dark color scheme. */
8
+ /** Keep directory sorting consistent with the dashboard's shared select. */
7
9
  export function DirectorySortSelect(props: {
8
10
  ariaLabel: string;
9
11
  onChange(value: string): void;
@@ -15,9 +17,9 @@ export function DirectorySortSelect(props: {
15
17
  <span className="flex h-full items-center border-r border-dashboard-border-subtle px-2 font-sans font-medium text-xs text-dashboard-text-muted">
16
18
  Sort
17
19
  </span>
18
- <select
20
+ <Select
19
21
  aria-label={props.ariaLabel}
20
- className="h-full min-w-0 bg-dashboard-control px-2 font-sans text-xs text-dashboard-text-muted outline-none [color-scheme:var(--dashboard-color-scheme,dark)]"
22
+ variant="sort"
21
23
  value={props.value}
22
24
  onChange={(event) => props.onChange(event.currentTarget.value)}
23
25
  >
@@ -30,7 +32,7 @@ export function DirectorySortSelect(props: {
30
32
  {option.label}
31
33
  </option>
32
34
  ))}
33
- </select>
35
+ </Select>
34
36
  </label>
35
37
  );
36
38
  }