@sentry/junior-dashboard 0.233.0 → 0.235.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 +178 -29
- package/dist/client.js +53 -50
- package/dist/index.js +178 -29
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/assets.ts +1 -0
- package/src/client/App.tsx +372 -345
- package/src/client/api.ts +60 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +19 -6
- package/src/client/components/SegmentedNav.tsx +167 -0
- package/src/client/components/SegmentedTabs.tsx +24 -5
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
- package/src/client/pages/automations/AutomationEditPage.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +179 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +343 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +186 -439
- package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationFormat.ts +43 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/code/CodeChangeRow.tsx +41 -0
- package/src/client/pages/code/CodePage.tsx +18 -93
- package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
- package/src/client/pages/code/RepositoryPage.tsx +227 -0
- package/src/client/pages/code/RepositorySections.tsx +108 -0
- package/src/client/pages/code/RepositoryTabs.tsx +146 -0
- package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
- package/src/client/pages/dev/ComponentsPage.tsx +55 -26
- package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
- package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
- package/src/client/pages/system/SystemNavigation.tsx +6 -2
- package/src/client/pages/system/SystemPageLayout.tsx +4 -6
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/code-repository.ts +77 -0
- package/src/mock-reporting/fixtures.ts +9 -3
- package/src/mock-reporting/routes.ts +39 -17
- package/src/shell.ts +21 -5
package/src/client/api.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
conversationStatsReportSchema,
|
|
7
7
|
codeOverviewReportSchema,
|
|
8
8
|
codePersonReportSchema,
|
|
9
|
+
codeRepositoryReportSchema,
|
|
9
10
|
statsReportSchema,
|
|
10
11
|
} from "@sentry/junior/api/schema";
|
|
11
12
|
import {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
personalSpendReportSchema,
|
|
17
18
|
automationExecutionListSchema,
|
|
18
19
|
automationListSchema,
|
|
20
|
+
automationSummarySchema,
|
|
19
21
|
automationRunListSchema,
|
|
20
22
|
} from "@sentry/junior/api/schema";
|
|
21
23
|
import {
|
|
@@ -114,6 +116,48 @@ export function useCodeOverviewData() {
|
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/** Fetch code activity, recent changes, and Workspaces for one repository. */
|
|
120
|
+
export function useCodeRepositoryData(repositoryId: string | undefined) {
|
|
121
|
+
return useQuery({
|
|
122
|
+
enabled: Boolean(repositoryId),
|
|
123
|
+
queryKey: ["dashboard", "code", "repositories", repositoryId],
|
|
124
|
+
queryFn: ({ signal }) =>
|
|
125
|
+
fetchDashboardJson(
|
|
126
|
+
codeRepositoryReportSchema,
|
|
127
|
+
`/api/code/repositories/${encodeURIComponent(repositoryId!)}`,
|
|
128
|
+
signal,
|
|
129
|
+
),
|
|
130
|
+
retry: false,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fetch conversations linked to one repository through its code changes. */
|
|
135
|
+
export function useCodeRepositoryConversationsData(
|
|
136
|
+
repositoryId: string | undefined,
|
|
137
|
+
search = "",
|
|
138
|
+
) {
|
|
139
|
+
return useQuery({
|
|
140
|
+
enabled: Boolean(repositoryId),
|
|
141
|
+
queryKey: [
|
|
142
|
+
"dashboard",
|
|
143
|
+
"conversations",
|
|
144
|
+
"code-repository",
|
|
145
|
+
{ repositoryId, search },
|
|
146
|
+
],
|
|
147
|
+
queryFn: ({ signal }) => {
|
|
148
|
+
const params = new URLSearchParams({ codeRepositoryId: repositoryId! });
|
|
149
|
+
if (search) params.set("q", search);
|
|
150
|
+
return fetchDashboardJson(
|
|
151
|
+
conversationFeedSchema,
|
|
152
|
+
`/api/conversations?${params.toString()}`,
|
|
153
|
+
signal,
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
placeholderData: keepPreviousData,
|
|
157
|
+
retry: false,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
117
161
|
/** Fetch the signed-in viewer's scheduled and event automations. */
|
|
118
162
|
export function useAutomationsData(enabled: boolean, search: string) {
|
|
119
163
|
return useQuery({
|
|
@@ -122,7 +166,7 @@ export function useAutomationsData(enabled: boolean, search: string) {
|
|
|
122
166
|
queryFn: ({ signal }) =>
|
|
123
167
|
fetchDashboardJson(
|
|
124
168
|
automationListSchema,
|
|
125
|
-
`/api/automations${search ?
|
|
169
|
+
`/api/automations${search ? `?${search}` : ""}`,
|
|
126
170
|
signal,
|
|
127
171
|
),
|
|
128
172
|
placeholderData: keepPreviousData,
|
|
@@ -130,6 +174,21 @@ export function useAutomationsData(enabled: boolean, search: string) {
|
|
|
130
174
|
});
|
|
131
175
|
}
|
|
132
176
|
|
|
177
|
+
/** Load details by ID, independent of list filters and pagination. */
|
|
178
|
+
export function useAutomationData(enabled: boolean, id: string | undefined) {
|
|
179
|
+
return useQuery({
|
|
180
|
+
enabled: enabled && Boolean(id),
|
|
181
|
+
queryKey: ["dashboard", "automations", "detail", id],
|
|
182
|
+
queryFn: ({ signal }) =>
|
|
183
|
+
fetchDashboardJson(
|
|
184
|
+
automationSummarySchema,
|
|
185
|
+
`/api/automations/${encodeURIComponent(id!)}`,
|
|
186
|
+
signal,
|
|
187
|
+
),
|
|
188
|
+
retry: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
133
192
|
/** Fetch newest runs across all viewer-visible automations. */
|
|
134
193
|
export function useAutomationRunsData(enabled: boolean) {
|
|
135
194
|
return useQuery({
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { cn } from "../styles";
|
|
3
|
+
|
|
4
|
+
/** Show an actor's image, with initials when the image is missing or fails. */
|
|
5
|
+
export function ActorAvatar(props: {
|
|
6
|
+
name: string;
|
|
7
|
+
imageUrl?: string;
|
|
8
|
+
size: "detail" | "list";
|
|
9
|
+
}) {
|
|
10
|
+
const [failedUrl, setFailedUrl] = useState<string>();
|
|
11
|
+
const words = props.name.trim().split(/\s+/).filter(Boolean);
|
|
12
|
+
const initials =
|
|
13
|
+
words.length > 1
|
|
14
|
+
? `${words[0]![0]}${words.at(-1)![0]}`
|
|
15
|
+
: props.name.slice(0, 2);
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
className={cn(
|
|
20
|
+
"relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm",
|
|
21
|
+
props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{initials.toUpperCase()}
|
|
25
|
+
{props.imageUrl && props.imageUrl !== failedUrl ? (
|
|
26
|
+
<img
|
|
27
|
+
alt=""
|
|
28
|
+
className="absolute inset-0 size-full object-cover"
|
|
29
|
+
loading="lazy"
|
|
30
|
+
onError={() => setFailedUrl(props.imageUrl)}
|
|
31
|
+
referrerPolicy="no-referrer"
|
|
32
|
+
src={props.imageUrl}
|
|
33
|
+
/>
|
|
34
|
+
) : null}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Link, type LinkProps } from "react-router";
|
|
|
4
4
|
import { cn } from "../styles";
|
|
5
5
|
|
|
6
6
|
type ButtonSize = "default" | "icon";
|
|
7
|
-
type ButtonTone = "default" | "danger";
|
|
7
|
+
type ButtonTone = "default" | "danger" | "primary";
|
|
8
8
|
type ToggleButtonVariant = "pill" | "segment" | "text";
|
|
9
9
|
|
|
10
10
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
@@ -44,15 +44,17 @@ export function Button({
|
|
|
44
44
|
? "text-dashboard-text-muted hover:!bg-rose-300/10 hover:!text-rose-300 focus-visible:outline-rose-300/55 disabled:hover:!text-dashboard-text-muted"
|
|
45
45
|
: "text-dashboard-text-muted hover:!bg-dashboard-fill-strong hover:!text-dashboard-text focus-visible:outline-cyan-300/55 disabled:hover:!text-dashboard-text-muted",
|
|
46
46
|
)
|
|
47
|
-
:
|
|
48
|
-
?
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
: tone === "primary"
|
|
48
|
+
? "inline-flex h-9 items-center justify-center gap-2 rounded border border-dashboard-text-solid bg-dashboard-text-solid px-3 font-sans text-sm font-semibold text-dashboard-text-inverse hover:bg-dashboard-text-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
49
|
+
: isDanger
|
|
50
|
+
? cn(
|
|
51
|
+
dangerButtonClassName,
|
|
52
|
+
"disabled:hover:border-dashboard-border-strong disabled:hover:text-dashboard-text-muted",
|
|
53
|
+
)
|
|
54
|
+
: cn(
|
|
55
|
+
buttonClassName,
|
|
56
|
+
"disabled:hover:border-dashboard-border-emphasis disabled:hover:bg-dashboard-surface-raised disabled:hover:text-dashboard-text",
|
|
57
|
+
),
|
|
56
58
|
props.disabled ? "" : "cursor-pointer",
|
|
57
59
|
className,
|
|
58
60
|
)}
|
|
@@ -20,6 +20,7 @@ export function Field(props: {
|
|
|
20
20
|
children: ReactNode;
|
|
21
21
|
className?: string;
|
|
22
22
|
help?: ReactNode;
|
|
23
|
+
error?: string;
|
|
23
24
|
htmlFor?: string;
|
|
24
25
|
label: ReactNode;
|
|
25
26
|
size?: FieldSize;
|
|
@@ -31,8 +32,20 @@ export function Field(props: {
|
|
|
31
32
|
{props.label}
|
|
32
33
|
</label>
|
|
33
34
|
{props.children}
|
|
35
|
+
{props.error ? (
|
|
36
|
+
<p
|
|
37
|
+
id={props.htmlFor ? `${props.htmlFor}-error` : undefined}
|
|
38
|
+
role="alert"
|
|
39
|
+
className="m-0 text-xs leading-relaxed text-rose-300"
|
|
40
|
+
>
|
|
41
|
+
{props.error}
|
|
42
|
+
</p>
|
|
43
|
+
) : null}
|
|
34
44
|
{props.help ? (
|
|
35
|
-
<p
|
|
45
|
+
<p
|
|
46
|
+
id={props.htmlFor ? `${props.htmlFor}-help` : undefined}
|
|
47
|
+
className="m-0 text-xs leading-relaxed text-dashboard-text-muted sm:text-sm"
|
|
48
|
+
>
|
|
36
49
|
{props.help}
|
|
37
50
|
</p>
|
|
38
51
|
) : null}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { TriangleAlert } from "lucide-react";
|
|
3
|
+
import { cn } from "../styles";
|
|
4
|
+
|
|
5
|
+
/** Keep form recovery text and actions visible without truncation. */
|
|
6
|
+
export function FormNotice(props: {
|
|
7
|
+
title: string;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
tone?: "error" | "warning";
|
|
10
|
+
}) {
|
|
11
|
+
const error = props.tone === "error";
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
role={error ? "alert" : "status"}
|
|
15
|
+
className={cn(
|
|
16
|
+
"flex min-w-0 gap-3 rounded-md border p-4",
|
|
17
|
+
error
|
|
18
|
+
? "border-rose-300/25 bg-rose-300/5"
|
|
19
|
+
: "border-amber-300/25 bg-amber-300/5",
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
22
|
+
<TriangleAlert
|
|
23
|
+
aria-hidden
|
|
24
|
+
size={18}
|
|
25
|
+
className={cn(
|
|
26
|
+
"mt-0.5 shrink-0",
|
|
27
|
+
error ? "text-rose-300" : "text-amber-300",
|
|
28
|
+
)}
|
|
29
|
+
/>
|
|
30
|
+
<div className="min-w-0">
|
|
31
|
+
<p className="m-0 text-sm font-medium">{props.title}</p>
|
|
32
|
+
{props.children ? (
|
|
33
|
+
<div className="mt-1 text-sm leading-relaxed text-dashboard-text-muted">
|
|
34
|
+
{props.children}
|
|
35
|
+
</div>
|
|
36
|
+
) : null}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
|
3
3
|
import { actorLabel } from "../format";
|
|
4
4
|
import { cn } from "../styles";
|
|
5
5
|
import type { Conversation } from "../types";
|
|
6
|
+
import { ActorAvatar } from "./ActorAvatar";
|
|
6
7
|
import { Tooltip } from "./Tooltip";
|
|
7
8
|
|
|
8
9
|
const MAX_VISIBLE_PARTICIPANTS = 3;
|
|
@@ -28,15 +29,6 @@ function participantDescription(participant: ActorIdentity): string {
|
|
|
28
29
|
return email && email !== name ? `${name}, ${email}` : name;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function participantInitials(participant: ActorIdentity): string {
|
|
32
|
-
const name = participantName(participant);
|
|
33
|
-
const words = name.split(/\s+/).filter(Boolean);
|
|
34
|
-
if (words.length > 1) {
|
|
35
|
-
return `${words[0]![0] ?? ""}${words.at(-1)?.[0] ?? ""}`.toUpperCase();
|
|
36
|
-
}
|
|
37
|
-
return name.slice(0, 2).toUpperCase();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
32
|
function participantKey(participant: ActorIdentity, index: number): string {
|
|
41
33
|
return (
|
|
42
34
|
participant.email?.trim().toLowerCase() ||
|
|
@@ -72,14 +64,14 @@ function Avatar(props: {
|
|
|
72
64
|
>
|
|
73
65
|
<span
|
|
74
66
|
aria-label={label}
|
|
75
|
-
className=
|
|
76
|
-
"relative inline-grid shrink-0 place-items-center rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
|
|
77
|
-
props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
|
|
78
|
-
)}
|
|
67
|
+
className="relative inline-flex rounded-full focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
|
|
79
68
|
onKeyDown={(event) => event.stopPropagation()}
|
|
80
69
|
tabIndex={0}
|
|
81
70
|
>
|
|
82
|
-
|
|
71
|
+
<ActorAvatar
|
|
72
|
+
name={participantName(props.participant)}
|
|
73
|
+
size={props.size}
|
|
74
|
+
/>
|
|
83
75
|
</span>
|
|
84
76
|
</Tooltip>
|
|
85
77
|
);
|
|
@@ -6,15 +6,21 @@ instead of copying Tailwind blocks.
|
|
|
6
6
|
## Layers
|
|
7
7
|
|
|
8
8
|
1. **Primitives** — `Button` / `ToggleButton`, `Field`, `TextInput`,
|
|
9
|
-
`StatusChip`, `StatusDot`, `Notice`, `Drawer`, `Tooltip`, `Skeleton`,
|
|
10
|
-
`PageContentSkeleton`, `SegmentedTabs`, and other small reusable controls.
|
|
11
|
-
Use
|
|
12
|
-
|
|
13
|
-
and
|
|
9
|
+
`StatusChip`, `StatusDot`, `Notice`, `FormNotice`, `Select`, `Drawer`, `Tooltip`, `Skeleton`,
|
|
10
|
+
`PageContentSkeleton`, `SegmentedTabs`, `SegmentedNav`, and other small reusable controls.
|
|
11
|
+
Use the segmented pill track to pick one of a few siblings. Use
|
|
12
|
+
`SegmentedTabs` to switch panels in place. Pass the selected value, items,
|
|
13
|
+
change handler, and selected panel content. It owns tab/panel labels and
|
|
14
|
+
keyboard focus. Use `SegmentedNav` for links between sibling pages. With
|
|
15
|
+
`fill`, links share the row width equally while their labels fit. Otherwise
|
|
16
|
+
each link keeps its label width. Rows that do not fit scroll sideways. Keep
|
|
17
|
+
collection filters in `FilterTabList`. Prefer
|
|
14
18
|
`ToggleButton` variants (`pill`, `segment`, `text`) over one-off pressed
|
|
15
19
|
styles. Prefer `Field` sizes (`default`, `compact`) over handwritten labels.
|
|
16
20
|
Prefer `StatusDot` for dense table or list status markers and `StatusChip`
|
|
17
|
-
when the label itself must stay visible.
|
|
21
|
+
when the label itself must stay visible. `StatusChip` uses a small dot and
|
|
22
|
+
sentence-case text, without a border, fill, or all-caps styling. It is not
|
|
23
|
+
an interactive control. Prefer `PageContentSkeleton`
|
|
18
24
|
variants over ad-hoc pulse blocks in route modules. Use `PageRouteLoading`
|
|
19
25
|
when a standard page must keep its real header above a body skeleton. Use a
|
|
20
26
|
feature-owned loading component for distinct geometry, such as conversations.
|
|
@@ -54,3 +60,10 @@ instead of copying Tailwind blocks.
|
|
|
54
60
|
- One-off page glue may stay inline when extraction only adds indirection.
|
|
55
61
|
|
|
56
62
|
See `policies/frontend-components.md`.
|
|
63
|
+
|
|
64
|
+
Use `TextInput size="comfortable"` for larger mobile form controls.
|
|
65
|
+
Form pages use `Button tone="primary"` for their one main save action. Use
|
|
66
|
+
`TextArea prose` for instructions rather than code. `Field error` renders an
|
|
67
|
+
error at `<htmlFor>-error`; the owning control must set `aria-invalid` and
|
|
68
|
+
`aria-describedby`. `Select` matches native text controls. Use `FormNotice` for
|
|
69
|
+
wrapping form recovery text; use `Notice` for compact transient messages.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
2
|
+
import { Link, NavLink, useLocation } from "react-router";
|
|
3
|
+
|
|
4
|
+
import { pathWithSearch } from "../searchParams";
|
|
5
|
+
import {
|
|
6
|
+
segmentedTabClass,
|
|
7
|
+
segmentedTabsFrameClass,
|
|
8
|
+
segmentedTabsInsetClass,
|
|
9
|
+
} from "./SegmentedTabs";
|
|
10
|
+
import { cn } from "../styles";
|
|
11
|
+
|
|
12
|
+
export type SegmentedNavItem = {
|
|
13
|
+
end?: boolean;
|
|
14
|
+
/** Override default NavLink matching when a path family should stay active. */
|
|
15
|
+
isActive?: (pathname: string) => boolean;
|
|
16
|
+
label: string;
|
|
17
|
+
to: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Link to a sibling page and keep page filters (range, scope, q). */
|
|
21
|
+
export function SiblingPageLink(props: {
|
|
22
|
+
className: (state: { isActive: boolean }) => string;
|
|
23
|
+
item: SegmentedNavItem;
|
|
24
|
+
}) {
|
|
25
|
+
const { className, item } = props;
|
|
26
|
+
const location = useLocation();
|
|
27
|
+
const to = pathWithSearch(item.to, location.search);
|
|
28
|
+
if (item.isActive) {
|
|
29
|
+
const isActive = item.isActive(location.pathname);
|
|
30
|
+
return (
|
|
31
|
+
<Link
|
|
32
|
+
aria-current={isActive ? "page" : undefined}
|
|
33
|
+
className={className({ isActive })}
|
|
34
|
+
to={to}
|
|
35
|
+
>
|
|
36
|
+
{item.label}
|
|
37
|
+
</Link>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<NavLink className={className} end={item.end} to={to}>
|
|
43
|
+
{item.label}
|
|
44
|
+
</NavLink>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Width of the fade on an edge that hides more links. */
|
|
49
|
+
const EDGE_FADE_PX = 24;
|
|
50
|
+
|
|
51
|
+
type HiddenEdges = { end: boolean; start: boolean };
|
|
52
|
+
|
|
53
|
+
function hiddenEdges(row: HTMLElement): HiddenEdges {
|
|
54
|
+
const maxScroll = row.scrollWidth - row.clientWidth;
|
|
55
|
+
return {
|
|
56
|
+
end: row.scrollLeft < maxScroll - 1,
|
|
57
|
+
start: row.scrollLeft > 1,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Scroll the current page link into view when the row or a fade covers it. */
|
|
62
|
+
function revealCurrentLink(row: HTMLElement) {
|
|
63
|
+
const link = row.querySelector<HTMLElement>('[aria-current="page"]');
|
|
64
|
+
if (!link || row.clientWidth === 0) return;
|
|
65
|
+
const edges = hiddenEdges(row);
|
|
66
|
+
const visibleStart = row.scrollLeft + (edges.start ? EDGE_FADE_PX : 0);
|
|
67
|
+
const visibleEnd =
|
|
68
|
+
row.scrollLeft + row.clientWidth - (edges.end ? EDGE_FADE_PX : 0);
|
|
69
|
+
const start = link.offsetLeft;
|
|
70
|
+
const end = start + link.offsetWidth;
|
|
71
|
+
if (start >= visibleStart && end <= visibleEnd) return;
|
|
72
|
+
// scrollIntoView can also scroll the page, so set scrollLeft on the row.
|
|
73
|
+
row.scrollLeft = start - (row.clientWidth - link.offsetWidth) / 2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fade each edge of the row that hides more links. */
|
|
77
|
+
function edgeFade(edges: HiddenEdges): CSSProperties | undefined {
|
|
78
|
+
if (!edges.start && !edges.end) return undefined;
|
|
79
|
+
const fade = `${EDGE_FADE_PX}px`;
|
|
80
|
+
const start = edges.start ? `transparent, black ${fade}` : "black";
|
|
81
|
+
const end = edges.end ? `black calc(100% - ${fade}), transparent` : "black";
|
|
82
|
+
const mask = `linear-gradient(to right, ${start}, ${end})`;
|
|
83
|
+
return { maskImage: mask, WebkitMaskImage: mask };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Show links between sibling pages in the segmented pill track.
|
|
88
|
+
*
|
|
89
|
+
* With `fill`, links share the row width equally while their labels fit.
|
|
90
|
+
* Otherwise each link keeps its label width. When the links do not fit, the
|
|
91
|
+
* row scrolls sideways and fades the edge that hides more links.
|
|
92
|
+
*/
|
|
93
|
+
export function SegmentedNav(props: {
|
|
94
|
+
ariaLabel: string;
|
|
95
|
+
fill?: boolean;
|
|
96
|
+
items: readonly SegmentedNavItem[];
|
|
97
|
+
}) {
|
|
98
|
+
const { pathname } = useLocation();
|
|
99
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
100
|
+
const [edges, setEdges] = useState<HiddenEdges>({
|
|
101
|
+
end: false,
|
|
102
|
+
start: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const row = rowRef.current;
|
|
107
|
+
if (!row) return;
|
|
108
|
+
const updateEdges = () => {
|
|
109
|
+
const { end, start } = hiddenEdges(row);
|
|
110
|
+
setEdges((previous) =>
|
|
111
|
+
previous.start === start && previous.end === end
|
|
112
|
+
? previous
|
|
113
|
+
: { end, start },
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
// A row hidden at this breakpoint has no width. Reveal the current link
|
|
117
|
+
// when it gets one.
|
|
118
|
+
const observer = new ResizeObserver(() => {
|
|
119
|
+
revealCurrentLink(row);
|
|
120
|
+
updateEdges();
|
|
121
|
+
});
|
|
122
|
+
updateEdges();
|
|
123
|
+
row.addEventListener("scroll", updateEdges, { passive: true });
|
|
124
|
+
observer.observe(row);
|
|
125
|
+
return () => {
|
|
126
|
+
row.removeEventListener("scroll", updateEdges);
|
|
127
|
+
observer.disconnect();
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
useLayoutEffect(() => {
|
|
132
|
+
if (rowRef.current) revealCurrentLink(rowRef.current);
|
|
133
|
+
}, [pathname]);
|
|
134
|
+
|
|
135
|
+
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
136
|
+
cn(
|
|
137
|
+
segmentedTabClass(isActive),
|
|
138
|
+
"flex items-center justify-center whitespace-nowrap no-underline",
|
|
139
|
+
props.fill ? "min-w-max flex-[1_0_0]" : "shrink-0",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<nav
|
|
144
|
+
aria-label={props.ariaLabel}
|
|
145
|
+
className={cn(
|
|
146
|
+
segmentedTabsFrameClass,
|
|
147
|
+
"flex min-w-0 max-w-full",
|
|
148
|
+
!props.fill && "w-fit",
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{/* Pad the scrolling row, not the frame, so the last link keeps its
|
|
152
|
+
padding when the row is scrolled to the end. */}
|
|
153
|
+
<div
|
|
154
|
+
className={cn(
|
|
155
|
+
segmentedTabsInsetClass,
|
|
156
|
+
"relative flex min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
157
|
+
)}
|
|
158
|
+
ref={rowRef}
|
|
159
|
+
style={edgeFade(edges)}
|
|
160
|
+
>
|
|
161
|
+
{props.items.map((item) => (
|
|
162
|
+
<SiblingPageLink className={linkClass} item={item} key={item.to} />
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</nav>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -2,6 +2,23 @@ import { useId, type ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "../styles";
|
|
4
4
|
|
|
5
|
+
/** Style the border and fill of the segmented pill track. */
|
|
6
|
+
export const segmentedTabsFrameClass =
|
|
7
|
+
"rounded-lg border border-dashboard-border bg-dashboard-surface-panel";
|
|
8
|
+
|
|
9
|
+
/** Style the spacing inside the segmented pill track. */
|
|
10
|
+
export const segmentedTabsInsetClass = "gap-1 p-1";
|
|
11
|
+
|
|
12
|
+
/** Style one tab or link in the segmented pill track. */
|
|
13
|
+
export function segmentedTabClass(selected: boolean): string {
|
|
14
|
+
return cn(
|
|
15
|
+
"min-h-9 cursor-pointer rounded-md border-0 px-3 py-2 font-sans text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
|
|
16
|
+
selected
|
|
17
|
+
? "bg-dashboard-fill-strong text-dashboard-text"
|
|
18
|
+
: "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/** Switch between related panels with equal-width tabs and keyboard navigation. */
|
|
6
23
|
export function SegmentedTabs<const Value extends string>(props: {
|
|
7
24
|
children: ReactNode;
|
|
@@ -16,7 +33,11 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
16
33
|
<div className="grid min-w-0 gap-4">
|
|
17
34
|
<div
|
|
18
35
|
aria-label={props.label}
|
|
19
|
-
className=
|
|
36
|
+
className={cn(
|
|
37
|
+
"grid auto-cols-fr grid-flow-col",
|
|
38
|
+
segmentedTabsFrameClass,
|
|
39
|
+
segmentedTabsInsetClass,
|
|
40
|
+
)}
|
|
20
41
|
role="tablist"
|
|
21
42
|
>
|
|
22
43
|
{props.items.map((item, index) => (
|
|
@@ -24,10 +45,8 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
24
45
|
aria-controls={`${id}-panel`}
|
|
25
46
|
aria-selected={props.value === item.value}
|
|
26
47
|
className={cn(
|
|
27
|
-
"min-
|
|
28
|
-
props.value === item.value
|
|
29
|
-
? "bg-dashboard-fill-strong text-dashboard-text"
|
|
30
|
-
: "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
|
|
48
|
+
"min-w-0",
|
|
49
|
+
segmentedTabClass(props.value === item.value),
|
|
31
50
|
)}
|
|
32
51
|
id={`${id}-${item.value}`}
|
|
33
52
|
key={item.value}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SelectHTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../styles";
|
|
3
|
+
|
|
4
|
+
/** Match native form choices to the dashboard text controls. */
|
|
5
|
+
export function Select({
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
9
|
+
return (
|
|
10
|
+
<select
|
|
11
|
+
{...props}
|
|
12
|
+
className={cn(
|
|
13
|
+
"block min-h-11 w-full min-w-0 rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-base text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-10 sm:text-sm",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -13,21 +13,20 @@ export type StatusChipTone =
|
|
|
13
13
|
export type StatusChipSize = "default" | "compact";
|
|
14
14
|
|
|
15
15
|
const toneClass: Record<StatusChipTone, string> = {
|
|
16
|
-
accent: "
|
|
17
|
-
danger: "
|
|
18
|
-
info: "
|
|
19
|
-
neutral: "
|
|
20
|
-
success: "
|
|
21
|
-
warning: "
|
|
16
|
+
accent: "text-violet-300",
|
|
17
|
+
danger: "text-rose-300",
|
|
18
|
+
info: "text-cyan-300",
|
|
19
|
+
neutral: "text-dashboard-text-muted",
|
|
20
|
+
success: "text-emerald-300",
|
|
21
|
+
warning: "text-amber-300",
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const sizeClass: Record<StatusChipSize, string> = {
|
|
25
|
-
compact:
|
|
26
|
-
|
|
27
|
-
default: "px-2 py-1 font-mono text-xs uppercase tracking-[0.1em]",
|
|
25
|
+
compact: "text-xs",
|
|
26
|
+
default: "text-sm",
|
|
28
27
|
};
|
|
29
28
|
|
|
30
|
-
/**
|
|
29
|
+
/** Show status as quiet text, not a button-shaped badge. */
|
|
31
30
|
export function StatusChip(props: {
|
|
32
31
|
children: ReactNode;
|
|
33
32
|
className?: string;
|
|
@@ -37,13 +36,17 @@ export function StatusChip(props: {
|
|
|
37
36
|
return (
|
|
38
37
|
<span
|
|
39
38
|
className={cn(
|
|
40
|
-
"inline-flex w-fit items-center
|
|
39
|
+
"inline-flex w-fit items-center gap-1.5 font-sans font-medium normal-case leading-snug tracking-normal",
|
|
41
40
|
sizeClass[props.size ?? "default"],
|
|
42
41
|
toneClass[props.tone ?? "neutral"],
|
|
43
42
|
props.className,
|
|
44
43
|
)}
|
|
45
44
|
>
|
|
46
|
-
|
|
45
|
+
<span
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
className="size-1.5 shrink-0 rounded-full bg-current"
|
|
48
|
+
/>
|
|
49
|
+
<span className="block first-letter:uppercase">{props.children}</span>
|
|
47
50
|
</span>
|
|
48
51
|
);
|
|
49
52
|
}
|
|
@@ -3,36 +3,56 @@ import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
|
3
3
|
import { cn } from "../styles";
|
|
4
4
|
|
|
5
5
|
const textControlClassName =
|
|
6
|
-
"block w-full rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-sm text-dashboard-text focus:border-dashboard-focus focus:outline-none disabled:cursor-not-allowed disabled:opacity-50";
|
|
6
|
+
"block w-full rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-sm text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-50";
|
|
7
7
|
|
|
8
8
|
export type TextInputProps = Omit<
|
|
9
9
|
InputHTMLAttributes<HTMLInputElement>,
|
|
10
10
|
"size"
|
|
11
11
|
> & {
|
|
12
12
|
className?: string;
|
|
13
|
+
size?: "default" | "comfortable";
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
/** Render the dashboard's standard single-line text control. */
|
|
16
|
-
export function TextInput({
|
|
17
|
+
export function TextInput({
|
|
18
|
+
className,
|
|
19
|
+
type = "text",
|
|
20
|
+
size = "default",
|
|
21
|
+
...props
|
|
22
|
+
}: TextInputProps) {
|
|
17
23
|
return (
|
|
18
24
|
<input
|
|
19
25
|
{...props}
|
|
20
|
-
className={cn(
|
|
26
|
+
className={cn(
|
|
27
|
+
textControlClassName,
|
|
28
|
+
size === "comfortable" && "min-h-11 text-base sm:min-h-10 sm:text-sm",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
21
31
|
type={type}
|
|
22
32
|
/>
|
|
23
33
|
);
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
37
|
+
prose?: boolean;
|
|
27
38
|
className?: string;
|
|
28
39
|
};
|
|
29
40
|
|
|
30
41
|
/** Render the dashboard's standard multi-line text control. */
|
|
31
|
-
export function TextArea({
|
|
42
|
+
export function TextArea({
|
|
43
|
+
className,
|
|
44
|
+
prose = false,
|
|
45
|
+
...props
|
|
46
|
+
}: TextAreaProps) {
|
|
32
47
|
return (
|
|
33
48
|
<textarea
|
|
34
49
|
{...props}
|
|
35
|
-
className={cn(
|
|
50
|
+
className={cn(
|
|
51
|
+
textControlClassName,
|
|
52
|
+
"min-h-36",
|
|
53
|
+
prose ? "font-sans leading-relaxed" : "font-mono",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
36
56
|
/>
|
|
37
57
|
);
|
|
38
58
|
}
|