@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
@@ -7,10 +7,14 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
7
7
  fullWidth?: boolean;
8
8
  iconLeft?: React.ReactNode;
9
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;
10
13
  }
11
14
 
12
15
  /* Fields are sunken wells (see --field tokens in globals.css). Raised
13
- means "press me", sunken means "fill me" — that is the whole system. */
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. */
14
18
 
15
19
  export function Input({
16
20
  label,
@@ -19,6 +23,7 @@ export function Input({
19
23
  fullWidth = false,
20
24
  iconLeft,
21
25
  iconRight,
26
+ layered = false,
22
27
  className = "",
23
28
  id,
24
29
  ...rest
@@ -31,57 +36,63 @@ export function Input({
31
36
  {label && (
32
37
  <label
33
38
  htmlFor={inputId}
34
- className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-neutral-950"
39
+ className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong"
35
40
  >
36
41
  {label}
37
42
  </label>
38
43
  )}
39
- <div className="relative group/input isolate">
40
- {iconLeft && (
41
- <span
42
- className="absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400 transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-neutral-800 group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
43
- >
44
- {iconLeft}
45
- </span>
46
- )}
47
- <input
48
- id={inputId}
49
- className={[
50
- "field relative z-10 w-full rounded-xl py-3 text-[13.5px] text-neutral-900 caret-neutral-900",
51
- "placeholder:text-neutral-400 placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-neutral-300",
52
- "disabled:opacity-40 disabled:cursor-not-allowed",
53
- iconLeft ? "pl-10" : "pl-4",
54
- iconRight ? "pr-10" : "pr-4",
55
- className,
56
- ]
57
- .filter(Boolean)
58
- .join(" ")}
59
- aria-invalid={error ? true : undefined}
60
- aria-describedby={messageId}
61
- {...rest}
62
- />
63
- <span
64
- aria-hidden
65
- className="pointer-events-none absolute inset-0 z-15 rounded-xl overflow-hidden"
66
- >
67
- <span
68
- className="block absolute inset-y-0 w-[30%] -left-[30%] opacity-0 group-focus-within/input:animate-input-shimmer"
69
- style={{
70
- background:
71
- "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%)",
72
- }}
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}
73
72
  />
74
- </span>
75
- {iconRight && (
76
73
  <span
77
- className="absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400 transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-neutral-800 group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
74
+ aria-hidden
75
+ className={["pointer-events-none absolute inset-0 z-15 overflow-hidden", layered ? "rounded-[11.5px]" : "rounded-xl"].join(" ")}
78
76
  >
79
- {iconRight}
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
+ />
80
84
  </span>
81
- )}
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>
82
93
  </div>
83
94
  {error && (
84
- <p id={messageId} role="alert" className="text-[11.5px] text-red-600 font-medium flex items-center gap-1 mt-0.5">
95
+ <p id={messageId} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
85
96
  <svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
86
97
  <circle cx="12" cy="12" r="10" />
87
98
  <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
@@ -90,7 +101,7 @@ export function Input({
90
101
  </p>
91
102
  )}
92
103
  {hint && !error && (
93
- <p id={messageId} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>
104
+ <p id={messageId} className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>
94
105
  )}
95
106
  </div>
96
107
  );
@@ -9,13 +9,12 @@ export function Kbd({
9
9
  }) {
10
10
  return (
11
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-neutral-500 ${className}`}
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
13
  style={{
14
14
  /* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */
15
- background: "var(--face-light)",
16
- boxShadow:
17
- "0 0 3px rgba(17,17,17,0.136), 0 1.5px 1px rgba(17,17,17,0.12), 0 2px 3px -1px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",
18
- textShadow: "0 1px 0 rgba(255,255,255,0.9)",
15
+ background: "var(--face)",
16
+ boxShadow: "var(--key-shadow)",
17
+ textShadow: "var(--emboss)",
19
18
  }}
20
19
  >
21
20
  {children}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { Portal } from "./Floating";
4
5
  import { CloseButton } from "./CloseButton";
5
6
  import { useModalBehavior } from "./useModalBehavior";
6
7
 
@@ -30,16 +31,15 @@ export function Modal({
30
31
  const dialogRef = useRef<HTMLDivElement>(null);
31
32
  const uid = useId();
32
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
+
33
39
  useEffect(() => {
34
- if (open) {
35
- setMounted(true);
36
- return;
37
- }
38
- if (mounted) {
39
- setVisible(false);
40
- const t = setTimeout(() => setMounted(false), CLOSE_MS);
41
- return () => clearTimeout(t);
42
- }
40
+ if (open || !mounted) return;
41
+ const t = setTimeout(() => setMounted(false), CLOSE_MS);
42
+ return () => clearTimeout(t);
43
43
  }, [open, mounted]);
44
44
 
45
45
  useLayoutEffect(() => {
@@ -55,6 +55,7 @@ export function Modal({
55
55
  if (!mounted) return null;
56
56
 
57
57
  return (
58
+ <Portal>
58
59
  <div className="fixed inset-0 z-90 flex items-center justify-center p-4">
59
60
  <div
60
61
  aria-hidden
@@ -85,7 +86,7 @@ export function Modal({
85
86
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
86
87
  >
87
88
  <div
88
- className="bg-white rounded-[16px] overflow-hidden"
89
+ className="bg-panel-face rounded-[16px] overflow-hidden"
89
90
  style={{ boxShadow: "var(--panel-face-shadow)" }}
90
91
  >
91
92
  <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4">
@@ -93,13 +94,13 @@ export function Modal({
93
94
  {title && (
94
95
  <h2
95
96
  id={`${uid}-title`}
96
- className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"
97
+ className="text-[16px] font-semibold text-ink tracking-[-0.015em] leading-snug"
97
98
  >
98
99
  {title}
99
100
  </h2>
100
101
  )}
101
102
  {description && (
102
- <p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">
103
+ <p id={`${uid}-desc`} className="text-[13px] text-ink-muted leading-relaxed">
103
104
  {description}
104
105
  </p>
105
106
  )}
@@ -107,15 +108,15 @@ export function Modal({
107
108
  <CloseButton label="Close dialog" onClick={onClose} />
108
109
  </div>
109
110
 
110
- {children && <div className="px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">{children}</div>}
111
+ {children && <div className="px-6 pb-5 text-[13.5px] text-ink-body leading-relaxed">{children}</div>}
111
112
 
112
113
  {/* Footer sits in the bezel tone so actions read as a separate tray */}
113
114
  {actions && (
114
115
  <div
115
116
  className="px-5 py-3.5 flex items-center justify-end gap-2"
116
117
  style={{
117
- background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",
118
- boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",
118
+ background: "var(--panel-tray)",
119
+ boxShadow: "inset 0 1px 0 var(--hairline)",
119
120
  }}
120
121
  >
121
122
  {actions}
@@ -124,5 +125,6 @@ export function Modal({
124
125
  </div>
125
126
  </div>
126
127
  </div>
128
+ </Portal>
127
129
  );
128
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
+ }