@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.
Files changed (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. 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/25 bg-dashboard-surface-raised text-rose-50 *:data-[slot=alert-description]:text-rose-100/70 *:[svg]:text-current",
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({ ...props }: DialogPrimitive.Root.Props) {
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({ ...props }: DialogPrimitive.Close.Props) {
11
+ function DialogClose(props: DialogPrimitive.Close.Props) {
15
12
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
16
13
  }
17
14
 
18
- function DialogOverlay({
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
- <DialogPortal>
38
- <DialogOverlay />
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-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-raised p-4 text-sm text-dashboard-text shadow-lg outline-none transition-[opacity,scale] duration-180 motion-reduce:transition-none sm:max-w-sm data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:scale-95 data-starting-style:scale-95",
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
- </DialogPortal>
34
+ </DialogPrimitive.Portal>
49
35
  );
50
36
  }
51
37
 
52
- /** Give the dialog its accessible name. */
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
- "m-0 text-base leading-none font-medium text-dashboard-text",
59
- className,
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 };