@blurrow/ui 0.1.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 (272) hide show
  1. package/README.md +60 -20
  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 +1644 -0
  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 +140 -0
  199. package/dist/registry/Alert.tsx +95 -0
  200. package/dist/registry/AuthorCard.tsx +67 -0
  201. package/dist/registry/Avatar.tsx +116 -0
  202. package/dist/registry/Badge.tsx +55 -0
  203. package/dist/registry/Banner.tsx +86 -0
  204. package/dist/registry/Breadcrumb.tsx +60 -0
  205. package/dist/registry/Button.tsx +361 -0
  206. package/dist/registry/ButtonGroup.tsx +82 -0
  207. package/dist/registry/Card.tsx +241 -0
  208. package/dist/registry/CardShell.tsx +78 -0
  209. package/dist/registry/Carousel.tsx +296 -0
  210. package/dist/registry/Checkbox.tsx +104 -0
  211. package/dist/registry/CloseButton.tsx +68 -0
  212. package/dist/registry/CodeBlock.tsx +202 -0
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +356 -0
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +49 -0
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +166 -0
  221. package/dist/registry/DropdownMenu.tsx +219 -0
  222. package/dist/registry/EmptyState.tsx +55 -0
  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 +108 -0
  228. package/dist/registry/Kbd.tsx +23 -0
  229. package/dist/registry/Modal.tsx +130 -0
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +160 -0
  233. package/dist/registry/Popover.tsx +116 -0
  234. package/dist/registry/Progress.tsx +66 -0
  235. package/dist/registry/QuoteCard.tsx +93 -0
  236. package/dist/registry/Radio.tsx +205 -0
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +101 -0
  239. package/dist/registry/ReadingProgress.tsx +51 -0
  240. package/dist/registry/ReadingTime.tsx +35 -0
  241. package/dist/registry/Select.tsx +265 -0
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +134 -0
  244. package/dist/registry/Sidebar.tsx +182 -0
  245. package/dist/registry/Skeleton.tsx +83 -0
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +106 -0
  249. package/dist/registry/Stat.tsx +82 -0
  250. package/dist/registry/StatCard.tsx +256 -0
  251. package/dist/registry/Steps.tsx +109 -0
  252. package/dist/registry/Table.tsx +123 -0
  253. package/dist/registry/TableOfContents.tsx +116 -0
  254. package/dist/registry/Tabs.tsx +136 -0
  255. package/dist/registry/TagInput.tsx +191 -0
  256. package/dist/registry/Textarea.tsx +84 -0
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +102 -0
  259. package/dist/registry/Toast.tsx +388 -0
  260. package/dist/registry/Toggle.tsx +96 -0
  261. package/dist/registry/Tooltip.tsx +160 -0
  262. package/dist/registry/styles.css +1207 -0
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/registry/useModalBehavior.ts +77 -0
  265. package/dist/styles.css +868 -62
  266. package/dist/useFloatingPanel.d.ts +24 -0
  267. package/dist/useFloatingPanel.js +89 -0
  268. package/dist/useFloatingPanel.js.map +1 -0
  269. package/dist/useModalBehavior.d.ts +15 -0
  270. package/dist/useModalBehavior.js +57 -0
  271. package/dist/useModalBehavior.js.map +1 -0
  272. package/package.json +23 -6
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+
3
+ interface SeparatorProps {
4
+ orientation?: "horizontal" | "vertical";
5
+ /** A few words set into the middle of the line, like "or" */
6
+ label?: React.ReactNode;
7
+ /** Only draws a line; screen readers skip it */
8
+ decorative?: boolean;
9
+ className?: string;
10
+ }
11
+
12
+ /* A groove scored into the surface: a dark line with a lit lip just
13
+ under it, the way a cut catches light from above. */
14
+ function Groove({ vertical }: { vertical: boolean }) {
15
+ return (
16
+ <span
17
+ aria-hidden
18
+ className={vertical ? "block w-px self-stretch" : "block h-px flex-1"}
19
+ style={{
20
+ background: "var(--groove)",
21
+ boxShadow: vertical ? "var(--groove-lip)" : "var(--groove-lip-below)",
22
+ }}
23
+ />
24
+ );
25
+ }
26
+
27
+ export function Separator({ orientation = "horizontal", label, decorative = false, className = "" }: SeparatorProps) {
28
+ const vertical = orientation === "vertical";
29
+ const a11y = decorative
30
+ ? { "aria-hidden": true as const }
31
+ : { role: "separator", "aria-orientation": orientation };
32
+
33
+ if (vertical) {
34
+ return (
35
+ <span {...a11y} className={["inline-flex self-stretch", className].join(" ")}>
36
+ <Groove vertical />
37
+ </span>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div {...a11y} className={["flex w-full items-center gap-3", className].join(" ")}>
43
+ <Groove vertical={false} />
44
+ {label && (
45
+ <>
46
+ <span className="shrink-0 text-[11.5px] font-medium text-ink-faint">{label}</span>
47
+ <Groove vertical={false} />
48
+ </>
49
+ )}
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { ButtonGroup } from "./ButtonGroup";
5
+
6
+ interface ShareButtonsProps {
7
+ url?: string;
8
+ title?: string;
9
+ className?: string;
10
+ }
11
+
12
+
13
+ function LinkIcon() {
14
+ return (
15
+ <svg
16
+ width="14"
17
+ height="14"
18
+ viewBox="0 0 24 24"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ strokeWidth="1.75"
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ >
25
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
26
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ function CheckIcon() {
32
+ return (
33
+ <svg
34
+ width="14"
35
+ height="14"
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ strokeWidth="2"
40
+ strokeLinecap="round"
41
+ strokeLinejoin="round"
42
+ >
43
+ <polyline points="20 6 9 17 4 12" />
44
+ </svg>
45
+ );
46
+ }
47
+
48
+ function XIcon() {
49
+ return (
50
+ <svg width="13" height="13" fill="currentColor" viewBox="0 0 24 24">
51
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ function LinkedInIcon() {
57
+ return (
58
+ <svg width="13" height="13" fill="currentColor" viewBox="0 0 24 24">
59
+ <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
60
+ </svg>
61
+ );
62
+ }
63
+
64
+ export function ShareButtons({
65
+ url,
66
+ title,
67
+ className = "",
68
+ }: ShareButtonsProps) {
69
+ const [copied, setCopied] = useState(false);
70
+
71
+ const shareUrl = url ?? (typeof window !== "undefined" ? window.location.href : "");
72
+ const shareTitle = title ?? "";
73
+
74
+ const handleCopy = useCallback(async () => {
75
+ try {
76
+ await navigator.clipboard.writeText(shareUrl);
77
+ setCopied(true);
78
+ setTimeout(() => setCopied(false), 2000);
79
+ } catch {
80
+ // Fallback silently
81
+ }
82
+ }, [shareUrl]);
83
+
84
+ const handleTwitter = useCallback(() => {
85
+ const params = new URLSearchParams({
86
+ text: shareTitle,
87
+ url: shareUrl,
88
+ });
89
+ window.open(
90
+ `https://x.com/intent/tweet?${params.toString()}`,
91
+ "_blank",
92
+ "noopener,noreferrer"
93
+ );
94
+ }, [shareUrl, shareTitle]);
95
+
96
+ const handleLinkedIn = useCallback(() => {
97
+ const params = new URLSearchParams({
98
+ url: shareUrl,
99
+ });
100
+ window.open(
101
+ `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`,
102
+ "_blank",
103
+ "noopener,noreferrer"
104
+ );
105
+ }, [shareUrl]);
106
+
107
+ const keys = [
108
+ {
109
+ label: copied ? "Link copied" : "Copy link",
110
+ onClick: handleCopy as () => void,
111
+ content: (
112
+ <>
113
+ <span key={String(copied)} className={["animate-checkbox-in", copied ? "text-success-soft" : ""].join(" ")}>
114
+ {copied ? <CheckIcon /> : <LinkIcon />}
115
+ </span>
116
+ <span className="w-[62px] text-left">{copied ? "copied" : "copy link"}</span>
117
+ </>
118
+ ),
119
+ wide: true,
120
+ },
121
+ { label: "Share on X", onClick: handleTwitter, content: <XIcon />, wide: false },
122
+ { label: "Share on LinkedIn", onClick: handleLinkedIn, content: <LinkedInIcon />, wide: false },
123
+ ];
124
+
125
+ return (
126
+ <div className={className}>
127
+ <p className="text-[12px] font-medium text-ink-muted lowercase mb-3">
128
+ Share
129
+ </p>
130
+ <ButtonGroup label="Share" items={keys} />
131
+ <span aria-live="polite" className="sr-only">{copied ? "Link copied to clipboard" : ""}</span>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
4
+
5
+ interface SidebarItem {
6
+ key: string;
7
+ label: string;
8
+ icon?: React.ReactNode;
9
+ badge?: string | number;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ interface SidebarSection {
14
+ title?: string;
15
+ items: SidebarItem[];
16
+ }
17
+
18
+ interface SidebarHeader {
19
+ logo?: React.ReactNode;
20
+ title: string;
21
+ subtitle?: string;
22
+ }
23
+
24
+ interface SidebarProps {
25
+ sections: SidebarSection[];
26
+ header?: SidebarHeader;
27
+ defaultActive?: string;
28
+ active?: string;
29
+ onSelect?: (key: string) => void;
30
+ className?: string;
31
+ }
32
+
33
+ export function Sidebar({
34
+ sections,
35
+ header,
36
+ defaultActive = "",
37
+ active,
38
+ onSelect,
39
+ className = "",
40
+ }: SidebarProps) {
41
+ const [internal, setInternal] = useState(defaultActive);
42
+ const current = active !== undefined ? active : internal;
43
+ const [pill, setPill] = useState({ top: 0, height: 0, ready: false });
44
+ const navRef = useRef<HTMLElement>(null);
45
+ const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
46
+
47
+ const measurePill = (key: string) => {
48
+ const btn = btnRefs.current[key];
49
+ const nav = navRef.current;
50
+ if (!btn || !nav) return;
51
+ const nr = nav.getBoundingClientRect();
52
+ const br = btn.getBoundingClientRect();
53
+ setPill({ top: br.top - nr.top, height: br.height, ready: true });
54
+ };
55
+
56
+ // Before paint, so the pill is never seen out of place
57
+ useLayoutEffect(() => { measurePill(current); }, [current]);
58
+
59
+ useEffect(() => {
60
+ const ro = new ResizeObserver(() => measurePill(current));
61
+ if (navRef.current) ro.observe(navRef.current);
62
+ return () => ro.disconnect();
63
+ }, [current]);
64
+
65
+ const handleSelect = (key: string, disabled?: boolean) => {
66
+ if (disabled) return;
67
+ setInternal(key);
68
+ onSelect?.(key);
69
+ };
70
+
71
+ return (
72
+ <div
73
+ className={["rounded-[20px] p-1.5", className].join(" ")}
74
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
75
+ >
76
+ <div className="bg-panel-face rounded-[15px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>
77
+ {header && (
78
+ <div className="px-3 py-3 border-b border-line flex items-center gap-2.5">
79
+ {header.logo && (
80
+ <div
81
+ className="w-7 h-7 rounded-[8px] flex items-center justify-center shrink-0 text-ink-inverse"
82
+ style={{ background: "var(--face-inverse)", boxShadow: "var(--face-inverse-shadow)" }}
83
+ >
84
+ {header.logo}
85
+ </div>
86
+ )}
87
+ <div className="flex-1 min-w-0">
88
+ <p className="text-[13px] font-semibold text-ink leading-none truncate">{header.title}</p>
89
+ {header.subtitle && (
90
+ <p className="text-[11px] text-ink-faint mt-0.5 leading-none truncate">{header.subtitle}</p>
91
+ )}
92
+ </div>
93
+ <button
94
+ type="button"
95
+ aria-label="Switch workspace"
96
+ className="shrink-0 w-6 h-6 flex items-center justify-center text-ink-faint hover:text-ink transition-colors cursor-pointer rounded-[7px] hover:bg-surface-active"
97
+ >
98
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
99
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
100
+ </svg>
101
+ </button>
102
+ </div>
103
+ )}
104
+
105
+ <nav ref={navRef} aria-label={header?.title ?? "Sidebar"} className="relative p-2 flex flex-col gap-4">
106
+ {pill.ready && (
107
+ <span
108
+ aria-hidden
109
+ className="absolute left-2 right-2 rounded-[11px] pointer-events-none z-0"
110
+ style={{
111
+ top: pill.top,
112
+ height: pill.height,
113
+ background: "var(--face-inverse)",
114
+ boxShadow: "var(--face-inverse-shadow)",
115
+ transition: "top 300ms var(--ease-spring), height 300ms var(--ease-spring)",
116
+ }}
117
+ />
118
+ )}
119
+
120
+ {sections.map((section, si) => (
121
+ <div key={si} className="flex flex-col gap-0.5">
122
+ {section.title && (
123
+ <p className="relative z-10 px-2.5 pt-1 pb-1.5 text-[11.5px] font-medium text-ink-faint lowercase">
124
+ {section.title}
125
+ </p>
126
+ )}
127
+ {section.items.map((item) => {
128
+ const isActive = current === item.key;
129
+ return (
130
+ <button
131
+ key={item.key}
132
+ ref={(el) => { btnRefs.current[item.key] = el; }}
133
+ type="button"
134
+ onClick={() => handleSelect(item.key, item.disabled)}
135
+ disabled={item.disabled}
136
+ aria-current={isActive ? "page" : undefined}
137
+ className={[
138
+ "relative z-10 w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[11px] text-[13px] font-medium text-left",
139
+ "transition-colors duration-200 cursor-pointer",
140
+ "disabled:opacity-35 disabled:pointer-events-none",
141
+ isActive
142
+ ? "text-ink-inverse"
143
+ : "text-ink-body hover:text-ink hover:bg-surface-active/80",
144
+ ].join(" ")}
145
+ >
146
+ {item.icon && (
147
+ <span
148
+ className={[
149
+ "shrink-0 w-[22px] h-[22px] rounded-md flex items-center justify-center transition-colors duration-200",
150
+ isActive
151
+ ? "bg-ink-inverse/12 shadow-[inset_0_1px_0_var(--inverse-lip)]"
152
+ : "text-ink-muted [background:var(--face)] [box-shadow:var(--face-shadow)]",
153
+ ].join(" ")}
154
+ >
155
+ {item.icon}
156
+ </span>
157
+ )}
158
+
159
+ <span className="flex-1 truncate">{item.label}</span>
160
+
161
+ {item.badge !== undefined && (
162
+ <span
163
+ className={[
164
+ "shrink-0 text-[11px] font-medium px-1.5 py-0.5 rounded-md tabular-nums leading-none transition-colors duration-200",
165
+ isActive
166
+ ? "bg-ink-inverse/20 text-ink-inverse/80"
167
+ : "bg-surface-active text-ink-muted",
168
+ ].join(" ")}
169
+ >
170
+ {item.badge}
171
+ </span>
172
+ )}
173
+ </button>
174
+ );
175
+ })}
176
+ </div>
177
+ ))}
178
+ </nav>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+
3
+ type SkeletonVariant = "text" | "circle" | "rect" | "card";
4
+
5
+ interface SkeletonProps {
6
+ variant?: SkeletonVariant;
7
+ width?: string | number;
8
+ height?: string | number;
9
+ lines?: number;
10
+ className?: string;
11
+ /** What is loading, for screen readers */
12
+ label?: string;
13
+ }
14
+
15
+ /* Placeholders are empty sunken wells — the shape of what will fill them —
16
+ with a light sweep travelling across, rather than a blink. */
17
+ const base = "skeleton-sheen relative overflow-hidden rounded-[8px]";
18
+ const well: React.CSSProperties = { background: "var(--well)", boxShadow: "var(--well-shadow)" };
19
+
20
+ export function Skeleton({
21
+ variant = "text",
22
+ width,
23
+ height,
24
+ lines = 1,
25
+ className = "",
26
+ label = "Loading",
27
+ }: SkeletonProps) {
28
+ const a11y = { role: "status", "aria-busy": true, "aria-label": label } as const;
29
+
30
+ if (variant === "circle") {
31
+ return (
32
+ <div
33
+ {...a11y}
34
+ className={[base, "rounded-full", className].join(" ")}
35
+ style={{ ...well, width: width ?? 40, height: height ?? 40 }}
36
+ />
37
+ );
38
+ }
39
+
40
+ if (variant === "card") {
41
+ return (
42
+ <div
43
+ {...a11y}
44
+ className={["rounded-[20px] p-1.5", className].join(" ")}
45
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
46
+ >
47
+ <div className="bg-panel-face rounded-[15px] p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
48
+ <div className={[base, "w-full rounded-[11px]"].join(" ")} style={{ ...well, height: height ?? 160 }} />
49
+ <div className="px-3.5 pt-4 pb-3.5 flex flex-col gap-2.5">
50
+ <div className={[base, "h-3.5 w-3/5"].join(" ")} style={well} />
51
+ <div className={[base, "h-2.5 w-full"].join(" ")} style={well} />
52
+ <div className={[base, "h-2.5 w-4/5"].join(" ")} style={well} />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ if (variant === "rect") {
60
+ return (
61
+ <div
62
+ {...a11y}
63
+ className={[base, "rounded-[12px]", className].join(" ")}
64
+ style={{ ...well, width: width ?? "100%", height: height ?? 80 }}
65
+ />
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div {...a11y} className={["flex flex-col gap-2", className].join(" ")}>
71
+ {Array.from({ length: lines }).map((_, i) => (
72
+ <div
73
+ key={i}
74
+ className={[base, "h-2.5 rounded-full"].join(" ")}
75
+ style={{
76
+ ...well,
77
+ width: width ?? (i === lines - 1 && lines > 1 ? "60%" : "100%"),
78
+ }}
79
+ />
80
+ ))}
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,229 @@
1
+ "use client";
2
+
3
+ import React, { useId, useRef, useState } from "react";
4
+ import { FieldLabel } from "./FieldMessage";
5
+
6
+ type SliderValue = number | [number, number];
7
+
8
+ interface SliderProps<V extends SliderValue> {
9
+ /** One number for a slider, two for a range */
10
+ value?: V;
11
+ defaultValue?: V;
12
+ onChange?: (value: V) => void;
13
+ /** Fires once the knob is let go, for work too heavy to do while dragging */
14
+ onCommit?: (value: V) => void;
15
+ min?: number;
16
+ max?: number;
17
+ step?: number;
18
+ label?: string;
19
+ /** Show the value beside the label; pass a function to format it */
20
+ showValue?: boolean | ((value: V) => React.ReactNode);
21
+ /** Small labels under the track, at these values */
22
+ marks?: number[];
23
+ disabled?: boolean;
24
+ /** Name of a hidden input (two for a range: name[0], name[1]) */
25
+ name?: string;
26
+ className?: string;
27
+ }
28
+
29
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
30
+ const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
31
+
32
+ /* A sunken track with the filled part in the inverse material, and a
33
+ raised knob (or two, for a range) that you drag along it. The knob
34
+ swells while held; arrows step, shift steps by ten, page keys by a
35
+ tenth of the range, home and end jump to the ends. */
36
+ export function Slider<V extends SliderValue = number>({
37
+ value,
38
+ defaultValue,
39
+ onChange,
40
+ onCommit,
41
+ min = 0,
42
+ max = 100,
43
+ step = 1,
44
+ label,
45
+ showValue = false,
46
+ marks,
47
+ disabled = false,
48
+ name,
49
+ className = "",
50
+ }: SliderProps<V>) {
51
+ const uid = useId();
52
+ const trackRef = useRef<HTMLDivElement>(null);
53
+ const knobs = useRef<(HTMLSpanElement | null)[]>([]);
54
+ const isRange = Array.isArray(value ?? defaultValue);
55
+ const [internal, setInternal] = useState<V>(() => (defaultValue ?? value ?? (min as V)) as V);
56
+ const current = (value !== undefined ? value : internal) as V;
57
+ const values: number[] = Array.isArray(current) ? [...current] : [current as number];
58
+ const [dragging, setDragging] = useState<number | null>(null);
59
+ const digits = decimals(step);
60
+
61
+ const snap = (n: number) => Number(clamp(Math.round((n - min) / step) * step + min, min, max).toFixed(digits));
62
+ const pct = (n: number) => ((n - min) / (max - min)) * 100;
63
+
64
+ const emit = (next: number[], commit = false) => {
65
+ const v = (isRange ? [next[0], next[1]] : next[0]) as V;
66
+ setInternal(v);
67
+ if (next.some((n, i) => n !== values[i])) onChange?.(v);
68
+ if (commit) onCommit?.(v);
69
+ };
70
+
71
+ /* Each knob is kept on its own side of the other */
72
+ const setAt = (i: number, n: number, commit = false) => {
73
+ const next = [...values];
74
+ next[i] = snap(n);
75
+ if (isRange) next[i] = i === 0 ? Math.min(next[0], values[1]) : Math.max(next[1], values[0]);
76
+ emit(next, commit);
77
+ };
78
+
79
+ const fromPointer = (clientX: number) => {
80
+ const r = trackRef.current!.getBoundingClientRect();
81
+ return min + clamp((clientX - r.left) / r.width, 0, 1) * (max - min);
82
+ };
83
+
84
+ const onPointerDown = (e: React.PointerEvent) => {
85
+ if (disabled || e.button !== 0) return;
86
+ e.preventDefault();
87
+ const n = fromPointer(e.clientX);
88
+ // Grab whichever knob is nearer; on a tie, the one that can move that way
89
+ let i = 0;
90
+ if (isRange) {
91
+ const d0 = Math.abs(n - values[0]);
92
+ const d1 = Math.abs(n - values[1]);
93
+ i = d1 < d0 || (d0 === d1 && n > values[1]) ? 1 : 0;
94
+ }
95
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
96
+ setDragging(i);
97
+ knobs.current[i]?.focus();
98
+ setAt(i, n);
99
+ };
100
+
101
+ const onKeyDown = (i: number) => (e: React.KeyboardEvent) => {
102
+ const big = Math.max(step, (max - min) / 10);
103
+ const moves: Record<string, number> = {
104
+ ArrowRight: step * (e.shiftKey ? 10 : 1),
105
+ ArrowUp: step * (e.shiftKey ? 10 : 1),
106
+ ArrowLeft: -step * (e.shiftKey ? 10 : 1),
107
+ ArrowDown: -step * (e.shiftKey ? 10 : 1),
108
+ PageUp: big,
109
+ PageDown: -big,
110
+ };
111
+ if (e.key in moves) {
112
+ e.preventDefault();
113
+ setAt(i, values[i] + moves[e.key], true);
114
+ } else if (e.key === "Home") {
115
+ e.preventDefault();
116
+ setAt(i, min, true);
117
+ } else if (e.key === "End") {
118
+ e.preventDefault();
119
+ setAt(i, max, true);
120
+ }
121
+ };
122
+
123
+ const shown =
124
+ typeof showValue === "function"
125
+ ? showValue(current)
126
+ : showValue
127
+ ? values.join(" – ")
128
+ : null;
129
+
130
+ const lo = isRange ? pct(values[0]) : 0;
131
+ const hi = pct(values[isRange ? 1 : 0]);
132
+
133
+ return (
134
+ <div className={["flex w-full flex-col gap-2.5", disabled ? "opacity-40" : "", className].join(" ")}>
135
+ {(label || shown !== null) && (
136
+ <div className="flex items-center justify-between gap-4">
137
+ {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
138
+ {shown !== null && <span className="text-[12.5px] tabular-nums text-ink-muted">{shown}</span>}
139
+ </div>
140
+ )}
141
+
142
+ <div
143
+ className={["relative flex h-6 touch-none items-center", disabled ? "pointer-events-none" : "cursor-pointer"].join(" ")}
144
+ onPointerDown={onPointerDown}
145
+ onPointerMove={(e) => dragging !== null && setAt(dragging, fromPointer(e.clientX))}
146
+ onPointerUp={() => {
147
+ if (dragging !== null) onCommit?.(current);
148
+ setDragging(null);
149
+ }}
150
+ onPointerCancel={() => setDragging(null)}
151
+ >
152
+ {/* The track: a groove cut into the surface */}
153
+ <div
154
+ ref={trackRef}
155
+ className="relative h-2 w-full rounded-full"
156
+ style={{ background: "var(--track)", boxShadow: "var(--track-shadow)" }}
157
+ >
158
+ <div
159
+ className="absolute inset-y-px rounded-full"
160
+ style={{
161
+ left: `calc(${lo}% + ${isRange ? 0 : 1}px)`,
162
+ width: `calc(${hi - lo}% - ${isRange ? 0 : 1}px)`,
163
+ background: "var(--face-inverse)",
164
+ boxShadow: "var(--fill-shadow)",
165
+ transition: dragging === null ? "left 200ms var(--ease-out-soft), width 200ms var(--ease-out-soft)" : "none",
166
+ }}
167
+ />
168
+ </div>
169
+
170
+ {values.map((v, i) => (
171
+ <span
172
+ key={i}
173
+ ref={(el) => {
174
+ knobs.current[i] = el;
175
+ }}
176
+ role="slider"
177
+ tabIndex={disabled ? -1 : 0}
178
+ aria-labelledby={label ? `${uid}-label` : undefined}
179
+ aria-label={isRange ? (i === 0 ? "minimum" : "maximum") : label ? undefined : "value"}
180
+ aria-valuemin={isRange && i === 1 ? values[0] : min}
181
+ aria-valuemax={isRange && i === 0 ? values[1] : max}
182
+ aria-valuenow={v}
183
+ aria-disabled={disabled || undefined}
184
+ onKeyDown={onKeyDown(i)}
185
+ className="absolute top-1/2 h-5 w-5 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-page"
186
+ style={{
187
+ left: `${pct(v)}%`,
188
+ // the last-dragged knob of a range sits on top, so two at one spot can be pulled apart
189
+ zIndex: dragging === i ? 2 : 1,
190
+ background: "var(--face)",
191
+ boxShadow: "var(--knob-shadow)",
192
+ transform: `translate(-50%, -50%) scale(${dragging === i ? 1.15 : 1})`,
193
+ transition:
194
+ dragging === null
195
+ ? "left 200ms var(--ease-out-soft), transform 220ms var(--ease-spring)"
196
+ : "transform 220ms var(--ease-spring)",
197
+ }}
198
+ >
199
+ {/* A small dimple on the knob's crown */}
200
+ <span
201
+ aria-hidden
202
+ className="absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
203
+ style={{ background: "var(--track)", boxShadow: "var(--track-shadow)" }}
204
+ />
205
+ </span>
206
+ ))}
207
+ </div>
208
+
209
+ {marks && marks.length > 0 && (
210
+ <div aria-hidden className="relative h-4">
211
+ {marks.map((m) => (
212
+ <span
213
+ key={m}
214
+ className="absolute -translate-x-1/2 text-[11px] tabular-nums text-ink-faint"
215
+ style={{ left: `${pct(m)}%` }}
216
+ >
217
+ {m}
218
+ </span>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {name &&
224
+ values.map((v, i) => (
225
+ <input key={i} type="hidden" name={isRange ? `${name}[${i}]` : name} value={v} />
226
+ ))}
227
+ </div>
228
+ );
229
+ }