@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,108 @@
1
+ import React from "react";
2
+
3
+ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
4
+ label?: string;
5
+ hint?: string;
6
+ error?: string;
7
+ fullWidth?: boolean;
8
+ iconLeft?: React.ReactNode;
9
+ iconRight?: React.ReactNode;
10
+ /** Seat the well in the same bezel as a `size="lg"` button, so a field and
11
+ a button side by side (a newsletter, a waitlist) share one height. */
12
+ layered?: boolean;
13
+ }
14
+
15
+ /* Fields are sunken wells (see --field tokens in globals.css). Raised
16
+ means "press me", sunken means "fill me" — that is the whole system.
17
+ Layered keeps the well sunken; only the bezel around it is the button's. */
18
+
19
+ export function Input({
20
+ label,
21
+ hint,
22
+ error,
23
+ fullWidth = false,
24
+ iconLeft,
25
+ iconRight,
26
+ layered = false,
27
+ className = "",
28
+ id,
29
+ ...rest
30
+ }: InputProps) {
31
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
32
+ const messageId = inputId && (error || hint) ? `${inputId}-message` : undefined;
33
+
34
+ return (
35
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
36
+ {label && (
37
+ <label
38
+ htmlFor={inputId}
39
+ className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong"
40
+ >
41
+ {label}
42
+ </label>
43
+ )}
44
+ <div
45
+ className={layered ? "relative rounded-[15px] p-[3.5px]" : "contents"}
46
+ style={layered ? { background: "var(--bezel)", boxShadow: "var(--bezel-shadow)" } : undefined}
47
+ >
48
+ <div className="relative group/input isolate">
49
+ {iconLeft && (
50
+ <span
51
+ className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-faint transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-ink-soft group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
52
+ >
53
+ {iconLeft}
54
+ </span>
55
+ )}
56
+ <input
57
+ id={inputId}
58
+ className={[
59
+ "field relative z-10 w-full text-ink caret-ink",
60
+ layered ? "rounded-[11.5px] py-3 text-[15px]" : "rounded-xl py-3 text-[13.5px]",
61
+ "placeholder:text-ink-faint placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-ink-ghost",
62
+ "disabled:opacity-40 disabled:cursor-not-allowed",
63
+ iconLeft ? "pl-10" : "pl-4",
64
+ iconRight ? "pr-10" : "pr-4",
65
+ className,
66
+ ]
67
+ .filter(Boolean)
68
+ .join(" ")}
69
+ aria-invalid={error ? true : undefined}
70
+ aria-describedby={messageId}
71
+ {...rest}
72
+ />
73
+ <span
74
+ aria-hidden
75
+ className={["pointer-events-none absolute inset-0 z-15 overflow-hidden", layered ? "rounded-[11.5px]" : "rounded-xl"].join(" ")}
76
+ >
77
+ <span
78
+ className="block absolute inset-y-0 w-[30%] -left-[30%] opacity-0 group-focus-within/input:animate-input-shimmer"
79
+ style={{
80
+ background:
81
+ "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)",
82
+ }}
83
+ />
84
+ </span>
85
+ {iconRight && (
86
+ <span
87
+ className="absolute right-3.5 top-1/2 -translate-y-1/2 text-ink-faint transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-ink-soft group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
88
+ >
89
+ {iconRight}
90
+ </span>
91
+ )}
92
+ </div>
93
+ </div>
94
+ {error && (
95
+ <p id={messageId} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
96
+ <svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
97
+ <circle cx="12" cy="12" r="10" />
98
+ <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
99
+ </svg>
100
+ {error}
101
+ </p>
102
+ )}
103
+ {hint && !error && (
104
+ <p id={messageId} className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>
105
+ )}
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,23 @@
1
+ import { ReactNode } from "react";
2
+
3
+ export function Kbd({
4
+ children,
5
+ className = "",
6
+ }: {
7
+ children: ReactNode;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <kbd
12
+ className={`inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-md text-[11px] font-semibold text-ink-muted ${className}`}
13
+ style={{
14
+ /* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */
15
+ background: "var(--face)",
16
+ boxShadow: "var(--key-shadow)",
17
+ textShadow: "var(--emboss)",
18
+ }}
19
+ >
20
+ {children}
21
+ </kbd>
22
+ );
23
+ }
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { Portal } from "./Floating";
5
+ import { CloseButton } from "./CloseButton";
6
+ import { useModalBehavior } from "./useModalBehavior";
7
+
8
+ interface ModalProps {
9
+ open: boolean;
10
+ onClose: () => void;
11
+ title?: string;
12
+ description?: string;
13
+ children?: React.ReactNode;
14
+ actions?: React.ReactNode;
15
+ className?: string;
16
+ }
17
+
18
+ const CLOSE_MS = 240;
19
+
20
+ export function Modal({
21
+ open,
22
+ onClose,
23
+ title,
24
+ description,
25
+ children,
26
+ actions,
27
+ className = "",
28
+ }: ModalProps) {
29
+ const [mounted, setMounted] = useState(false);
30
+ const [visible, setVisible] = useState(false);
31
+ const dialogRef = useRef<HTMLDivElement>(null);
32
+ const uid = useId();
33
+
34
+ /* Mounting on open and fading on close happen during render, so
35
+ neither waits for an extra commit; only the unmount is delayed */
36
+ if (open && !mounted) setMounted(true);
37
+ if (!open && visible) setVisible(false);
38
+
39
+ useEffect(() => {
40
+ if (open || !mounted) return;
41
+ const t = setTimeout(() => setMounted(false), CLOSE_MS);
42
+ return () => clearTimeout(t);
43
+ }, [open, mounted]);
44
+
45
+ useLayoutEffect(() => {
46
+ if (!mounted || !open || visible) return;
47
+ void dialogRef.current?.getBoundingClientRect();
48
+ const id = requestAnimationFrame(() => setVisible(true));
49
+ return () => cancelAnimationFrame(id);
50
+ }, [mounted, open, visible]);
51
+
52
+ // Escape, focus trap + restore, scroll lock
53
+ useModalBehavior({ open, ready: visible, ref: dialogRef, onClose });
54
+
55
+ if (!mounted) return null;
56
+
57
+ return (
58
+ <Portal>
59
+ <div className="fixed inset-0 z-90 flex items-center justify-center p-4">
60
+ <div
61
+ aria-hidden
62
+ className={[
63
+ "absolute inset-0 backdrop-blur-[6px]",
64
+ "transition-opacity ease-out",
65
+ visible ? "opacity-100 duration-300" : "opacity-0 duration-200",
66
+ ].join(" ")}
67
+ style={{ background: "var(--scrim)" }}
68
+ onClick={onClose}
69
+ />
70
+ <div
71
+ ref={dialogRef}
72
+ role="dialog"
73
+ aria-modal="true"
74
+ aria-labelledby={title ? `${uid}-title` : undefined}
75
+ aria-describedby={description ? `${uid}-desc` : undefined}
76
+ className={[
77
+ "relative z-10 w-full max-w-lg rounded-[22px] p-1.5",
78
+ "transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",
79
+ visible
80
+ ? "opacity-100 scale-100 translate-y-0 duration-300"
81
+ : "opacity-0 scale-[0.94] translate-y-3 duration-200",
82
+ className,
83
+ ]
84
+ .filter(Boolean)
85
+ .join(" ")}
86
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
87
+ >
88
+ <div
89
+ className="bg-panel-face rounded-[16px] overflow-hidden"
90
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
91
+ >
92
+ <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4">
93
+ <div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
94
+ {title && (
95
+ <h2
96
+ id={`${uid}-title`}
97
+ className="text-[16px] font-semibold text-ink tracking-[-0.015em] leading-snug"
98
+ >
99
+ {title}
100
+ </h2>
101
+ )}
102
+ {description && (
103
+ <p id={`${uid}-desc`} className="text-[13px] text-ink-muted leading-relaxed">
104
+ {description}
105
+ </p>
106
+ )}
107
+ </div>
108
+ <CloseButton label="Close dialog" onClick={onClose} />
109
+ </div>
110
+
111
+ {children && <div className="px-6 pb-5 text-[13.5px] text-ink-body leading-relaxed">{children}</div>}
112
+
113
+ {/* Footer sits in the bezel tone so actions read as a separate tray */}
114
+ {actions && (
115
+ <div
116
+ className="px-5 py-3.5 flex items-center justify-end gap-2"
117
+ style={{
118
+ background: "var(--panel-tray)",
119
+ boxShadow: "inset 0 1px 0 var(--hairline)",
120
+ }}
121
+ >
122
+ {actions}
123
+ </div>
124
+ )}
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </Portal>
129
+ );
130
+ }
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useRef, useState } from "react";
4
+ import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";
5
+
6
+ interface NumberInputProps
7
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "size" | "prefix"> {
8
+ value?: number | null;
9
+ defaultValue?: number | null;
10
+ onChange?: (value: number | null) => void;
11
+ min?: number;
12
+ max?: number;
13
+ step?: number;
14
+ /** Digits after the decimal point; inferred from `step` when left out */
15
+ precision?: number;
16
+ label?: string;
17
+ hint?: string;
18
+ error?: string;
19
+ /** A unit shown before or after the number, like "$" or "kg" */
20
+ prefix?: React.ReactNode;
21
+ suffix?: React.ReactNode;
22
+ fullWidth?: boolean;
23
+ }
24
+
25
+ const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
26
+
27
+ /* A sunken field with two small raised keys at its right edge. The keys
28
+ repeat while held, arrow keys step (shift for ten at a time), and
29
+ whatever you type is clamped and rounded when you leave the field. */
30
+ export function NumberInput({
31
+ value,
32
+ defaultValue = null,
33
+ onChange,
34
+ min = -Infinity,
35
+ max = Infinity,
36
+ step = 1,
37
+ precision,
38
+ label,
39
+ hint,
40
+ error,
41
+ prefix,
42
+ suffix,
43
+ fullWidth = false,
44
+ disabled,
45
+ className = "",
46
+ id,
47
+ onBlur,
48
+ onKeyDown,
49
+ ...rest
50
+ }: NumberInputProps) {
51
+ const uid = useId();
52
+ const inputId = id ?? `${uid}-input`;
53
+ const messageId = error || hint ? `${inputId}-message` : undefined;
54
+ const digits = precision ?? decimals(step);
55
+
56
+ const [internal, setInternal] = useState<number | null>(defaultValue);
57
+ const current = value !== undefined ? value : internal;
58
+ const format = (n: number | null) => (n === null ? "" : n.toFixed(digits));
59
+ const [draft, setDraft] = useState(format(current));
60
+ const editing = useRef(false);
61
+
62
+ // Follow the controlled value unless someone is mid-way through typing
63
+ useEffect(() => {
64
+ if (!editing.current) setDraft(format(current));
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [current, digits]);
67
+
68
+ const clamp = (n: number) => Math.min(max, Math.max(min, Number(n.toFixed(digits))));
69
+
70
+ // The hold-to-repeat timer reads the newest value from here, not a stale render
71
+ const latest = useRef(current);
72
+ useEffect(() => {
73
+ latest.current = current;
74
+ }, [current]);
75
+
76
+ const commit = (n: number | null) => {
77
+ const next = n === null || Number.isNaN(n) ? null : clamp(n);
78
+ const prev = latest.current;
79
+ latest.current = next;
80
+ setInternal(next);
81
+ setDraft(format(next));
82
+ if (next !== prev) onChange?.(next);
83
+ };
84
+
85
+ // An empty field starts from zero, or from the nearest edge of the range
86
+ const nudge = (dir: 1 | -1, times = 1) => {
87
+ const base = latest.current;
88
+ commit(base === null ? clamp(0) : base + dir * step * times);
89
+ };
90
+
91
+ const hold = useRef<{ t?: ReturnType<typeof setTimeout>; i?: ReturnType<typeof setInterval> }>({});
92
+ const stopHold = () => {
93
+ clearTimeout(hold.current.t);
94
+ clearInterval(hold.current.i);
95
+ };
96
+ useEffect(() => stopHold, []);
97
+ const startHold = (dir: 1 | -1) => {
98
+ stopHold();
99
+ nudge(dir);
100
+ hold.current.t = setTimeout(() => {
101
+ hold.current.i = setInterval(() => nudge(dir), 60);
102
+ }, 400);
103
+ };
104
+
105
+ const atMin = current !== null && current <= min;
106
+ const atMax = current !== null && current >= max;
107
+
108
+ return (
109
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
110
+ {label && <FieldLabel htmlFor={inputId}>{label}</FieldLabel>}
111
+ <div className={["relative isolate", fullWidth ? "w-full" : "w-40", className].join(" ")}>
112
+ {prefix && (
113
+ <span className="pointer-events-none absolute left-3.5 top-1/2 z-20 -translate-y-1/2 text-[13.5px] text-ink-faint transition-colors duration-200 group-focus-within/field:text-ink-label">
114
+ {prefix}
115
+ </span>
116
+ )}
117
+ <input
118
+ {...rest}
119
+ id={inputId}
120
+ type="text"
121
+ inputMode={digits > 0 || min < 0 ? "decimal" : "numeric"}
122
+ autoComplete="off"
123
+ role="spinbutton"
124
+ aria-valuenow={current ?? undefined}
125
+ aria-valuemin={Number.isFinite(min) ? min : undefined}
126
+ aria-valuemax={Number.isFinite(max) ? max : undefined}
127
+ aria-invalid={error ? true : undefined}
128
+ aria-describedby={messageId}
129
+ disabled={disabled}
130
+ value={draft}
131
+ onChange={(e) => {
132
+ editing.current = true;
133
+ setDraft(e.target.value.replace(/[^\d.,\-]/g, ""));
134
+ }}
135
+ onBlur={(e) => {
136
+ editing.current = false;
137
+ const raw = draft.replace(",", ".").trim();
138
+ commit(raw === "" || raw === "-" ? null : parseFloat(raw));
139
+ onBlur?.(e);
140
+ }}
141
+ onKeyDown={(e) => {
142
+ onKeyDown?.(e);
143
+ if (e.defaultPrevented) return;
144
+ const big = e.shiftKey ? 10 : 1;
145
+ if (e.key === "ArrowUp") { e.preventDefault(); editing.current = false; nudge(1, big); }
146
+ else if (e.key === "ArrowDown") { e.preventDefault(); editing.current = false; nudge(-1, big); }
147
+ else if (e.key === "PageUp") { e.preventDefault(); nudge(1, 10); }
148
+ else if (e.key === "PageDown") { e.preventDefault(); nudge(-1, 10); }
149
+ else if (e.key === "Home" && Number.isFinite(min)) { e.preventDefault(); commit(min); }
150
+ else if (e.key === "End" && Number.isFinite(max)) { e.preventDefault(); commit(max); }
151
+ else if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); (e.target as HTMLInputElement).focus(); }
152
+ }}
153
+ className={[
154
+ "field relative z-10 w-full rounded-xl py-3 text-[13.5px] tabular-nums text-ink caret-ink",
155
+ "placeholder:text-ink-faint focus:placeholder:text-ink-ghost",
156
+ "disabled:opacity-40 disabled:cursor-not-allowed",
157
+ prefix ? "pl-8" : "pl-4",
158
+ suffix ? "pr-[92px]" : "pr-[64px]",
159
+ ].join(" ")}
160
+ />
161
+ {suffix && (
162
+ <span className="pointer-events-none absolute right-[64px] top-1/2 z-20 -translate-y-1/2 text-[12.5px] text-ink-faint">
163
+ {suffix}
164
+ </span>
165
+ )}
166
+ <div
167
+ className={[
168
+ "absolute right-1.5 top-1/2 z-20 flex -translate-y-1/2 gap-1",
169
+ disabled ? "opacity-40 pointer-events-none" : "",
170
+ ].join(" ")}
171
+ >
172
+ <FieldKey label="decrease" focusable={false} disabled={atMin} onPress={() => startHold(-1)} onRelease={stopHold}>
173
+ <path d="M5 12h14" />
174
+ </FieldKey>
175
+ <FieldKey label="increase" focusable={false} disabled={atMax} onPress={() => startHold(1)} onRelease={stopHold}>
176
+ <path d="M12 5v14M5 12h14" />
177
+ </FieldKey>
178
+ </div>
179
+ </div>
180
+ <FieldMessage id={messageId} error={error} hint={hint} />
181
+ </div>
182
+ );
183
+ }
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import React, { useId, useRef, useState } from "react";
4
+ import { FieldLabel, FieldMessage } from "./FieldMessage";
5
+
6
+ interface OtpInputProps {
7
+ /** How many characters */
8
+ length?: number;
9
+ value?: string;
10
+ defaultValue?: string;
11
+ onChange?: (code: string) => void;
12
+ /** Fires once every cell is filled, with the whole code */
13
+ onComplete?: (code: string) => void;
14
+ /** Digits only, or letters and digits */
15
+ type?: "numeric" | "alphanumeric";
16
+ /** Split the cells into wells of this many, like 123 456 */
17
+ groupSize?: number;
18
+ /** Show dots instead of the characters */
19
+ mask?: boolean;
20
+ label?: string;
21
+ hint?: string;
22
+ error?: string;
23
+ disabled?: boolean;
24
+ autoFocus?: boolean;
25
+ name?: string;
26
+ }
27
+
28
+ const CELL = 44;
29
+
30
+ /* One real input sits underneath, so typing, backspace, paste and the
31
+ phone filling the code from a text all behave natively. What you see
32
+ is drawn over it: each group is a single sunken field, cut into cells
33
+ by scored grooves, that lifts flush and draws its border while you're
34
+ in it, exactly like Input. A caret glides from cell to cell on a
35
+ spring as you type, and characters press into place. */
36
+ export function OtpInput({
37
+ length = 6,
38
+ value,
39
+ defaultValue = "",
40
+ onChange,
41
+ onComplete,
42
+ type = "numeric",
43
+ groupSize,
44
+ mask = false,
45
+ label,
46
+ hint,
47
+ error,
48
+ disabled = false,
49
+ autoFocus = false,
50
+ name,
51
+ }: OtpInputProps) {
52
+ const uid = useId();
53
+ const inputRef = useRef<HTMLInputElement>(null);
54
+ const [internal, setInternal] = useState(defaultValue.slice(0, length));
55
+ const code = (value !== undefined ? value : internal).slice(0, length);
56
+ const [focused, setFocused] = useState(false);
57
+ // The cell the caret is in: the selection start, kept within the code
58
+ const [cursor, setCursor] = useState(0);
59
+ const messageId = error || hint ? `${uid}-message` : undefined;
60
+ const at = Math.min(cursor, code.length, length - 1);
61
+
62
+ const pattern = type === "numeric" ? /[^0-9]/g : /[^0-9a-z]/gi;
63
+
64
+ /* A filled cell is selected rather than sitting behind a caret, so
65
+ typing over it replaces it in place */
66
+ const selectCell = (i: number) => {
67
+ const el = inputRef.current;
68
+ if (!el) return;
69
+ const n = el.value.length;
70
+ const pos = Math.max(0, Math.min(i, n));
71
+ el.setSelectionRange(pos, pos < n ? pos + 1 : pos);
72
+ setCursor(pos);
73
+ };
74
+
75
+ const groups = groupSize && groupSize < length ? Math.ceil(length / groupSize) : 1;
76
+ const per = groups > 1 ? groupSize! : length;
77
+
78
+ return (
79
+ <div className="group/field flex flex-col gap-1.5">
80
+ {label && (
81
+ <FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>
82
+ {label}
83
+ </FieldLabel>
84
+ )}
85
+
86
+ <div
87
+ className={["relative flex w-fit items-center gap-3", disabled ? "opacity-40" : ""].join(" ")}
88
+ >
89
+ <input
90
+ ref={inputRef}
91
+ id={`${uid}-input`}
92
+ type={mask ? "password" : "text"}
93
+ inputMode={type === "numeric" ? "numeric" : "text"}
94
+ autoComplete="one-time-code"
95
+ autoCapitalize="characters"
96
+ autoFocus={autoFocus}
97
+ spellCheck={false}
98
+ maxLength={length}
99
+ disabled={disabled}
100
+ value={code}
101
+ aria-invalid={error ? true : undefined}
102
+ aria-describedby={messageId}
103
+ aria-label={label ? undefined : `${length}-character code`}
104
+ // The input lies over the cells; send the caret to whichever was clicked
105
+ onClick={(e) => {
106
+ const cell = document
107
+ .elementsFromPoint(e.clientX, e.clientY)
108
+ .find((el): el is HTMLElement => el instanceof HTMLElement && el.dataset.cell !== undefined);
109
+ selectCell(cell ? Number(cell.dataset.cell) : code.length);
110
+ }}
111
+ onFocus={() => {
112
+ setFocused(true);
113
+ requestAnimationFrame(() => selectCell(code.length));
114
+ }}
115
+ onBlur={() => setFocused(false)}
116
+ onChange={(e) => {
117
+ const next = e.target.value.replace(pattern, "").toUpperCase().slice(0, length);
118
+ setInternal(next);
119
+ onChange?.(next);
120
+ if (next.length === length && next !== code) onComplete?.(next);
121
+ const pos = Math.min(e.target.selectionStart ?? next.length, next.length);
122
+ requestAnimationFrame(() => selectCell(pos));
123
+ }}
124
+ onKeyDown={(e) => {
125
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
126
+ e.preventDefault();
127
+ selectCell(at + (e.key === "ArrowLeft" ? -1 : 1));
128
+ } else if (e.key === "Home" || e.key === "End") {
129
+ e.preventDefault();
130
+ selectCell(e.key === "Home" ? 0 : code.length);
131
+ }
132
+ }}
133
+ onSelect={(e) => {
134
+ const el = e.currentTarget;
135
+ setCursor(Math.min(el.selectionStart ?? 0, length - 1));
136
+ }}
137
+ // Invisible, but it's what a tap lands on, so phones raise their keyboard
138
+ className="absolute inset-0 z-30 h-full w-full cursor-text opacity-0 disabled:cursor-not-allowed"
139
+ style={{ fontSize: 16 /* stops iOS zooming in on focus */ }}
140
+ />
141
+
142
+ {Array.from({ length: groups }, (_, g) => {
143
+ const start = g * per;
144
+ const cells = Math.min(per, length - start);
145
+ const caretHere = focused && at >= start && at < start + cells;
146
+ return (
147
+ <React.Fragment key={g}>
148
+ {g > 0 && (
149
+ <span
150
+ aria-hidden
151
+ className="h-[2px] w-2.5 shrink-0 rounded-full"
152
+ style={{ background: "var(--groove)", boxShadow: "var(--groove-lip-below)" }}
153
+ />
154
+ )}
155
+ {/* One field per group: data-open gives it the focused well */}
156
+ <div
157
+ aria-hidden
158
+ data-open={caretHere || undefined}
159
+ data-invalid={error ? true : undefined}
160
+ className="field relative z-10 flex h-[52px] overflow-hidden rounded-xl"
161
+ >
162
+ {Array.from({ length: cells }, (_, k) => {
163
+ const i = start + k;
164
+ const ch = code[i];
165
+ return (
166
+ <div key={i} data-cell={i} className="relative grid place-items-center" style={{ width: CELL }}>
167
+ {k > 0 && (
168
+ <span
169
+ className="absolute inset-y-3 left-0 w-px"
170
+ style={{ background: "var(--groove)", boxShadow: "var(--groove-lip)" }}
171
+ />
172
+ )}
173
+ {ch &&
174
+ (mask ? (
175
+ <span key={ch + i} className="animate-checkbox-in h-2.5 w-2.5 rounded-full bg-ink" />
176
+ ) : (
177
+ <span key={ch + i} className="animate-checkbox-in text-[20px] font-semibold tabular-nums text-ink">
178
+ {ch}
179
+ </span>
180
+ ))}
181
+ {/* A filled cell under the caret is picked out, ready to be typed over */}
182
+ {caretHere && i === at && ch && (
183
+ <span className="absolute inset-x-1.5 bottom-2 h-[2px] rounded-full bg-ink animate-otp-caret" />
184
+ )}
185
+ </div>
186
+ );
187
+ })}
188
+
189
+ {/* The caret glides between the empty cells of this group */}
190
+ {caretHere && !code[at] && (
191
+ <span
192
+ className="pointer-events-none absolute top-1/2 h-[22px] w-[2px] -translate-y-1/2"
193
+ style={{
194
+ left: (at - start) * CELL + CELL / 2 - 1,
195
+ transition: "left 260ms var(--ease-spring)",
196
+ }}
197
+ >
198
+ <span className="block h-full w-full animate-otp-caret rounded-full bg-ink" />
199
+ </span>
200
+ )}
201
+
202
+ {/* Input's sweep of light as the well comes up */}
203
+ {caretHere && (
204
+ <span
205
+ className="pointer-events-none absolute inset-y-0 -left-[30%] block w-[30%] animate-input-shimmer"
206
+ style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}
207
+ />
208
+ )}
209
+ </div>
210
+ </React.Fragment>
211
+ );
212
+ })}
213
+ </div>
214
+
215
+ {name && <input type="hidden" name={name} value={code} />}
216
+ <FieldMessage id={messageId} error={error} hint={hint} />
217
+ </div>
218
+ );
219
+ }