@sentry/junior-dashboard 0.266.0 → 0.268.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 +85 -12
- package/dist/client.js +94 -64
- package/dist/index.js +85 -12
- package/dist/tailwind.css +1 -1
- package/package.json +11 -5
- package/src/app.ts +0 -2
- package/src/auth.ts +4 -7
- package/src/client/App.tsx +55 -53
- package/src/client/components/DetailsPanel.tsx +12 -13
- package/src/client/components/Drawer.tsx +46 -23
- package/src/client/components/ImageAttachment.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +6 -2
- package/src/client/components/README.md +15 -2
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/alert.tsx +1 -1
- package/src/client/components/ui/button-group.tsx +59 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/components/ui/dialog.tsx +29 -35
- package/src/client/components/ui/drawer.tsx +140 -0
- package/src/client/components/ui/empty.tsx +30 -1
- package/src/client/components/ui/separator.tsx +22 -0
- package/src/client/components/ui/useSheet.ts +5 -1
- package/src/client/conversations/ComposerImages.tsx +66 -14
- package/src/client/conversations/ConversationComposer.tsx +84 -12
- package/src/client/conversations/ConversationDetails.tsx +3 -27
- package/src/client/conversations/ConversationEventDetails.tsx +26 -10
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +160 -2
- package/src/client/conversations/ConversationSidebar.tsx +6 -0
- package/src/client/conversations/ConversationTranscript.tsx +48 -16
- package/src/client/conversations/ConversationWorkspace.tsx +4 -0
- package/src/client/conversations/DetailsList.tsx +33 -0
- package/src/client/conversations/EventDetails.tsx +46 -19
- package/src/client/conversations/ForkConversationButton.tsx +79 -73
- package/src/client/conversations/PendingMailboxStack.tsx +310 -112
- package/src/client/conversations/README.md +30 -13
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
- package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
- package/src/client/conversations/TranscriptAttachment.tsx +39 -4
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
- package/src/client/conversations/TranscriptFailureView.tsx +10 -13
- package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
- package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
- package/src/client/conversations/TranscriptMessageView.tsx +10 -1
- package/src/client/conversations/TranscriptSummary.tsx +5 -31
- package/src/client/conversations/TranscriptToolView.tsx +13 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
- package/src/client/conversations/TranscriptView.tsx +7 -3
- package/src/client/conversations/UserInputRequestCard.tsx +39 -0
- package/src/client/conversations/conversationOutbox.ts +46 -0
- package/src/client/conversations/eventTime.ts +13 -1
- package/src/client/conversations/eventTranscript.ts +12 -5
- package/src/client/conversations/queries.ts +127 -26
- package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
- package/src/client/conversations/turnContext.ts +15 -0
- package/src/client/conversations/userInputRequest.ts +56 -0
- package/src/client/format.ts +31 -8
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
- package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +182 -156
- package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
- package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
- package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
- package/src/client/pages/automations/AutomationOverview.tsx +238 -0
- package/src/client/pages/automations/AutomationPage.tsx +343 -0
- package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
- package/src/client/pages/automations/AutomationRow.tsx +11 -13
- package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
- package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
- package/src/client/pages/automations/AutomationStatus.tsx +164 -108
- package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
- package/src/client/pages/automations/AutomationsPage.tsx +10 -98
- package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
- package/src/client/pages/automations/README.md +56 -46
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
- package/src/client/review-desk/ReviewItemActions.tsx +165 -0
- package/src/client/review-desk/ReviewItemCard.tsx +47 -0
- package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
- package/src/client/review-desk/queries.ts +164 -0
- package/src/client/review-desk/reviewItemFormat.ts +61 -0
- package/src/client/types.ts +3 -0
- package/src/mock-reporting/fixtures.ts +54 -2
- package/src/mock-reporting/routes.ts +20 -0
- package/src/shell.ts +3 -0
- package/src/client/conversations/transcriptStyles.ts +0 -10
- package/src/client/pages/automations/AutomationDetails.tsx +0 -193
- package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
- package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { useId, useRef, useState, type FormEvent, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buttonUserInputField,
|
|
4
|
+
isEmptyUserInputAnswer,
|
|
5
|
+
normalizeUserInputAnswer,
|
|
6
|
+
type UserInputAnswer,
|
|
7
|
+
type UserInputField,
|
|
8
|
+
type UserInputRequest,
|
|
9
|
+
} from "@sentry/junior/api/schema";
|
|
10
|
+
|
|
11
|
+
import { cn } from "../styles";
|
|
12
|
+
import { Button } from "./ui/button";
|
|
13
|
+
import { Checkbox } from "./ui/checkbox";
|
|
14
|
+
import { Field, FieldDescription, FieldLabel } from "./ui/field";
|
|
15
|
+
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
|
16
|
+
import { Textarea } from "./ui/textarea";
|
|
17
|
+
|
|
18
|
+
// Each choice is a whole selectable row, so the hit area and the selected
|
|
19
|
+
// state read at a glance. The control inside keeps its own focus ring.
|
|
20
|
+
const optionClass =
|
|
21
|
+
"flex cursor-pointer items-start gap-3 rounded-md border border-dashboard-border bg-dashboard-surface-panel px-3 py-2.5 text-sm transition-colors hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover has-[[data-checked]]:border-dashboard-accent/60 has-[[data-checked]]:bg-dashboard-accent/10 has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-60";
|
|
22
|
+
|
|
23
|
+
function OptionText(props: { description?: string; label: string }) {
|
|
24
|
+
return (
|
|
25
|
+
<span className="grid min-w-0 gap-0.5 break-words">
|
|
26
|
+
<span className="font-medium text-dashboard-text">{props.label}</span>
|
|
27
|
+
{props.description ? (
|
|
28
|
+
<span className="text-xs text-dashboard-text-muted">
|
|
29
|
+
{props.description}
|
|
30
|
+
</span>
|
|
31
|
+
) : null}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ChoiceField(props: {
|
|
37
|
+
disabled: boolean;
|
|
38
|
+
field: Extract<UserInputField, { kind: "choice" | "multi_choice" }>;
|
|
39
|
+
onChange(values: string[]): void;
|
|
40
|
+
values: string[];
|
|
41
|
+
}) {
|
|
42
|
+
const id = useId();
|
|
43
|
+
const { field } = props;
|
|
44
|
+
const legend = (
|
|
45
|
+
<legend className="mb-2 p-0 text-sm font-semibold text-dashboard-text">
|
|
46
|
+
{field.label}
|
|
47
|
+
</legend>
|
|
48
|
+
);
|
|
49
|
+
const description = field.description ? (
|
|
50
|
+
<FieldDescription className="-mt-1 mb-2" id={`${id}-help`}>
|
|
51
|
+
{field.description}
|
|
52
|
+
</FieldDescription>
|
|
53
|
+
) : null;
|
|
54
|
+
const describedBy = field.description ? `${id}-help` : undefined;
|
|
55
|
+
if (field.kind === "choice") {
|
|
56
|
+
return (
|
|
57
|
+
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
58
|
+
{legend}
|
|
59
|
+
{description}
|
|
60
|
+
<RadioGroup
|
|
61
|
+
aria-describedby={describedBy}
|
|
62
|
+
aria-label={field.label}
|
|
63
|
+
className="grid gap-2"
|
|
64
|
+
disabled={props.disabled}
|
|
65
|
+
onValueChange={(value) => props.onChange([String(value)])}
|
|
66
|
+
value={props.values[0] ?? null}
|
|
67
|
+
>
|
|
68
|
+
{field.options.map((option) => (
|
|
69
|
+
<label className={optionClass} key={option.label}>
|
|
70
|
+
<RadioGroupItem className="mt-0.5" value={option.label} />
|
|
71
|
+
<OptionText
|
|
72
|
+
description={option.description}
|
|
73
|
+
label={option.label}
|
|
74
|
+
/>
|
|
75
|
+
</label>
|
|
76
|
+
))}
|
|
77
|
+
</RadioGroup>
|
|
78
|
+
</fieldset>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
<fieldset
|
|
83
|
+
aria-describedby={describedBy}
|
|
84
|
+
className="m-0 min-w-0 border-0 p-0"
|
|
85
|
+
>
|
|
86
|
+
{legend}
|
|
87
|
+
{description}
|
|
88
|
+
<div className="grid gap-2">
|
|
89
|
+
{field.options.map((option) => (
|
|
90
|
+
<label className={optionClass} key={option.label}>
|
|
91
|
+
<Checkbox
|
|
92
|
+
checked={props.values.includes(option.label)}
|
|
93
|
+
className="mt-0.5"
|
|
94
|
+
disabled={props.disabled}
|
|
95
|
+
onCheckedChange={(checked) =>
|
|
96
|
+
props.onChange(
|
|
97
|
+
checked
|
|
98
|
+
? [...props.values, option.label]
|
|
99
|
+
: props.values.filter((value) => value !== option.label),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
/>
|
|
103
|
+
<OptionText description={option.description} label={option.label} />
|
|
104
|
+
</label>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</fieldset>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function TextField(props: {
|
|
112
|
+
className?: string;
|
|
113
|
+
description?: string;
|
|
114
|
+
disabled: boolean;
|
|
115
|
+
label: string;
|
|
116
|
+
onChange(value: string): void;
|
|
117
|
+
value: string;
|
|
118
|
+
}) {
|
|
119
|
+
const id = useId();
|
|
120
|
+
return (
|
|
121
|
+
<Field>
|
|
122
|
+
<FieldLabel htmlFor={id}>{props.label}</FieldLabel>
|
|
123
|
+
{props.description ? (
|
|
124
|
+
<FieldDescription className="-mt-1" id={`${id}-help`}>
|
|
125
|
+
{props.description}
|
|
126
|
+
</FieldDescription>
|
|
127
|
+
) : null}
|
|
128
|
+
<Textarea
|
|
129
|
+
aria-describedby={props.description ? `${id}-help` : undefined}
|
|
130
|
+
className={cn("min-h-20 leading-relaxed", props.className)}
|
|
131
|
+
disabled={props.disabled}
|
|
132
|
+
id={id}
|
|
133
|
+
onChange={(event) => props.onChange(event.target.value)}
|
|
134
|
+
value={props.value}
|
|
135
|
+
/>
|
|
136
|
+
</Field>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Render the answer controls of a `requestUserInput` request. A request with
|
|
142
|
+
* one short choice field shows one button per choice, and a click is the
|
|
143
|
+
* answer. Other requests show a form that the reader submits once. A failed
|
|
144
|
+
* `onSubmit` lets the reader try again. `secondaryActions`, such as Dismiss,
|
|
145
|
+
* sit in the same action row, after the answer controls.
|
|
146
|
+
*/
|
|
147
|
+
export function UserInputForm(props: {
|
|
148
|
+
onSubmit(answer: UserInputAnswer): Promise<void>;
|
|
149
|
+
request: UserInputRequest;
|
|
150
|
+
requestId: string;
|
|
151
|
+
secondaryActions?: ReactNode;
|
|
152
|
+
}) {
|
|
153
|
+
const [sending, setSending] = useState(false);
|
|
154
|
+
const [failed, setFailed] = useState(false);
|
|
155
|
+
const [values, setValues] = useState<Record<string, string[]>>({});
|
|
156
|
+
const [other, setOther] = useState("");
|
|
157
|
+
const buttonField = buttonUserInputField(props.request);
|
|
158
|
+
|
|
159
|
+
// A ref blocks a second click before React renders the disabled controls.
|
|
160
|
+
// The lock stays after a sent answer; only a failed send can retry.
|
|
161
|
+
const sendLock = useRef(false);
|
|
162
|
+
|
|
163
|
+
async function send(answer: UserInputAnswer) {
|
|
164
|
+
if (sendLock.current) return;
|
|
165
|
+
sendLock.current = true;
|
|
166
|
+
setSending(true);
|
|
167
|
+
setFailed(false);
|
|
168
|
+
try {
|
|
169
|
+
await props.onSubmit(answer);
|
|
170
|
+
} catch {
|
|
171
|
+
sendLock.current = false;
|
|
172
|
+
setSending(false);
|
|
173
|
+
setFailed(true);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const answer: UserInputAnswer = {
|
|
178
|
+
other,
|
|
179
|
+
requestId: props.requestId,
|
|
180
|
+
values,
|
|
181
|
+
};
|
|
182
|
+
function submit(event: FormEvent) {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
if (
|
|
185
|
+
!isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer))
|
|
186
|
+
) {
|
|
187
|
+
void send(answer);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const secondary = props.secondaryActions ? (
|
|
192
|
+
<div className="flex flex-wrap gap-1 sm:ml-auto">
|
|
193
|
+
{props.secondaryActions}
|
|
194
|
+
</div>
|
|
195
|
+
) : null;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
{buttonField ? (
|
|
200
|
+
<>
|
|
201
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
202
|
+
{buttonField.options.map((option) => (
|
|
203
|
+
<Button
|
|
204
|
+
disabled={sending}
|
|
205
|
+
key={option.label}
|
|
206
|
+
onClick={() =>
|
|
207
|
+
void send({
|
|
208
|
+
requestId: props.requestId,
|
|
209
|
+
values: { [buttonField.id]: [option.label] },
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
title={option.description}
|
|
213
|
+
>
|
|
214
|
+
{option.label}
|
|
215
|
+
</Button>
|
|
216
|
+
))}
|
|
217
|
+
{secondary}
|
|
218
|
+
</div>
|
|
219
|
+
{buttonField.options.some((option) => option.description) ? (
|
|
220
|
+
<ul className="mb-0 mt-2 list-none p-0 text-xs text-dashboard-text-muted">
|
|
221
|
+
{buttonField.options
|
|
222
|
+
.filter((option) => option.description)
|
|
223
|
+
.map((option) => (
|
|
224
|
+
<li key={option.label}>
|
|
225
|
+
<span className="font-medium text-dashboard-text">
|
|
226
|
+
{option.label}
|
|
227
|
+
</span>
|
|
228
|
+
: {option.description}
|
|
229
|
+
</li>
|
|
230
|
+
))}
|
|
231
|
+
</ul>
|
|
232
|
+
) : null}
|
|
233
|
+
</>
|
|
234
|
+
) : (
|
|
235
|
+
<form className="mt-4 grid gap-5" onSubmit={submit}>
|
|
236
|
+
{props.request.fields.map((field) =>
|
|
237
|
+
field.kind === "text" ? (
|
|
238
|
+
<TextField
|
|
239
|
+
description={field.description}
|
|
240
|
+
disabled={sending}
|
|
241
|
+
key={field.id}
|
|
242
|
+
label={field.label}
|
|
243
|
+
onChange={(value) =>
|
|
244
|
+
setValues((current) => ({ ...current, [field.id]: [value] }))
|
|
245
|
+
}
|
|
246
|
+
value={values[field.id]?.[0] ?? ""}
|
|
247
|
+
/>
|
|
248
|
+
) : (
|
|
249
|
+
<ChoiceField
|
|
250
|
+
disabled={sending}
|
|
251
|
+
field={field}
|
|
252
|
+
key={field.id}
|
|
253
|
+
onChange={(picked) =>
|
|
254
|
+
setValues((current) => ({ ...current, [field.id]: picked }))
|
|
255
|
+
}
|
|
256
|
+
values={values[field.id] ?? []}
|
|
257
|
+
/>
|
|
258
|
+
),
|
|
259
|
+
)}
|
|
260
|
+
<TextField
|
|
261
|
+
className="min-h-16"
|
|
262
|
+
description="Optional. Anything else, or an answer that is not listed."
|
|
263
|
+
disabled={sending}
|
|
264
|
+
label="Other"
|
|
265
|
+
onChange={setOther}
|
|
266
|
+
value={other}
|
|
267
|
+
/>
|
|
268
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
269
|
+
<Button disabled={sending} type="submit" variant="primary">
|
|
270
|
+
Send
|
|
271
|
+
</Button>
|
|
272
|
+
{secondary}
|
|
273
|
+
</div>
|
|
274
|
+
</form>
|
|
275
|
+
)}
|
|
276
|
+
{failed ? (
|
|
277
|
+
<p className="mb-0 mt-2 text-xs text-rose-300" role="alert">
|
|
278
|
+
Could not send your answer. Try again.
|
|
279
|
+
</p>
|
|
280
|
+
) : null}
|
|
281
|
+
</>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -15,11 +15,22 @@ import {
|
|
|
15
15
|
import { cn, dashboardInteractiveTextClass } from "../../styles";
|
|
16
16
|
|
|
17
17
|
export type DashboardHeaderNavItem = {
|
|
18
|
+
/** Shown beside the label when it is above zero. */
|
|
19
|
+
count?: number;
|
|
18
20
|
key: string;
|
|
19
21
|
label: string;
|
|
20
22
|
to: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
25
|
+
function NavCount(props: { count?: number }) {
|
|
26
|
+
if (!props.count) return null;
|
|
27
|
+
return (
|
|
28
|
+
<span className="ml-1.5 rounded-full bg-violet-300/20 px-1.5 py-0.5 text-xs text-violet-200">
|
|
29
|
+
{props.count.toLocaleString("en-US")}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
/** Render the primary dashboard shell header and optional mobile nav sheet. */
|
|
24
35
|
export function DashboardHeader(props: {
|
|
25
36
|
/** Back target used on mobile conversation detail. */
|
|
@@ -151,6 +162,7 @@ export function DashboardHeader(props: {
|
|
|
151
162
|
{props.navItems.map((item) => (
|
|
152
163
|
<NavLink className={navLinkClass} key={item.key} to={item.to}>
|
|
153
164
|
{item.label}
|
|
165
|
+
<NavCount count={item.count} />
|
|
154
166
|
</NavLink>
|
|
155
167
|
))}
|
|
156
168
|
</nav>
|
|
@@ -204,6 +216,7 @@ export function DashboardHeader(props: {
|
|
|
204
216
|
to={item.to}
|
|
205
217
|
>
|
|
206
218
|
{item.label}
|
|
219
|
+
<NavCount count={item.count} />
|
|
207
220
|
</NavLink>
|
|
208
221
|
))}
|
|
209
222
|
</nav>
|
|
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
|
|
10
10
|
default:
|
|
11
11
|
"border-dashboard-border-strong bg-dashboard-surface-raised text-dashboard-text",
|
|
12
12
|
destructive:
|
|
13
|
-
"border-rose-300/
|
|
13
|
+
"border-rose-300/20 bg-rose-300/[0.08] text-rose-100 *:data-[slot=alert-description]:text-rose-100/70 *:[svg]:text-rose-300",
|
|
14
14
|
},
|
|
15
15
|
},
|
|
16
16
|
defaultVariants: {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { cn } from "@dashboard/lib/utils";
|
|
3
|
+
|
|
4
|
+
import { Separator } from "@dashboard/components/ui/separator";
|
|
5
|
+
|
|
6
|
+
const buttonGroupVariants = cva(
|
|
7
|
+
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
orientation: {
|
|
11
|
+
horizontal:
|
|
12
|
+
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
|
13
|
+
vertical:
|
|
14
|
+
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: {
|
|
18
|
+
orientation: "horizontal",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** Join related buttons into one control and name it for assistive technology. */
|
|
24
|
+
function ButtonGroup({
|
|
25
|
+
className,
|
|
26
|
+
orientation,
|
|
27
|
+
...props
|
|
28
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
role="group"
|
|
32
|
+
data-slot="button-group"
|
|
33
|
+
data-orientation={orientation}
|
|
34
|
+
className={cn(buttonGroupVariants({ orientation }), className)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Draw a divider between buttons in a group. */
|
|
41
|
+
function ButtonGroupSeparator({
|
|
42
|
+
className,
|
|
43
|
+
orientation = "vertical",
|
|
44
|
+
...props
|
|
45
|
+
}: React.ComponentProps<typeof Separator>) {
|
|
46
|
+
return (
|
|
47
|
+
<Separator
|
|
48
|
+
data-slot="button-group-separator"
|
|
49
|
+
orientation={orientation}
|
|
50
|
+
className={cn(
|
|
51
|
+
"relative self-stretch bg-dashboard-border data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { ButtonGroup, ButtonGroupSeparator };
|
|
@@ -5,7 +5,7 @@ import { cn } from "@dashboard/lib/utils";
|
|
|
5
5
|
const commandButtonClass =
|
|
6
6
|
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium leading-none focus-visible:outline-2 focus-visible:outline-offset-2";
|
|
7
7
|
|
|
8
|
-
/** Style the dashboard command, primary, danger, icon, and menu row button surfaces. Links use it with `className`. */
|
|
8
|
+
/** Style the dashboard command, primary, quiet, danger, icon, and menu row button surfaces. Links use it with `className`. */
|
|
9
9
|
const buttonVariants = cva(
|
|
10
10
|
"cursor-pointer font-sans no-underline transition-colors data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
|
11
11
|
{
|
|
@@ -13,6 +13,8 @@ const buttonVariants = cva(
|
|
|
13
13
|
variant: {
|
|
14
14
|
default: "",
|
|
15
15
|
primary: "",
|
|
16
|
+
/** A secondary action beside a primary one, such as Dismiss. */
|
|
17
|
+
quiet: "",
|
|
16
18
|
danger: "",
|
|
17
19
|
},
|
|
18
20
|
size: {
|
|
@@ -38,6 +40,14 @@ const buttonVariants = cva(
|
|
|
38
40
|
"border-dashboard-text-solid bg-dashboard-text-solid text-dashboard-text-inverse hover:border-dashboard-text-subtle hover:bg-dashboard-text-subtle focus-visible:outline-dashboard-focus data-disabled:hover:border-dashboard-text-solid data-disabled:hover:bg-dashboard-text-solid",
|
|
39
41
|
),
|
|
40
42
|
},
|
|
43
|
+
{
|
|
44
|
+
size: "default",
|
|
45
|
+
variant: "quiet",
|
|
46
|
+
className: cn(
|
|
47
|
+
commandButtonClass,
|
|
48
|
+
"border-transparent bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:outline-dashboard-focus data-disabled:hover:bg-transparent data-disabled:hover:text-dashboard-text-muted",
|
|
49
|
+
),
|
|
50
|
+
},
|
|
41
51
|
{
|
|
42
52
|
size: "default",
|
|
43
53
|
variant: "danger",
|
|
@@ -1,66 +1,60 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
1
2
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
|
2
3
|
import { cn } from "@dashboard/lib/utils";
|
|
3
4
|
|
|
4
|
-
/** Own the open state and modal focus rules for a dialog. */
|
|
5
|
-
function Dialog(
|
|
5
|
+
/** Own the open state and modal focus rules for a centered dialog. */
|
|
6
|
+
function Dialog(props: DialogPrimitive.Root.Props) {
|
|
6
7
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
|
10
|
-
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
10
|
/** Close the dialog through Base UI dismissal and focus handling. */
|
|
14
|
-
function DialogClose(
|
|
11
|
+
function DialogClose(props: DialogPrimitive.Close.Props) {
|
|
15
12
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
className,
|
|
20
|
-
...props
|
|
21
|
-
}: DialogPrimitive.Backdrop.Props) {
|
|
22
|
-
return (
|
|
23
|
-
<DialogPrimitive.Backdrop
|
|
24
|
-
data-slot="dialog-overlay"
|
|
25
|
-
className={cn(
|
|
26
|
-
"fixed inset-0 isolate z-50 bg-dashboard-overlay transition-opacity duration-180 motion-reduce:transition-none data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
27
|
-
className,
|
|
28
|
-
)}
|
|
29
|
-
{...props}
|
|
30
|
-
/>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Show the dialog above a backdrop, centered in the viewport. */
|
|
15
|
+
/** Show a short task above the page. Small screens keep it near the top, clear of the keyboard. */
|
|
35
16
|
function DialogContent({ className, ...props }: DialogPrimitive.Popup.Props) {
|
|
36
17
|
return (
|
|
37
|
-
<
|
|
38
|
-
|
|
18
|
+
<DialogPrimitive.Portal data-slot="dialog-portal">
|
|
19
|
+
{/* Dim an open sheet too, so a dialog opened from it stands out. */}
|
|
20
|
+
<DialogPrimitive.Backdrop
|
|
21
|
+
forceRender
|
|
22
|
+
data-slot="dialog-overlay"
|
|
23
|
+
className="fixed inset-0 z-50 bg-dashboard-overlay transition-opacity duration-180 motion-reduce:transition-none data-ending-style:opacity-0 data-starting-style:opacity-0"
|
|
24
|
+
/>
|
|
39
25
|
<DialogPrimitive.Popup
|
|
40
|
-
data-slot="dialog-content"
|
|
41
26
|
data-dashboard-modal=""
|
|
27
|
+
data-slot="dialog-content"
|
|
42
28
|
className={cn(
|
|
43
|
-
"fixed top-
|
|
29
|
+
"fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 gap-4 overflow-y-auto rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-raised p-5 text-sm text-dashboard-text shadow-lg outline-none transition-[opacity,scale] duration-180 motion-reduce:transition-none data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:top-1/2 sm:-translate-y-1/2",
|
|
44
30
|
className,
|
|
45
31
|
)}
|
|
46
32
|
{...props}
|
|
47
33
|
/>
|
|
48
|
-
</
|
|
34
|
+
</DialogPrimitive.Portal>
|
|
49
35
|
);
|
|
50
36
|
}
|
|
51
37
|
|
|
52
|
-
/**
|
|
38
|
+
/** Name the dialog task. */
|
|
53
39
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
|
54
40
|
return (
|
|
55
41
|
<DialogPrimitive.Title
|
|
56
42
|
data-slot="dialog-title"
|
|
57
|
-
className={cn(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
43
|
+
className={cn("m-0 font-display text-lg font-medium", className)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Keep dialog actions together at the end of the dialog. */
|
|
50
|
+
function DialogFooter({ className, ...props }: ComponentProps<"div">) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
data-slot="dialog-footer"
|
|
54
|
+
className={cn("flex flex-wrap justify-end gap-2", className)}
|
|
61
55
|
{...props}
|
|
62
56
|
/>
|
|
63
57
|
);
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
export { Dialog, DialogClose, DialogContent, DialogTitle };
|
|
60
|
+
export { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
|
|
3
|
+
import { cn } from "@dashboard/lib/utils";
|
|
4
|
+
|
|
5
|
+
type DrawerContextProps = {
|
|
6
|
+
modal: DrawerPrimitive.Root.Props["modal"];
|
|
7
|
+
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DrawerContext = React.createContext<DrawerContextProps | null>(null);
|
|
11
|
+
|
|
12
|
+
function useDrawer() {
|
|
13
|
+
const context = React.useContext(DrawerContext);
|
|
14
|
+
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error("useDrawer must be used within a Drawer.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Own the open state, swipe dismissal, and nesting for a shadcn drawer.
|
|
24
|
+
* A drawer rendered inside another drawer stacks in front of it.
|
|
25
|
+
*/
|
|
26
|
+
function Drawer({
|
|
27
|
+
modal = true,
|
|
28
|
+
swipeDirection = "down",
|
|
29
|
+
...props
|
|
30
|
+
}: DrawerPrimitive.Root.Props) {
|
|
31
|
+
const contextValue = React.useMemo(
|
|
32
|
+
() => ({ modal, swipeDirection }),
|
|
33
|
+
[modal, swipeDirection],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<DrawerContext.Provider value={contextValue}>
|
|
38
|
+
<DrawerPrimitive.Root
|
|
39
|
+
data-slot="drawer"
|
|
40
|
+
modal={modal}
|
|
41
|
+
swipeDirection={swipeDirection}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
</DrawerContext.Provider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
|
|
49
|
+
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Close the drawer through Base UI dismissal and focus handling. */
|
|
53
|
+
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
|
|
54
|
+
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function DrawerOverlay({
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: DrawerPrimitive.Backdrop.Props) {
|
|
61
|
+
return (
|
|
62
|
+
<DrawerPrimitive.Backdrop
|
|
63
|
+
data-slot="drawer-overlay"
|
|
64
|
+
className={cn(
|
|
65
|
+
"fixed inset-0 z-50 min-h-dvh bg-dashboard-overlay opacity-[calc(1-var(--drawer-swipe-progress))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none motion-reduce:transition-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute",
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Place the drawer on its swipe edge. While a nested drawer is open, this
|
|
75
|
+
* drawer scales back, peeks out behind it, and hides its content.
|
|
76
|
+
*/
|
|
77
|
+
function DrawerContent({
|
|
78
|
+
className,
|
|
79
|
+
children,
|
|
80
|
+
...props
|
|
81
|
+
}: DrawerPrimitive.Popup.Props) {
|
|
82
|
+
const { modal, swipeDirection } = useDrawer();
|
|
83
|
+
const swipeAxis =
|
|
84
|
+
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<DrawerPortal>
|
|
88
|
+
{/* App drawers trap focus without a modal root, so keep the overlay.
|
|
89
|
+
Base UI skips it for a nested drawer. */}
|
|
90
|
+
<DrawerOverlay />
|
|
91
|
+
<DrawerPrimitive.Viewport
|
|
92
|
+
data-slot="drawer-viewport"
|
|
93
|
+
data-modal={modal}
|
|
94
|
+
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
|
|
95
|
+
>
|
|
96
|
+
<DrawerPrimitive.Popup
|
|
97
|
+
data-slot="drawer-popup"
|
|
98
|
+
data-swipe-axis={swipeAxis}
|
|
99
|
+
className={cn(
|
|
100
|
+
// Base.
|
|
101
|
+
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col border-dashboard-border-strong bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none motion-reduce:transition-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
|
|
102
|
+
// Nested.
|
|
103
|
+
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
|
|
104
|
+
// Bleed.
|
|
105
|
+
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
|
|
106
|
+
// Sizing.
|
|
107
|
+
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
|
|
108
|
+
// Stack.
|
|
109
|
+
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
|
|
110
|
+
// Transitions.
|
|
111
|
+
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
|
|
112
|
+
// Axis: y.
|
|
113
|
+
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
|
|
114
|
+
// Axis: x.
|
|
115
|
+
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
|
|
116
|
+
// Direction: down.
|
|
117
|
+
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
|
|
118
|
+
// Direction: up.
|
|
119
|
+
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
|
|
120
|
+
// Direction: left.
|
|
121
|
+
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
|
|
122
|
+
// Direction: right.
|
|
123
|
+
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
{...props}
|
|
127
|
+
>
|
|
128
|
+
<DrawerPrimitive.Content
|
|
129
|
+
data-slot="drawer-content"
|
|
130
|
+
className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text motion-reduce:transition-none group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none"
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</DrawerPrimitive.Content>
|
|
134
|
+
</DrawerPrimitive.Popup>
|
|
135
|
+
</DrawerPrimitive.Viewport>
|
|
136
|
+
</DrawerPortal>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { Drawer, DrawerClose, DrawerContent };
|