sid-ui 0.0.0-stage → 0.1.1

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 (122) hide show
  1. package/README.md +54 -2
  2. package/package.json +51 -4
  3. package/src/brand/BrandFooter.tsx +45 -0
  4. package/src/brand/BrandHeading.tsx +24 -0
  5. package/src/brand/BrandLockup.tsx +47 -0
  6. package/src/brand/JoinedLogo.tsx +67 -0
  7. package/src/brand/JoinedMark.tsx +76 -0
  8. package/src/brand/geometry.ts +26 -0
  9. package/src/brand/index.ts +11 -0
  10. package/src/brand/name.ts +5 -0
  11. package/src/brand/paint.tsx +56 -0
  12. package/src/components/Action.tsx +44 -0
  13. package/src/components/AddressSelector.tsx +99 -0
  14. package/src/components/BarChart.tsx +132 -0
  15. package/src/components/Calendar.tsx +284 -0
  16. package/src/components/CalendarAgenda.tsx +87 -0
  17. package/src/components/CalendarMonth.tsx +223 -0
  18. package/src/components/CalendarSchedule.tsx +210 -0
  19. package/src/components/Card.tsx +80 -0
  20. package/src/components/Charts.tsx +64 -0
  21. package/src/components/Chat.tsx +35 -0
  22. package/src/components/CitySelector.tsx +81 -0
  23. package/src/components/Clock.tsx +196 -0
  24. package/src/components/CodeInput.tsx +183 -0
  25. package/src/components/Container.tsx +20 -0
  26. package/src/components/Content.tsx +50 -0
  27. package/src/components/Data.tsx +43 -0
  28. package/src/components/DataInput.tsx +107 -0
  29. package/src/components/DateField.tsx +162 -0
  30. package/src/components/DonutChart.tsx +123 -0
  31. package/src/components/Drawer.tsx +110 -0
  32. package/src/components/Feedback.tsx +37 -0
  33. package/src/components/FileUploader.tsx +377 -0
  34. package/src/components/FunnelChart.tsx +83 -0
  35. package/src/components/Glyph.tsx +330 -0
  36. package/src/components/GoogleSignIn.tsx +67 -0
  37. package/src/components/GridSystem.tsx +131 -0
  38. package/src/components/HeatmapCalendar.tsx +167 -0
  39. package/src/components/KanbanBoard.tsx +421 -0
  40. package/src/components/Kpi.tsx +61 -0
  41. package/src/components/LayoutPrimitives.tsx +86 -0
  42. package/src/components/Lists.tsx +19 -0
  43. package/src/components/LocationSelector.tsx +49 -0
  44. package/src/components/MessageList.tsx +80 -0
  45. package/src/components/Nav.tsx +69 -0
  46. package/src/components/Navigation.tsx +52 -0
  47. package/src/components/Notification.tsx +115 -0
  48. package/src/components/NotificationTrigger.tsx +248 -0
  49. package/src/components/Overlay.tsx +50 -0
  50. package/src/components/Page.tsx +143 -0
  51. package/src/components/PageTabs.tsx +28 -0
  52. package/src/components/PickerShell.tsx +138 -0
  53. package/src/components/PillNav.tsx +194 -0
  54. package/src/components/Primitives.tsx +30 -0
  55. package/src/components/Rating.tsx +78 -0
  56. package/src/components/Responsive.tsx +128 -0
  57. package/src/components/ResponsiveFrame.tsx +122 -0
  58. package/src/components/Segment.tsx +190 -0
  59. package/src/components/Sparkline.tsx +52 -0
  60. package/src/components/StateSelector.tsx +39 -0
  61. package/src/components/Sticky.tsx +95 -0
  62. package/src/components/Table.tsx +344 -0
  63. package/src/components/ThemeToggle.tsx +48 -0
  64. package/src/components/Tile.tsx +23 -0
  65. package/src/components/TimeColumns.tsx +155 -0
  66. package/src/components/TimeDial.tsx +260 -0
  67. package/src/components/TimeField.tsx +196 -0
  68. package/src/components/TimeInput.tsx +213 -0
  69. package/src/components/TimeSlots.tsx +83 -0
  70. package/src/components/Timeline.tsx +111 -0
  71. package/src/components/TokenDemo.tsx +378 -0
  72. package/src/components/Tree.tsx +283 -0
  73. package/src/components/TrendChart.tsx +211 -0
  74. package/src/components/breakpoints.ts +42 -0
  75. package/src/components/calendarTypes.ts +26 -0
  76. package/src/components/chartMath.ts +125 -0
  77. package/src/components/chartParts.tsx +116 -0
  78. package/src/components/codeInputRules.ts +58 -0
  79. package/src/components/date.ts +87 -0
  80. package/src/components/hooks.ts +53 -0
  81. package/src/components/index.ts +195 -0
  82. package/src/components/kanban.ts +78 -0
  83. package/src/components/motion.ts +78 -0
  84. package/src/components/places-search.tsx +73 -0
  85. package/src/components/places.ts +209 -0
  86. package/src/components/size.ts +3 -0
  87. package/src/components/time.ts +83 -0
  88. package/src/components/useFileDrop.ts +104 -0
  89. package/src/places/geoapify.ts +129 -0
  90. package/src/styles/astryx.css +5 -0
  91. package/src/styles/components/brand.css +98 -0
  92. package/src/styles/components/calendar.css +680 -0
  93. package/src/styles/components/charts.css +659 -0
  94. package/src/styles/components/code-input.css +233 -0
  95. package/src/styles/components/google.css +11 -0
  96. package/src/styles/components/message-list.css +153 -0
  97. package/src/styles/components/notification.css +350 -0
  98. package/src/styles/components/picker.css +114 -0
  99. package/src/styles/components/pill-nav.css +179 -0
  100. package/src/styles/components/responsive.css +419 -0
  101. package/src/styles/components/sticky.css +23 -0
  102. package/src/styles/components/table.css +267 -0
  103. package/src/styles/components/time.css +526 -0
  104. package/src/styles/components/timeline.css +426 -0
  105. package/src/styles/components/tree.css +269 -0
  106. package/src/styles/components.css +423 -0
  107. package/src/styles/joined.css +4 -0
  108. package/src/styles/theme.css +79 -0
  109. package/src/styles/tokens.css +338 -0
  110. package/src/theme/JoinedProvider.tsx +50 -0
  111. package/src/theme/index.ts +3 -0
  112. package/src/theme/joined.d.ts +11 -0
  113. package/src/theme/joined.js +569 -0
  114. package/src/theme/joined.theme.ts +74 -0
  115. package/src/theme/joined.variants.d.ts +16 -0
  116. package/src/theme/theme.css +786 -0
  117. package/styles/astryx.css +1 -0
  118. package/styles/components.css +1 -0
  119. package/styles/joined.css +1 -0
  120. package/styles/theme.css +1 -0
  121. package/styles/tokens.css +1 -0
  122. package/styles.css +1 -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
+ }