@blurrow/ui 0.2.0 → 0.2.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 (271) hide show
  1. package/README.md +48 -36
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +172 -5
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +6 -6
  199. package/dist/registry/Alert.tsx +13 -10
  200. package/dist/registry/AuthorCard.tsx +7 -7
  201. package/dist/registry/Avatar.tsx +5 -5
  202. package/dist/registry/Badge.tsx +12 -15
  203. package/dist/registry/Banner.tsx +15 -18
  204. package/dist/registry/Breadcrumb.tsx +3 -3
  205. package/dist/registry/Button.tsx +58 -69
  206. package/dist/registry/ButtonGroup.tsx +9 -9
  207. package/dist/registry/Card.tsx +15 -15
  208. package/dist/registry/CardShell.tsx +6 -6
  209. package/dist/registry/Carousel.tsx +19 -15
  210. package/dist/registry/Checkbox.tsx +14 -9
  211. package/dist/registry/CloseButton.tsx +2 -2
  212. package/dist/registry/CodeBlock.tsx +15 -15
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +31 -24
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +4 -4
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +17 -15
  221. package/dist/registry/DropdownMenu.tsx +39 -77
  222. package/dist/registry/EmptyState.tsx +8 -8
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +54 -43
  228. package/dist/registry/Kbd.tsx +4 -5
  229. package/dist/registry/Modal.tsx +17 -15
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +15 -18
  233. package/dist/registry/Popover.tsx +40 -45
  234. package/dist/registry/Progress.tsx +4 -4
  235. package/dist/registry/QuoteCard.tsx +7 -7
  236. package/dist/registry/Radio.tsx +24 -21
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +5 -5
  239. package/dist/registry/ReadingProgress.tsx +2 -2
  240. package/dist/registry/ReadingTime.tsx +2 -2
  241. package/dist/registry/Select.tsx +28 -53
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +2 -2
  244. package/dist/registry/Sidebar.tsx +18 -18
  245. package/dist/registry/Skeleton.tsx +1 -1
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +6 -6
  249. package/dist/registry/Stat.tsx +4 -4
  250. package/dist/registry/StatCard.tsx +19 -19
  251. package/dist/registry/Steps.tsx +7 -7
  252. package/dist/registry/Table.tsx +12 -10
  253. package/dist/registry/TableOfContents.tsx +8 -8
  254. package/dist/registry/Tabs.tsx +6 -6
  255. package/dist/registry/TagInput.tsx +9 -9
  256. package/dist/registry/Textarea.tsx +5 -5
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +14 -14
  259. package/dist/registry/Toast.tsx +15 -12
  260. package/dist/registry/Toggle.tsx +7 -10
  261. package/dist/registry/Tooltip.tsx +21 -73
  262. package/dist/registry/styles.css +868 -62
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/styles.css +868 -62
  265. package/dist/useFloatingPanel.d.ts +24 -0
  266. package/dist/useFloatingPanel.js +89 -0
  267. package/dist/useFloatingPanel.js.map +1 -0
  268. package/dist/useModalBehavior.d.ts +15 -0
  269. package/dist/useModalBehavior.js +57 -0
  270. package/dist/useModalBehavior.js.map +1 -0
  271. package/package.json +1 -1
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ /* The label above a field and the line under it, shared by every form
6
+ control so they read as one family. */
7
+
8
+ export function FieldLabel({
9
+ id,
10
+ htmlFor,
11
+ children,
12
+ }: {
13
+ id?: string;
14
+ htmlFor?: string;
15
+ children: React.ReactNode;
16
+ }) {
17
+ const className =
18
+ "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
19
+ return htmlFor ? (
20
+ <label id={id} htmlFor={htmlFor} className={className}>
21
+ {children}
22
+ </label>
23
+ ) : (
24
+ <span id={id} className={className}>
25
+ {children}
26
+ </span>
27
+ );
28
+ }
29
+
30
+ /* An error wins over a hint; either is announced through `id` */
31
+ export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
32
+ if (error) {
33
+ return (
34
+ <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
35
+ <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
36
+ <circle cx="12" cy="12" r="10" />
37
+ <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
38
+ </svg>
39
+ {error}
40
+ </p>
41
+ );
42
+ }
43
+ if (hint) {
44
+ return (
45
+ <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
46
+ {hint}
47
+ </p>
48
+ );
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /* A small raised key that sits inside a field or a panel: steppers,
54
+ month arrows, clear buttons. It sinks flush while held. */
55
+ export function FieldKey({
56
+ label,
57
+ disabled,
58
+ onClick,
59
+ onPress,
60
+ onRelease,
61
+ focusable = true,
62
+ size = 28,
63
+ children,
64
+ }: {
65
+ label: string;
66
+ disabled?: boolean;
67
+ onClick?: () => void;
68
+ /** Pointer went down (for hold-to-repeat) */
69
+ onPress?: () => void;
70
+ onRelease?: () => void;
71
+ focusable?: boolean;
72
+ size?: number;
73
+ children: React.ReactNode;
74
+ }) {
75
+ const [pressed, setPressed] = React.useState(false);
76
+ const release = () => {
77
+ setPressed(false);
78
+ onRelease?.();
79
+ };
80
+ return (
81
+ <button
82
+ type="button"
83
+ aria-label={label}
84
+ tabIndex={focusable ? undefined : -1}
85
+ disabled={disabled}
86
+ onClick={onClick}
87
+ onPointerDown={(e) => {
88
+ if (e.button !== 0) return;
89
+ if (!focusable) e.preventDefault(); // keep focus in the field
90
+ setPressed(true);
91
+ onPress?.();
92
+ }}
93
+ onPointerUp={release}
94
+ onPointerLeave={() => pressed && release()}
95
+ onPointerCancel={release}
96
+ className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"
97
+ style={{
98
+ width: size,
99
+ height: size,
100
+ background: "var(--face)",
101
+ boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
102
+ }}
103
+ >
104
+ <svg
105
+ aria-hidden
106
+ width="12"
107
+ height="12"
108
+ fill="none"
109
+ stroke="currentColor"
110
+ strokeWidth="2.25"
111
+ strokeLinecap="round"
112
+ strokeLinejoin="round"
113
+ viewBox="0 0 24 24"
114
+ style={{
115
+ transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
116
+ transition: "transform 150ms var(--ease-spring)",
117
+ }}
118
+ >
119
+ {children}
120
+ </svg>
121
+ </button>
122
+ );
123
+ }
@@ -0,0 +1,318 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useRef, useState } from "react";
4
+ import { Button } from "./Button";
5
+ import { CloseButton } from "./CloseButton";
6
+ import { FieldLabel, FieldMessage } from "./FieldMessage";
7
+
8
+ export interface UploadItem {
9
+ id: string;
10
+ file: File;
11
+ /** 0–100 while uploading */
12
+ progress: number;
13
+ status: "ready" | "uploading" | "done" | "error";
14
+ error?: string;
15
+ }
16
+
17
+ interface FileUploadProps {
18
+ /** Same as the input's accept: "image/*", ".pdf,.docx" */
19
+ accept?: string;
20
+ multiple?: boolean;
21
+ /** In bytes; bigger files land with an error instead of uploading */
22
+ maxSize?: number;
23
+ maxFiles?: number;
24
+ /** Upload each file as it lands. Call onProgress with 0–100; throw to fail it */
25
+ upload?: (file: File, onProgress: (pct: number) => void) => Promise<void>;
26
+ /** Every change to the list: added, progressed, finished, removed */
27
+ onChange?: (items: UploadItem[]) => void;
28
+ label?: string;
29
+ hint?: string;
30
+ /** The words in the empty tray */
31
+ prompt?: string;
32
+ disabled?: boolean;
33
+ className?: string;
34
+ }
35
+
36
+ export function formatBytes(n: number) {
37
+ if (n < 1024) return `${n} b`;
38
+ const units = ["kb", "mb", "gb"];
39
+ let i = -1;
40
+ do {
41
+ n /= 1024;
42
+ i++;
43
+ } while (n >= 1024 && i < units.length - 1);
44
+ return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
45
+ }
46
+
47
+ /* Matches a file against an accept string the way the browser's picker does */
48
+ function accepts(file: File, accept?: string) {
49
+ if (!accept) return true;
50
+ return accept.split(",").some((rule) => {
51
+ const r = rule.trim().toLowerCase();
52
+ if (r.startsWith(".")) return file.name.toLowerCase().endsWith(r);
53
+ if (r.endsWith("/*")) return file.type.startsWith(r.slice(0, -1));
54
+ return file.type === r;
55
+ });
56
+ }
57
+
58
+ let seq = 0;
59
+
60
+ /* Something you fill, so it's built as a field: a wide sunken tray. Drag
61
+ files over it and it lifts flush and draws its border, the way an
62
+ Input does when it takes focus. What you drop lands inside it as
63
+ raised chips; each has a groove along its foot that fills while it
64
+ uploads, then fades once it's done. */
65
+ export function FileUpload({
66
+ accept,
67
+ multiple = true,
68
+ maxSize,
69
+ maxFiles,
70
+ upload,
71
+ onChange,
72
+ label,
73
+ hint,
74
+ prompt,
75
+ disabled = false,
76
+ className = "",
77
+ }: FileUploadProps) {
78
+ const uid = useId();
79
+ const inputRef = useRef<HTMLInputElement>(null);
80
+ const [items, setItems] = useState<UploadItem[]>([]);
81
+ const [previews, setPreviews] = useState<Record<string, string>>({});
82
+ const [over, setOver] = useState(false);
83
+ const [notice, setNotice] = useState<string | null>(null);
84
+ const depth = useRef(0);
85
+
86
+ // Report every change to the list, after it renders
87
+ const report = useRef(onChange);
88
+ useEffect(() => {
89
+ report.current = onChange;
90
+ });
91
+ useEffect(() => {
92
+ report.current?.(items);
93
+ }, [items]);
94
+
95
+ // Image previews are object URLs; give them back when they're gone
96
+ const urls = useRef(previews);
97
+ useEffect(() => {
98
+ urls.current = previews;
99
+ });
100
+ useEffect(() => () => Object.values(urls.current).forEach(URL.revokeObjectURL), []);
101
+
102
+ const patch = (id: string, p: Partial<UploadItem>) =>
103
+ setItems((list) => list.map((it) => (it.id === id ? { ...it, ...p } : it)));
104
+
105
+ const start = (item: UploadItem) => {
106
+ if (!upload) return;
107
+ patch(item.id, { status: "uploading", progress: 0 });
108
+ upload(item.file, (pct) => patch(item.id, { progress: Math.max(0, Math.min(100, pct)) }))
109
+ .then(() => patch(item.id, { status: "done", progress: 100 }))
110
+ .catch((err: unknown) =>
111
+ patch(item.id, { status: "error", error: err instanceof Error ? err.message : "upload failed" }),
112
+ );
113
+ };
114
+
115
+ const add = (files: FileList | File[]) => {
116
+ if (disabled) return;
117
+ let list = Array.from(files);
118
+ if (!multiple) list = list.slice(0, 1);
119
+ const room = maxFiles ? Math.max(0, maxFiles - (multiple ? items.length : 0)) : Infinity;
120
+ setNotice(list.length > room ? `only ${maxFiles} file${maxFiles === 1 ? "" : "s"} can go in` : null);
121
+ const next: UploadItem[] = list.slice(0, room).map((file) => {
122
+ const id = `${uid}-${++seq}`;
123
+ if (!accepts(file, accept)) return { id, file, progress: 0, status: "error", error: "not a type this takes" };
124
+ if (maxSize && file.size > maxSize)
125
+ return { id, file, progress: 0, status: "error", error: `over ${formatBytes(maxSize)}` };
126
+ return { id, file, progress: 0, status: "ready" };
127
+ });
128
+ const shots: Record<string, string> = {};
129
+ next.forEach((it) => {
130
+ if (it.file.type.startsWith("image/") && it.status !== "error") shots[it.id] = URL.createObjectURL(it.file);
131
+ });
132
+ if (!multiple) Object.values(previews).forEach(URL.revokeObjectURL);
133
+ setPreviews((p) => (multiple ? { ...p, ...shots } : shots));
134
+ setItems((cur) => (multiple ? [...cur, ...next] : next));
135
+ next.filter((it) => it.status === "ready").forEach(start);
136
+ };
137
+
138
+ const remove = (id: string) => {
139
+ if (previews[id]) URL.revokeObjectURL(previews[id]);
140
+ setPreviews((p) => {
141
+ const rest = { ...p };
142
+ delete rest[id];
143
+ return rest;
144
+ });
145
+ setItems((list) => list.filter((it) => it.id !== id));
146
+ };
147
+
148
+ const browse = () => !disabled && inputRef.current?.click();
149
+ const full = !!maxFiles && multiple && items.length >= maxFiles;
150
+ const empty = items.length === 0;
151
+
152
+ return (
153
+ <div className={["group/field flex w-full flex-col gap-1.5", className].join(" ")}>
154
+ {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
155
+
156
+ {/* The tray: a field, so data-open gives it Input's focused well */}
157
+ <div
158
+ role="group"
159
+ aria-labelledby={label ? `${uid}-label` : undefined}
160
+ aria-describedby={hint ? `${uid}-hint` : undefined}
161
+ data-open={over || undefined}
162
+ onDragEnter={(e) => {
163
+ e.preventDefault();
164
+ if (disabled) return;
165
+ depth.current++;
166
+ setOver(true);
167
+ }}
168
+ onDragOver={(e) => e.preventDefault()}
169
+ onDragLeave={() => {
170
+ // dragleave fires for every child crossed; only the last one counts
171
+ if (--depth.current <= 0) setOver(false);
172
+ }}
173
+ onDrop={(e) => {
174
+ e.preventDefault();
175
+ depth.current = 0;
176
+ setOver(false);
177
+ add(e.dataTransfer.files);
178
+ }}
179
+ className={[
180
+ "field relative flex flex-col rounded-[18px] p-2",
181
+ disabled ? "pointer-events-none opacity-40" : "",
182
+ ].join(" ")}
183
+ >
184
+ {empty ? (
185
+ <div
186
+ className="flex cursor-pointer flex-col items-center gap-3.5 px-6 py-9 text-center"
187
+ onClick={(e) => e.target === e.currentTarget && browse()}
188
+ >
189
+ <span className="flex flex-col gap-1">
190
+ <span className="text-[14px] font-medium text-ink tracking-[-0.01em]">
191
+ {over ? "let go to drop them in" : prompt ?? (multiple ? "drop files here" : "drop a file here")}
192
+ </span>
193
+ {hint && (
194
+ <span id={`${uid}-hint`} className="text-[12px] text-ink-muted">
195
+ {hint}
196
+ </span>
197
+ )}
198
+ </span>
199
+ <Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>
200
+ {multiple ? "choose files" : "choose a file"}
201
+ </Button>
202
+ </div>
203
+ ) : (
204
+ <>
205
+ <ul aria-label="files" className="flex flex-col gap-1.5">
206
+ {items.map((it) => (
207
+ <FileChip key={it.id} item={it} preview={previews[it.id]} onRemove={() => remove(it.id)} />
208
+ ))}
209
+ </ul>
210
+ <div className="flex items-center justify-between gap-3 pl-2.5 pr-0.5 pt-2.5 pb-0.5">
211
+ <span id={`${uid}-hint`} className="truncate text-[12px] text-ink-muted">
212
+ {over ? "let go to add them" : hint ?? `${items.length} file${items.length === 1 ? "" : "s"}`}
213
+ </span>
214
+ {!full && (
215
+ <Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>
216
+ {multiple ? "add more" : "replace"}
217
+ </Button>
218
+ )}
219
+ </div>
220
+ </>
221
+ )}
222
+
223
+ <input
224
+ ref={inputRef}
225
+ type="file"
226
+ accept={accept}
227
+ multiple={multiple}
228
+ disabled={disabled}
229
+ tabIndex={-1}
230
+ aria-hidden
231
+ className="sr-only"
232
+ onChange={(e) => {
233
+ if (e.target.files) add(e.target.files);
234
+ e.target.value = ""; // so picking the same file again still fires
235
+ }}
236
+ />
237
+ </div>
238
+
239
+ {notice && <FieldMessage error={notice} />}
240
+ </div>
241
+ );
242
+ }
243
+
244
+ /* A raised chip sitting in the tray. The groove along its foot is a
245
+ Progress track in miniature; it fills, then fades once the file is up. */
246
+ function FileChip({ item, preview, onRemove }: { item: UploadItem; preview?: string; onRemove: () => void }) {
247
+ const failed = item.status === "error";
248
+ const meta = failed
249
+ ? item.error
250
+ : item.status === "uploading"
251
+ ? `${formatBytes(item.file.size)} · ${Math.round(item.progress)}%`
252
+ : item.status === "done"
253
+ ? `${formatBytes(item.file.size)} · uploaded`
254
+ : formatBytes(item.file.size);
255
+ return (
256
+ <li
257
+ className="relative flex items-center gap-3 overflow-hidden rounded-[12px] pt-2.5 pb-3.5 pl-2.5 pr-2"
258
+ style={{
259
+ background: "var(--face)",
260
+ boxShadow: "var(--thumb-shadow)",
261
+ animation: "select-option-in 320ms var(--ease-out-soft) both",
262
+ }}
263
+ >
264
+ {preview && (
265
+ // eslint-disable-next-line @next/next/no-img-element -- a local object URL, nothing to optimise
266
+ <img
267
+ src={preview}
268
+ alt=""
269
+ className="h-9 w-9 shrink-0 rounded-[8px] object-cover"
270
+ style={{ boxShadow: "inset 0 0 0 1px var(--hairline), var(--key-shadow)" }}
271
+ />
272
+ )}
273
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
274
+ <span className="truncate text-[13px] font-medium text-ink">{item.file.name}</span>
275
+ <span
276
+ className={[
277
+ "flex items-center gap-1 truncate text-[11.5px] tabular-nums",
278
+ failed ? "text-danger" : "text-ink-muted",
279
+ ].join(" ")}
280
+ >
281
+ {item.status === "done" && (
282
+ <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24" className="text-success">
283
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
284
+ </svg>
285
+ )}
286
+ {meta}
287
+ </span>
288
+ </span>
289
+ <CloseButton size="sm" label={`remove ${item.file.name}`} onClick={onRemove} />
290
+
291
+ {/* The groove along the chip's foot */}
292
+ <span
293
+ aria-hidden={item.status !== "uploading"}
294
+ role={item.status === "uploading" ? "progressbar" : undefined}
295
+ aria-label={item.status === "uploading" ? `uploading ${item.file.name}` : undefined}
296
+ aria-valuenow={item.status === "uploading" ? Math.round(item.progress) : undefined}
297
+ aria-valuemin={0}
298
+ aria-valuemax={100}
299
+ className="absolute inset-x-2.5 bottom-[6px] h-[3px] rounded-full transition-opacity duration-500"
300
+ style={{
301
+ background: "var(--track)",
302
+ boxShadow: "var(--track-shadow)",
303
+ opacity: item.status === "uploading" ? 1 : 0,
304
+ transitionDelay: item.status === "done" ? "400ms" : "0ms",
305
+ }}
306
+ >
307
+ <span
308
+ className="block h-full rounded-full"
309
+ style={{
310
+ width: `${item.progress}%`,
311
+ background: "var(--face-inverse)",
312
+ transition: "width 300ms var(--ease-out-soft)",
313
+ }}
314
+ />
315
+ </span>
316
+ </li>
317
+ );
318
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ /* ── Portal ───────────────────────────────────────────────────────── */
7
+ /* Anything that floats over the page is rendered at the end of <body>.
8
+ Left where it was declared, a panel lives inside whatever stacking
9
+ context its parents made (a transform, a filter, an isolate), and a
10
+ later sibling on the page can paint straight over it, whatever its
11
+ z-index says. */
12
+ const noop = () => () => {};
13
+
14
+ export function Portal({ children }: { children: React.ReactNode }) {
15
+ const client = useSyncExternalStore(noop, () => true, () => false);
16
+ return client ? createPortal(children, document.body) : null;
17
+ }
18
+
19
+ /* Layers, lowest first: dialogs, then toasts, then anything anchored to
20
+ a control (so a select inside a modal opens over the modal), then
21
+ tooltips over all of it. */
22
+ export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;
23
+
24
+ /* ── Anchored position ────────────────────────────────────────────── */
25
+ export type Side = "top" | "bottom" | "left" | "right";
26
+ export type Align = "start" | "center" | "end";
27
+
28
+ const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
29
+ const PAD = 8;
30
+
31
+ interface AnchorOptions {
32
+ open: boolean;
33
+ side?: Side;
34
+ align?: Align;
35
+ /** Space between the anchor and the panel */
36
+ gap?: number;
37
+ /** Make the panel at least as wide as the anchor (selects, comboboxes) */
38
+ matchWidth?: boolean;
39
+ }
40
+
41
+ /* Places a fixed-position panel next to its anchor: on the side asked
42
+ for unless the other side has more room, then nudged back inside the
43
+ viewport. Follows the anchor while the page scrolls or resizes. The
44
+ position is written straight onto the element, so scrolling never
45
+ re-renders the component. */
46
+ export function useAnchoredPosition(
47
+ anchor: RefObject<HTMLElement | null>,
48
+ { open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,
49
+ ) {
50
+ const [placed, setPlaced] = useState<Side>(side);
51
+ const floating = useRef<HTMLElement | null>(null);
52
+
53
+ const update = useCallback(() => {
54
+ const a = anchor.current?.getBoundingClientRect();
55
+ const el = floating.current;
56
+ if (!a || !el) return;
57
+ if (matchWidth) el.style.minWidth = `${a.width}px`;
58
+
59
+ // offset sizes ignore the enter transform, so these are the settled size
60
+ const w = el.offsetWidth;
61
+ const h = el.offsetHeight;
62
+ const vw = document.documentElement.clientWidth;
63
+ const vh = window.innerHeight;
64
+ const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
65
+ const vertical = side === "top" || side === "bottom";
66
+ const need = (vertical ? h : w) + gap + PAD;
67
+
68
+ let s = side;
69
+ if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
70
+
71
+ let top: number;
72
+ let left: number;
73
+ if (s === "top" || s === "bottom") {
74
+ top = s === "bottom" ? a.bottom + gap : a.top - gap - h;
75
+ left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;
76
+ } else {
77
+ left = s === "right" ? a.right + gap : a.left - gap - w;
78
+ top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;
79
+ }
80
+ left = Math.max(PAD, Math.min(left, vw - w - PAD));
81
+ top = Math.max(PAD, Math.min(top, vh - h - PAD));
82
+
83
+ el.style.top = `${Math.round(top)}px`;
84
+ el.style.left = `${Math.round(left)}px`;
85
+ el.dataset.side = s;
86
+ setPlaced(s);
87
+ }, [anchor, side, align, gap, matchWidth]);
88
+
89
+ /* Attach this as the panel's ref: it's placed before its first paint */
90
+ const setFloating = useCallback(
91
+ (el: HTMLElement | null) => {
92
+ floating.current = el;
93
+ if (el) update();
94
+ },
95
+ [update],
96
+ );
97
+
98
+ useEffect(() => {
99
+ if (!open) return;
100
+ let frame = 0;
101
+ const schedule = () => {
102
+ cancelAnimationFrame(frame);
103
+ frame = requestAnimationFrame(update);
104
+ };
105
+ window.addEventListener("scroll", schedule, true);
106
+ window.addEventListener("resize", schedule);
107
+ // The panel can change size while open (a filtered list, a new month)
108
+ const ro = new ResizeObserver(schedule);
109
+ if (floating.current) ro.observe(floating.current);
110
+ if (anchor.current) ro.observe(anchor.current);
111
+ return () => {
112
+ cancelAnimationFrame(frame);
113
+ window.removeEventListener("scroll", schedule, true);
114
+ window.removeEventListener("resize", schedule);
115
+ ro.disconnect();
116
+ };
117
+ }, [open, update, anchor]);
118
+
119
+ return { setFloating, side: placed, floatingRef: floating, update };
120
+ }
121
+
122
+ /* Where the panel grows from, and where it slides in from, for each side */
123
+ export const ORIGIN: Record<Side, string> = {
124
+ top: "bottom",
125
+ bottom: "top",
126
+ left: "right",
127
+ right: "left",
128
+ };
129
+ export const ENTER_FROM: Record<Side, string> = {
130
+ top: "translateY(4px)",
131
+ bottom: "translateY(-4px)",
132
+ left: "translateX(4px)",
133
+ right: "translateX(-4px)",
134
+ };
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useRef, useState } from "react";
4
+ import { ENTER_FROM, LAYER, ORIGIN, Portal, Side, useAnchoredPosition } from "./Floating";
5
+
6
+ interface HoverCardProps {
7
+ /** What you hover: a link, a name, an avatar */
8
+ trigger: React.ReactNode;
9
+ children: React.ReactNode;
10
+ side?: Side;
11
+ align?: "start" | "center" | "end";
12
+ /** Milliseconds to wait before opening, and before closing */
13
+ openDelay?: number;
14
+ closeDelay?: number;
15
+ className?: string;
16
+ }
17
+
18
+ /* A preview that floats up when you rest the pointer on something, like
19
+ a profile behind a username. It waits a beat before opening, stays
20
+ open while the pointer crosses into it, and opens on keyboard focus
21
+ too. It's a preview: anything in it should also be reachable by
22
+ following the trigger. */
23
+ export function HoverCard({
24
+ trigger,
25
+ children,
26
+ side = "bottom",
27
+ align = "center",
28
+ openDelay = 450,
29
+ closeDelay = 200,
30
+ className = "",
31
+ }: HoverCardProps) {
32
+ const uid = useId();
33
+ const ref = useRef<HTMLSpanElement>(null);
34
+ const [open, setOpen] = useState(false);
35
+ const [mounted, setMounted] = useState(false);
36
+ const [visible, setVisible] = useState(false);
37
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
38
+ const { setFloating, side: placed } = useAnchoredPosition(ref, { open: mounted, side, align, gap: 10 });
39
+
40
+ if (open && !mounted) setMounted(true);
41
+ if (!open && visible) setVisible(false);
42
+
43
+ useEffect(() => {
44
+ if (open) {
45
+ const id = requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
46
+ return () => cancelAnimationFrame(id);
47
+ }
48
+ const t = setTimeout(() => setMounted(false), 180);
49
+ return () => clearTimeout(t);
50
+ }, [open]);
51
+
52
+ useEffect(() => () => {
53
+ if (timer.current) clearTimeout(timer.current);
54
+ }, []);
55
+
56
+ useEffect(() => {
57
+ if (!open) return;
58
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
59
+ document.addEventListener("keydown", onKey);
60
+ return () => document.removeEventListener("keydown", onKey);
61
+ }, [open]);
62
+
63
+ const later = (next: boolean, ms: number) => {
64
+ if (timer.current) clearTimeout(timer.current);
65
+ timer.current = setTimeout(() => setOpen(next), ms);
66
+ };
67
+ // Touch has no hover; a tap goes through to the trigger instead
68
+ const enter = (e: React.PointerEvent) => e.pointerType !== "touch" && later(true, openDelay);
69
+ const leave = (e: React.PointerEvent) => e.pointerType !== "touch" && later(false, closeDelay);
70
+
71
+ return (
72
+ <span
73
+ ref={ref}
74
+ className={["inline-flex", className].join(" ")}
75
+ onPointerEnter={enter}
76
+ onPointerLeave={leave}
77
+ onFocus={() => later(true, 0)}
78
+ onBlur={() => later(false, closeDelay)}
79
+ >
80
+ {React.isValidElement(trigger)
81
+ ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
82
+ "aria-describedby": mounted ? `${uid}-card` : undefined,
83
+ })
84
+ : trigger}
85
+ {mounted && (
86
+ <Portal>
87
+ <div
88
+ ref={setFloating}
89
+ id={`${uid}-card`}
90
+ onPointerEnter={enter}
91
+ onPointerLeave={leave}
92
+ className="w-[300px] max-w-[calc(100vw-16px)] rounded-[18px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
93
+ style={{
94
+ position: "fixed",
95
+ top: 0,
96
+ left: 0,
97
+ zIndex: LAYER.floating,
98
+ transformOrigin: ORIGIN[placed],
99
+ opacity: visible ? 1 : 0,
100
+ transform: visible ? "none" : `${ENTER_FROM[placed]} scale(0.96)`,
101
+ background: "var(--panel-bezel)",
102
+ boxShadow: "var(--overlay-shadow)",
103
+ }}
104
+ >
105
+ <div className="rounded-[13px] bg-panel-face p-4 text-[13px] text-ink-body" style={{ boxShadow: "var(--panel-face-shadow)" }}>
106
+ {children}
107
+ </div>
108
+ </div>
109
+ </Portal>
110
+ )}
111
+ </span>
112
+ );
113
+ }