sid-ui 0.0.0-stage → 0.1.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/README.md +54 -2
- package/package.json +51 -4
- package/src/brand/BrandFooter.tsx +45 -0
- package/src/brand/BrandHeading.tsx +24 -0
- package/src/brand/BrandLockup.tsx +47 -0
- package/src/brand/JoinedLogo.tsx +67 -0
- package/src/brand/JoinedMark.tsx +76 -0
- package/src/brand/geometry.ts +26 -0
- package/src/brand/index.ts +11 -0
- package/src/brand/name.ts +5 -0
- package/src/brand/paint.tsx +56 -0
- package/src/components/Action.tsx +44 -0
- package/src/components/AddressSelector.tsx +99 -0
- package/src/components/BarChart.tsx +132 -0
- package/src/components/Calendar.tsx +284 -0
- package/src/components/CalendarAgenda.tsx +87 -0
- package/src/components/CalendarMonth.tsx +223 -0
- package/src/components/CalendarSchedule.tsx +210 -0
- package/src/components/Card.tsx +80 -0
- package/src/components/Charts.tsx +64 -0
- package/src/components/Chat.tsx +35 -0
- package/src/components/CitySelector.tsx +81 -0
- package/src/components/Clock.tsx +196 -0
- package/src/components/CodeInput.tsx +183 -0
- package/src/components/Container.tsx +20 -0
- package/src/components/Content.tsx +50 -0
- package/src/components/Data.tsx +43 -0
- package/src/components/DataInput.tsx +107 -0
- package/src/components/DateField.tsx +162 -0
- package/src/components/DonutChart.tsx +123 -0
- package/src/components/Drawer.tsx +110 -0
- package/src/components/Feedback.tsx +37 -0
- package/src/components/FileUploader.tsx +377 -0
- package/src/components/FunnelChart.tsx +83 -0
- package/src/components/Glyph.tsx +330 -0
- package/src/components/GoogleSignIn.tsx +67 -0
- package/src/components/GridSystem.tsx +131 -0
- package/src/components/HeatmapCalendar.tsx +167 -0
- package/src/components/KanbanBoard.tsx +421 -0
- package/src/components/Kpi.tsx +61 -0
- package/src/components/LayoutPrimitives.tsx +86 -0
- package/src/components/Lists.tsx +19 -0
- package/src/components/LocationSelector.tsx +49 -0
- package/src/components/MessageList.tsx +80 -0
- package/src/components/Nav.tsx +69 -0
- package/src/components/Navigation.tsx +52 -0
- package/src/components/Notification.tsx +115 -0
- package/src/components/NotificationTrigger.tsx +248 -0
- package/src/components/Overlay.tsx +50 -0
- package/src/components/Page.tsx +143 -0
- package/src/components/PageTabs.tsx +28 -0
- package/src/components/PickerShell.tsx +138 -0
- package/src/components/PillNav.tsx +194 -0
- package/src/components/Primitives.tsx +30 -0
- package/src/components/Rating.tsx +78 -0
- package/src/components/Responsive.tsx +128 -0
- package/src/components/ResponsiveFrame.tsx +122 -0
- package/src/components/Segment.tsx +190 -0
- package/src/components/Sparkline.tsx +52 -0
- package/src/components/StateSelector.tsx +39 -0
- package/src/components/Sticky.tsx +95 -0
- package/src/components/Table.tsx +344 -0
- package/src/components/ThemeToggle.tsx +48 -0
- package/src/components/Tile.tsx +23 -0
- package/src/components/TimeColumns.tsx +155 -0
- package/src/components/TimeDial.tsx +260 -0
- package/src/components/TimeField.tsx +196 -0
- package/src/components/TimeInput.tsx +213 -0
- package/src/components/TimeSlots.tsx +83 -0
- package/src/components/Timeline.tsx +111 -0
- package/src/components/TokenDemo.tsx +378 -0
- package/src/components/Tree.tsx +283 -0
- package/src/components/TrendChart.tsx +211 -0
- package/src/components/breakpoints.ts +42 -0
- package/src/components/calendarTypes.ts +26 -0
- package/src/components/chartMath.ts +125 -0
- package/src/components/chartParts.tsx +116 -0
- package/src/components/codeInputRules.ts +58 -0
- package/src/components/date.ts +87 -0
- package/src/components/hooks.ts +53 -0
- package/src/components/index.ts +195 -0
- package/src/components/kanban.ts +78 -0
- package/src/components/motion.ts +78 -0
- package/src/components/places-search.tsx +73 -0
- package/src/components/places.ts +209 -0
- package/src/components/size.ts +3 -0
- package/src/components/time.ts +83 -0
- package/src/components/useFileDrop.ts +104 -0
- package/src/places/geoapify.ts +129 -0
- package/src/styles/astryx.css +5 -0
- package/src/styles/components/brand.css +98 -0
- package/src/styles/components/calendar.css +680 -0
- package/src/styles/components/charts.css +659 -0
- package/src/styles/components/code-input.css +233 -0
- package/src/styles/components/google.css +11 -0
- package/src/styles/components/message-list.css +153 -0
- package/src/styles/components/notification.css +350 -0
- package/src/styles/components/picker.css +114 -0
- package/src/styles/components/pill-nav.css +179 -0
- package/src/styles/components/responsive.css +419 -0
- package/src/styles/components/sticky.css +23 -0
- package/src/styles/components/table.css +267 -0
- package/src/styles/components/time.css +526 -0
- package/src/styles/components/timeline.css +426 -0
- package/src/styles/components/tree.css +269 -0
- package/src/styles/components.css +423 -0
- package/src/styles/joined.css +4 -0
- package/src/styles/theme.css +79 -0
- package/src/styles/tokens.css +338 -0
- package/src/theme/JoinedProvider.tsx +50 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/joined.d.ts +11 -0
- package/src/theme/joined.js +569 -0
- package/src/theme/joined.theme.ts +74 -0
- package/src/theme/joined.variants.d.ts +16 -0
- package/src/theme/theme.css +786 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button, IconButton } from "./Action";
|
|
6
|
+
import { Kbd, Thumbnail } from "./Content";
|
|
7
|
+
import { Badge, Banner, ProgressBar } from "./Feedback";
|
|
8
|
+
import { icons } from "./Glyph";
|
|
9
|
+
import { Card, HStack, Icon, Stack, Text } from "./Primitives";
|
|
10
|
+
import { formatBytes, matchesAccept, useFileDrop } from "./useFileDrop";
|
|
11
|
+
|
|
12
|
+
export type UploadStatus = "uploading" | "done" | "error";
|
|
13
|
+
|
|
14
|
+
export interface UploadItem {
|
|
15
|
+
id: string;
|
|
16
|
+
file: File;
|
|
17
|
+
status: UploadStatus;
|
|
18
|
+
/** 0–100 while uploading. */
|
|
19
|
+
progress: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
/** Object URL for image previews; revoked when the item is removed. */
|
|
22
|
+
previewUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Upload one file, reporting 0–100 as it goes. Throw to mark the file as failed. */
|
|
26
|
+
export type UploadHandler = (file: File, onProgress: (percent: number) => void) => Promise<void>;
|
|
27
|
+
|
|
28
|
+
export interface FileUploaderProps {
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
/** Same syntax as the input attribute, e.g. ".pdf,image/*". */
|
|
32
|
+
accept?: string;
|
|
33
|
+
/** Largest file in bytes. */
|
|
34
|
+
maxSize?: number;
|
|
35
|
+
maxFiles?: number;
|
|
36
|
+
/** Accept several files. Defaults to true. */
|
|
37
|
+
isMultiple?: boolean;
|
|
38
|
+
isDisabled?: boolean;
|
|
39
|
+
/** Accept files pasted from the clipboard while the uploader has focus. */
|
|
40
|
+
hasPaste?: boolean;
|
|
41
|
+
/** Runs for each accepted file. Without it, files are ready as soon as they are added. */
|
|
42
|
+
upload?: UploadHandler;
|
|
43
|
+
/** Called with every successfully uploaded file, in order. */
|
|
44
|
+
onChange?: (files: File[]) => void;
|
|
45
|
+
/** dropzone is the large target; compact is a single row for dense forms. */
|
|
46
|
+
variant?: "dropzone" | "compact";
|
|
47
|
+
/** list shows name, size, and progress; grid shows image thumbnails. */
|
|
48
|
+
layout?: "list" | "grid";
|
|
49
|
+
/** Replaces the default hint under the prompt. */
|
|
50
|
+
hint?: ReactNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PERCENT = 100;
|
|
54
|
+
|
|
55
|
+
function isImage(file: File) {
|
|
56
|
+
return file.type.startsWith("image/");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function describeLimits(accept?: string, maxSize?: number, maxFiles?: number) {
|
|
60
|
+
const parts = [
|
|
61
|
+
accept &&
|
|
62
|
+
accept
|
|
63
|
+
.split(",")
|
|
64
|
+
.map((t) => t.trim().replace(/^\./, "").replace("/*", "").toUpperCase())
|
|
65
|
+
.join(", "),
|
|
66
|
+
maxSize && `up to ${formatBytes(maxSize)}`,
|
|
67
|
+
maxFiles && `${maxFiles} file${maxFiles === 1 ? "" : "s"} max`,
|
|
68
|
+
];
|
|
69
|
+
return parts.filter(Boolean).join(" · ");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Drag-and-drop uploader. Drop files, browse, or paste; each accepted file
|
|
74
|
+
* uploads with its own progress, preview, retry, and remove. Built only from
|
|
75
|
+
* Astryx parts, so it follows the theme.
|
|
76
|
+
*/
|
|
77
|
+
export function FileUploader({
|
|
78
|
+
label,
|
|
79
|
+
description,
|
|
80
|
+
accept,
|
|
81
|
+
maxSize,
|
|
82
|
+
maxFiles,
|
|
83
|
+
isMultiple = true,
|
|
84
|
+
isDisabled = false,
|
|
85
|
+
hasPaste = false,
|
|
86
|
+
upload,
|
|
87
|
+
onChange,
|
|
88
|
+
variant = "dropzone",
|
|
89
|
+
layout = "list",
|
|
90
|
+
hint,
|
|
91
|
+
}: FileUploaderProps) {
|
|
92
|
+
const [items, setItems] = useState<UploadItem[]>([]);
|
|
93
|
+
const [rejected, setRejected] = useState<string[]>([]);
|
|
94
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
95
|
+
const nextId = useRef(0);
|
|
96
|
+
const itemsRef = useRef(items);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
itemsRef.current = items;
|
|
99
|
+
}, [items]);
|
|
100
|
+
|
|
101
|
+
const patch = useCallback((id: string, next: Partial<UploadItem>) => {
|
|
102
|
+
setItems((all) => all.map((item) => (item.id === id ? { ...item, ...next } : item)));
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const run = useCallback(
|
|
106
|
+
async (item: UploadItem) => {
|
|
107
|
+
if (!upload) {
|
|
108
|
+
patch(item.id, { status: "done", progress: PERCENT });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
patch(item.id, { status: "uploading", progress: 0, error: undefined });
|
|
112
|
+
try {
|
|
113
|
+
await upload(item.file, (percent) =>
|
|
114
|
+
patch(item.id, { progress: Math.min(PERCENT, Math.max(0, percent)) }),
|
|
115
|
+
);
|
|
116
|
+
patch(item.id, { status: "done", progress: PERCENT });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
patch(item.id, {
|
|
119
|
+
status: "error",
|
|
120
|
+
error: error instanceof Error ? error.message : "Upload failed.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[patch, upload],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const add = useCallback(
|
|
128
|
+
(incoming: File[]) => {
|
|
129
|
+
const reasons: string[] = [];
|
|
130
|
+
const room = maxFiles ? Math.max(0, maxFiles - itemsRef.current.length) : Infinity;
|
|
131
|
+
const accepted: UploadItem[] = [];
|
|
132
|
+
for (const file of isMultiple ? incoming : incoming.slice(0, 1)) {
|
|
133
|
+
if (!matchesAccept(file, accept)) reasons.push(`${file.name} isn’t an accepted type.`);
|
|
134
|
+
else if (maxSize && file.size > maxSize)
|
|
135
|
+
reasons.push(`${file.name} is over ${formatBytes(maxSize)}.`);
|
|
136
|
+
else if (accepted.length >= room)
|
|
137
|
+
reasons.push(`${file.name} is over the ${maxFiles}-file limit.`);
|
|
138
|
+
else
|
|
139
|
+
accepted.push({
|
|
140
|
+
id: `upload-${nextId.current++}`,
|
|
141
|
+
file,
|
|
142
|
+
status: "uploading",
|
|
143
|
+
progress: 0,
|
|
144
|
+
previewUrl: isImage(file) ? URL.createObjectURL(file) : undefined,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
setRejected(reasons);
|
|
148
|
+
if (!accepted.length) return;
|
|
149
|
+
setItems((all) => {
|
|
150
|
+
const replaced = isMultiple ? all : [];
|
|
151
|
+
if (!isMultiple)
|
|
152
|
+
all.forEach((item) => item.previewUrl && URL.revokeObjectURL(item.previewUrl));
|
|
153
|
+
return [...replaced, ...accepted];
|
|
154
|
+
});
|
|
155
|
+
accepted.forEach((item) => {
|
|
156
|
+
void run(item);
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[accept, isMultiple, maxFiles, maxSize, run],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const remove = (id: string) => {
|
|
163
|
+
setItems((all) => {
|
|
164
|
+
const gone = all.find((item) => item.id === id);
|
|
165
|
+
if (gone?.previewUrl) URL.revokeObjectURL(gone.previewUrl);
|
|
166
|
+
return all.filter((item) => item.id !== id);
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Report finished files whenever the set of done uploads changes.
|
|
171
|
+
const doneKey = items
|
|
172
|
+
.filter((item) => item.status === "done")
|
|
173
|
+
.map((item) => item.id)
|
|
174
|
+
.join();
|
|
175
|
+
const onChangeRef = useRef(onChange);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
onChangeRef.current = onChange;
|
|
178
|
+
}, [onChange]);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
onChangeRef.current?.(
|
|
181
|
+
itemsRef.current.filter((item) => item.status === "done").map((item) => item.file),
|
|
182
|
+
);
|
|
183
|
+
}, [doneKey]);
|
|
184
|
+
|
|
185
|
+
// Revoke any previews left when the uploader unmounts.
|
|
186
|
+
useEffect(
|
|
187
|
+
() => () =>
|
|
188
|
+
itemsRef.current.forEach((item) => item.previewUrl && URL.revokeObjectURL(item.previewUrl)),
|
|
189
|
+
[],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const { isDragging, dropProps } = useFileDrop({ onFiles: add, isDisabled, hasPaste });
|
|
193
|
+
const full = maxFiles != null && items.length >= maxFiles;
|
|
194
|
+
const limits = hint ?? describeLimits(accept, maxSize, maxFiles);
|
|
195
|
+
const browse = () => inputRef.current?.click();
|
|
196
|
+
const done = items.filter((item) => item.status === "done").length;
|
|
197
|
+
|
|
198
|
+
const picker = (
|
|
199
|
+
<input
|
|
200
|
+
ref={inputRef}
|
|
201
|
+
type="file"
|
|
202
|
+
hidden
|
|
203
|
+
accept={accept}
|
|
204
|
+
multiple={isMultiple}
|
|
205
|
+
disabled={isDisabled}
|
|
206
|
+
onChange={(event) => {
|
|
207
|
+
add(Array.from(event.target.files ?? []));
|
|
208
|
+
event.target.value = "";
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const target =
|
|
214
|
+
variant === "compact" ? (
|
|
215
|
+
<Card variant={isDragging ? "blue" : "muted"} padding={3}>
|
|
216
|
+
<HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
|
|
217
|
+
<HStack gap={2} vAlign="center">
|
|
218
|
+
<Icon icon={icons.upload} color={isDragging ? "accent" : "secondary"} />
|
|
219
|
+
<Stack gap={0}>
|
|
220
|
+
<Text weight="semibold">{isDragging ? "Drop to upload" : "Drag files here"}</Text>
|
|
221
|
+
{limits && (
|
|
222
|
+
<Text type="supporting" color="secondary">
|
|
223
|
+
{limits}
|
|
224
|
+
</Text>
|
|
225
|
+
)}
|
|
226
|
+
</Stack>
|
|
227
|
+
</HStack>
|
|
228
|
+
<Button label="Browse" size="sm" onClick={browse} isDisabled={isDisabled || full} />
|
|
229
|
+
</HStack>
|
|
230
|
+
</Card>
|
|
231
|
+
) : (
|
|
232
|
+
<Card
|
|
233
|
+
variant={isDragging ? "blue" : "muted"}
|
|
234
|
+
padding={6}
|
|
235
|
+
elevation={isDragging ? "low" : "none"}
|
|
236
|
+
>
|
|
237
|
+
<Stack gap={2} hAlign="center">
|
|
238
|
+
<Icon icon={icons.upload} size="lg" color={isDragging ? "accent" : "secondary"} />
|
|
239
|
+
<Text weight="semibold" justify="center">
|
|
240
|
+
{isDragging
|
|
241
|
+
? "Drop to upload"
|
|
242
|
+
: full
|
|
243
|
+
? "File limit reached"
|
|
244
|
+
: "Drag and drop files here"}
|
|
245
|
+
</Text>
|
|
246
|
+
{limits && (
|
|
247
|
+
<Text type="supporting" color="secondary" justify="center">
|
|
248
|
+
{limits}
|
|
249
|
+
</Text>
|
|
250
|
+
)}
|
|
251
|
+
<HStack gap={2} vAlign="center" wrap="wrap" hAlign="center">
|
|
252
|
+
<Button
|
|
253
|
+
label="Browse files"
|
|
254
|
+
variant="secondary"
|
|
255
|
+
size="sm"
|
|
256
|
+
icon={<Icon icon={icons.folderOpen} />}
|
|
257
|
+
onClick={browse}
|
|
258
|
+
isDisabled={isDisabled || full}
|
|
259
|
+
/>
|
|
260
|
+
{hasPaste && (
|
|
261
|
+
<Text type="supporting" color="secondary">
|
|
262
|
+
or paste with <Kbd keys="mod+v" />
|
|
263
|
+
</Text>
|
|
264
|
+
)}
|
|
265
|
+
</HStack>
|
|
266
|
+
</Stack>
|
|
267
|
+
</Card>
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<Stack gap={3}>
|
|
272
|
+
<Stack gap={0.5}>
|
|
273
|
+
<Text type="label">{label}</Text>
|
|
274
|
+
{description && (
|
|
275
|
+
<Text type="supporting" color="secondary">
|
|
276
|
+
{description}
|
|
277
|
+
</Text>
|
|
278
|
+
)}
|
|
279
|
+
</Stack>
|
|
280
|
+
<div {...dropProps} aria-disabled={isDisabled || undefined} aria-label={label} role="group">
|
|
281
|
+
{picker}
|
|
282
|
+
{target}
|
|
283
|
+
</div>
|
|
284
|
+
{rejected.length > 0 && (
|
|
285
|
+
<Banner
|
|
286
|
+
status="error"
|
|
287
|
+
title={`${rejected.length} file${rejected.length === 1 ? " wasn’t" : "s weren’t"} added`}
|
|
288
|
+
description={rejected.join(" ")}
|
|
289
|
+
isDismissable
|
|
290
|
+
onDismiss={() => setRejected([])}
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
{items.length > 0 &&
|
|
294
|
+
(layout === "grid" ? (
|
|
295
|
+
<HStack gap={3} wrap="wrap">
|
|
296
|
+
{items.map((item) => (
|
|
297
|
+
<Stack key={item.id} gap={1} width={96}>
|
|
298
|
+
<Thumbnail
|
|
299
|
+
src={item.previewUrl}
|
|
300
|
+
alt={item.file.name}
|
|
301
|
+
label={item.file.name}
|
|
302
|
+
isLoading={item.status === "uploading"}
|
|
303
|
+
onRemove={() => remove(item.id)}
|
|
304
|
+
/>
|
|
305
|
+
<Text
|
|
306
|
+
type="supporting"
|
|
307
|
+
color={item.status === "error" ? "accent" : "secondary"}
|
|
308
|
+
maxLines={1}
|
|
309
|
+
>
|
|
310
|
+
{item.status === "error" ? "Failed" : item.file.name}
|
|
311
|
+
</Text>
|
|
312
|
+
</Stack>
|
|
313
|
+
))}
|
|
314
|
+
</HStack>
|
|
315
|
+
) : (
|
|
316
|
+
<Stack gap={2}>
|
|
317
|
+
{items.map((item) => (
|
|
318
|
+
<Card key={item.id} padding={3}>
|
|
319
|
+
<HStack gap={3} vAlign="center">
|
|
320
|
+
<Thumbnail src={item.previewUrl} alt={item.file.name} label={item.file.name} />
|
|
321
|
+
<Stack gap={1} width="100%">
|
|
322
|
+
<HStack gap={2} vAlign="center" hAlign="between">
|
|
323
|
+
<Text weight="semibold" maxLines={1}>
|
|
324
|
+
{item.file.name}
|
|
325
|
+
</Text>
|
|
326
|
+
{item.status === "done" && (
|
|
327
|
+
<Badge
|
|
328
|
+
label="Uploaded"
|
|
329
|
+
variant="success"
|
|
330
|
+
icon={<Icon icon={icons.check} size="xsm" />}
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
333
|
+
{item.status === "error" && <Badge label="Failed" variant="error" />}
|
|
334
|
+
</HStack>
|
|
335
|
+
{item.status === "uploading" ? (
|
|
336
|
+
<ProgressBar
|
|
337
|
+
label={`Uploading ${item.file.name}`}
|
|
338
|
+
isLabelHidden
|
|
339
|
+
value={item.progress}
|
|
340
|
+
/>
|
|
341
|
+
) : (
|
|
342
|
+
<Text type="supporting" color="secondary">
|
|
343
|
+
{item.status === "error" ? item.error : formatBytes(item.file.size)}
|
|
344
|
+
</Text>
|
|
345
|
+
)}
|
|
346
|
+
</Stack>
|
|
347
|
+
{item.status === "error" && (
|
|
348
|
+
<IconButton
|
|
349
|
+
label={`Retry ${item.file.name}`}
|
|
350
|
+
variant="ghost"
|
|
351
|
+
size="sm"
|
|
352
|
+
icon={<Icon icon={icons.refresh} />}
|
|
353
|
+
onClick={() => {
|
|
354
|
+
void run(item);
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
<IconButton
|
|
359
|
+
label={`Remove ${item.file.name}`}
|
|
360
|
+
variant="ghost"
|
|
361
|
+
size="sm"
|
|
362
|
+
icon={<Icon icon={icons.close} />}
|
|
363
|
+
onClick={() => remove(item.id)}
|
|
364
|
+
/>
|
|
365
|
+
</HStack>
|
|
366
|
+
</Card>
|
|
367
|
+
))}
|
|
368
|
+
{items.length > 1 && (
|
|
369
|
+
<Text type="supporting" color="secondary">
|
|
370
|
+
{done} of {items.length} uploaded
|
|
371
|
+
</Text>
|
|
372
|
+
)}
|
|
373
|
+
</Stack>
|
|
374
|
+
))}
|
|
375
|
+
</Stack>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { conversionRates } from "./chartMath";
|
|
6
|
+
import { ChartTable, defaultFormat, type ChartTone } from "./chartParts";
|
|
7
|
+
|
|
8
|
+
export type FunnelStage = {
|
|
9
|
+
label: string;
|
|
10
|
+
value: number;
|
|
11
|
+
/** A quiet note under the name, e.g. "Recruiter or hiring manager wrote back". */
|
|
12
|
+
hint?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stages that narrow: saved → applied → replied → interview → offer. Each bar is drawn
|
|
17
|
+
* against the first stage; the badge after it is the conversion from the stage above.
|
|
18
|
+
*/
|
|
19
|
+
export function FunnelChart({
|
|
20
|
+
stages,
|
|
21
|
+
label,
|
|
22
|
+
tone = "blue",
|
|
23
|
+
formatValue = defaultFormat,
|
|
24
|
+
}: {
|
|
25
|
+
stages: FunnelStage[];
|
|
26
|
+
/** Accessible name for the chart. */
|
|
27
|
+
label: string;
|
|
28
|
+
tone?: ChartTone;
|
|
29
|
+
formatValue?: (value: number) => string;
|
|
30
|
+
}) {
|
|
31
|
+
const [active, setActive] = useState<number | null>(null);
|
|
32
|
+
const first = Math.max(0, stages[0]?.value ?? 0);
|
|
33
|
+
const rates = conversionRates(stages.map((stage) => stage.value));
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="os-chart">
|
|
37
|
+
<ol className="os-funnel" aria-label={label}>
|
|
38
|
+
{stages.map((stage, index) => (
|
|
39
|
+
<li
|
|
40
|
+
key={stage.label}
|
|
41
|
+
className="os-funnel-stage"
|
|
42
|
+
data-active={active === index || undefined}
|
|
43
|
+
onPointerEnter={() => setActive(index)}
|
|
44
|
+
onPointerLeave={() => setActive(null)}
|
|
45
|
+
>
|
|
46
|
+
<span className="os-funnel-head">
|
|
47
|
+
<span className="os-funnel-name">{stage.label}</span>
|
|
48
|
+
{stage.hint ? <span className="os-funnel-hint">{stage.hint}</span> : null}
|
|
49
|
+
</span>
|
|
50
|
+
<span className="os-funnel-track">
|
|
51
|
+
<span
|
|
52
|
+
className="os-funnel-mark"
|
|
53
|
+
data-tone={tone}
|
|
54
|
+
style={{ width: first > 0 ? `${(Math.max(0, stage.value) / first) * 100}%` : "0%" }}
|
|
55
|
+
/>
|
|
56
|
+
</span>
|
|
57
|
+
<span className="os-funnel-figures">
|
|
58
|
+
<strong>{formatValue(stage.value)}</strong>
|
|
59
|
+
{index > 0 ? (
|
|
60
|
+
<span className="os-funnel-rate" title={`From ${stages[index - 1].label}`}>
|
|
61
|
+
{rates[index]}%
|
|
62
|
+
</span>
|
|
63
|
+
) : (
|
|
64
|
+
<span className="os-funnel-rate" data-first>
|
|
65
|
+
100%
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</span>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ol>
|
|
72
|
+
<ChartTable
|
|
73
|
+
caption={label}
|
|
74
|
+
headers={["Stage", "Count", "From the stage above"]}
|
|
75
|
+
rows={stages.map((stage, index) => [
|
|
76
|
+
stage.label,
|
|
77
|
+
formatValue(stage.value),
|
|
78
|
+
index === 0 ? "—" : `${rates[index]}%`,
|
|
79
|
+
])}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|