@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.
- package/dist/app.js +2 -2
- package/dist/client.js +96 -74
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +7 -6
- package/src/client/components/BottomSheet.tsx +32 -0
- package/src/client/components/Button.tsx +2 -1
- package/src/client/components/Drawer.tsx +23 -109
- package/src/client/components/README.md +14 -4
- package/src/client/components/SegmentedTabs.tsx +23 -4
- package/src/client/components/Select.tsx +41 -7
- package/src/client/components/Sheet.LICENSE +21 -0
- package/src/client/components/Sheet.tsx +141 -0
- package/src/client/components/controls/DirectorySortSelect.tsx +6 -4
- package/src/client/conversations/{ConversationDetailsDrawer.tsx → ConversationDetails.tsx} +19 -38
- package/src/client/conversations/ConversationDetailsFrame.tsx +91 -0
- package/src/client/conversations/ConversationEventLog.tsx +0 -1
- package/src/client/conversations/ConversationHeader.tsx +20 -28
- package/src/client/conversations/ConversationHeaderActions.tsx +5 -1
- package/src/client/conversations/ConversationPage.tsx +49 -36
- package/src/client/conversations/ConversationWorkspace.tsx +9 -12
- package/src/client/conversations/ConversationWorkspaceLoading.tsx +1 -4
- package/src/client/conversations/ForkConversationButton.tsx +0 -1
- package/src/client/conversations/README.md +18 -2
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +0 -1
- package/src/client/conversations/TelemetryMetrics.tsx +2 -2
- package/src/client/conversations/TranscriptSubagentView.tsx +1 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +0 -1
- package/src/client/debouncedValue.ts +14 -0
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +0 -1
- package/src/client/pages/automations/AutomationFilters.tsx +4 -3
- package/src/client/pages/code/RepositoryTabs.tsx +3 -2
- package/src/client/pages/dev/ComponentsPage.tsx +20 -2
- package/src/client/pages/dev/SegmentedFixtures.tsx +8 -7
- package/src/client/pages/dev/SelectFixtures.tsx +62 -0
- package/src/client/pages/memory/MemoryDetailsDrawer.tsx +0 -1
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
</
|
|
49
|
+
</SheetClose>
|
|
136
50
|
</div>
|
|
137
51
|
</header>
|
|
138
|
-
<
|
|
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
|
-
</
|
|
141
|
-
</
|
|
142
|
-
</
|
|
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`
|
|
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.** `
|
|
83
|
-
|
|
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-
|
|
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
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
-
<
|
|
11
|
-
{...props}
|
|
23
|
+
<span
|
|
12
24
|
className={cn(
|
|
13
|
-
"block
|
|
14
|
-
|
|
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
|
-
/**
|
|
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
|
-
<
|
|
20
|
+
<Select
|
|
19
21
|
aria-label={props.ariaLabel}
|
|
20
|
-
|
|
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
|
-
</
|
|
35
|
+
</Select>
|
|
34
36
|
</label>
|
|
35
37
|
);
|
|
36
38
|
}
|