@blurrow/ui 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +48 -36
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +172 -5
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +6 -6
  199. package/dist/registry/Alert.tsx +13 -10
  200. package/dist/registry/AuthorCard.tsx +7 -7
  201. package/dist/registry/Avatar.tsx +5 -5
  202. package/dist/registry/Badge.tsx +12 -15
  203. package/dist/registry/Banner.tsx +15 -18
  204. package/dist/registry/Breadcrumb.tsx +3 -3
  205. package/dist/registry/Button.tsx +58 -69
  206. package/dist/registry/ButtonGroup.tsx +9 -9
  207. package/dist/registry/Card.tsx +15 -15
  208. package/dist/registry/CardShell.tsx +6 -6
  209. package/dist/registry/Carousel.tsx +19 -15
  210. package/dist/registry/Checkbox.tsx +14 -9
  211. package/dist/registry/CloseButton.tsx +2 -2
  212. package/dist/registry/CodeBlock.tsx +15 -15
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +31 -24
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +4 -4
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +17 -15
  221. package/dist/registry/DropdownMenu.tsx +39 -77
  222. package/dist/registry/EmptyState.tsx +8 -8
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +54 -43
  228. package/dist/registry/Kbd.tsx +4 -5
  229. package/dist/registry/Modal.tsx +17 -15
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +15 -18
  233. package/dist/registry/Popover.tsx +40 -45
  234. package/dist/registry/Progress.tsx +4 -4
  235. package/dist/registry/QuoteCard.tsx +7 -7
  236. package/dist/registry/Radio.tsx +24 -21
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +5 -5
  239. package/dist/registry/ReadingProgress.tsx +2 -2
  240. package/dist/registry/ReadingTime.tsx +2 -2
  241. package/dist/registry/Select.tsx +28 -53
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +2 -2
  244. package/dist/registry/Sidebar.tsx +18 -18
  245. package/dist/registry/Skeleton.tsx +1 -1
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +6 -6
  249. package/dist/registry/Stat.tsx +4 -4
  250. package/dist/registry/StatCard.tsx +19 -19
  251. package/dist/registry/Steps.tsx +7 -7
  252. package/dist/registry/Table.tsx +12 -10
  253. package/dist/registry/TableOfContents.tsx +8 -8
  254. package/dist/registry/Tabs.tsx +6 -6
  255. package/dist/registry/TagInput.tsx +9 -9
  256. package/dist/registry/Textarea.tsx +5 -5
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +14 -14
  259. package/dist/registry/Toast.tsx +15 -12
  260. package/dist/registry/Toggle.tsx +7 -10
  261. package/dist/registry/Tooltip.tsx +21 -73
  262. package/dist/registry/styles.css +868 -62
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/styles.css +868 -62
  265. package/dist/useFloatingPanel.d.ts +24 -0
  266. package/dist/useFloatingPanel.js +89 -0
  267. package/dist/useFloatingPanel.js.map +1 -0
  268. package/dist/useModalBehavior.d.ts +15 -0
  269. package/dist/useModalBehavior.js +57 -0
  270. package/dist/useModalBehavior.js.map +1 -0
  271. package/package.json +1 -1
@@ -0,0 +1,232 @@
1
+ "use client";
2
+
3
+ import React, { useId, useRef, useState } from "react";
4
+ import { Calendar, CalendarIcon, ClearKey, DateRange, sameDay, startOfDay, toISODate } from "./DatePicker";
5
+ import { Portal } from "./Floating";
6
+ import { FieldLabel, FieldMessage } from "./FieldMessage";
7
+ import { useFloatingPanel } from "./useFloatingPanel";
8
+
9
+ export type { DateRange };
10
+
11
+ export interface DateRangePreset {
12
+ label: string;
13
+ range: () => DateRange;
14
+ }
15
+
16
+ interface DateRangePickerProps {
17
+ value?: DateRange;
18
+ defaultValue?: DateRange;
19
+ onChange?: (range: DateRange) => void;
20
+ label?: string;
21
+ hint?: string;
22
+ error?: string;
23
+ placeholder?: string;
24
+ min?: Date;
25
+ max?: Date;
26
+ isDateDisabled?: (date: Date) => boolean;
27
+ weekStartsOn?: 0 | 1;
28
+ locale?: string;
29
+ /** Shortcuts down the side, like "last 7 days" */
30
+ presets?: DateRangePreset[];
31
+ /** One month instead of two side by side */
32
+ singleMonth?: boolean;
33
+ clearable?: boolean;
34
+ disabled?: boolean;
35
+ fullWidth?: boolean;
36
+ /** Submits name[from] and name[to] as yyyy-mm-dd */
37
+ name?: string;
38
+ className?: string;
39
+ }
40
+
41
+ const EMPTY: DateRange = { from: null, to: null };
42
+
43
+ /* The first click sets where the range starts, the second where it ends
44
+ (either way round), and the days between sink into a well as you hover.
45
+ Two months sit side by side, wrapping to one above the other on a
46
+ narrow screen. */
47
+ export function DateRangePicker({
48
+ value,
49
+ defaultValue = EMPTY,
50
+ onChange,
51
+ label,
52
+ hint,
53
+ error,
54
+ placeholder = "pick dates",
55
+ min,
56
+ max,
57
+ isDateDisabled,
58
+ weekStartsOn,
59
+ locale = "en-US",
60
+ presets,
61
+ singleMonth = false,
62
+ clearable = false,
63
+ disabled = false,
64
+ fullWidth = false,
65
+ name,
66
+ className = "",
67
+ }: DateRangePickerProps) {
68
+ const uid = useId();
69
+ const ref = useRef<HTMLDivElement>(null);
70
+ const buttonRef = useRef<HTMLButtonElement>(null);
71
+ const [internal, setInternal] = useState<DateRange>(defaultValue);
72
+ const range = value ?? internal;
73
+ // Mid-selection: the start is down, the end follows the pointer
74
+ const [draft, setDraft] = useState<Date | null>(null);
75
+ const [hover, setHover] = useState<Date | null>(null);
76
+ const { open, mounted, toggle, close, setFloating, panelStyle } = useFloatingPanel(ref, {
77
+ onEscape: () => buttonRef.current?.focus(),
78
+ });
79
+ const messageId = error || hint ? `${uid}-message` : undefined;
80
+
81
+ // Reset a half-made selection whenever the panel closes
82
+ const [wasOpen, setWasOpen] = useState(open);
83
+ if (open !== wasOpen) {
84
+ setWasOpen(open);
85
+ if (!open) {
86
+ setDraft(null);
87
+ setHover(null);
88
+ }
89
+ }
90
+
91
+ const set = (r: DateRange) => {
92
+ setInternal(r);
93
+ onChange?.(r);
94
+ };
95
+
96
+ const pickDay = (d: Date) => {
97
+ if (!draft) {
98
+ setDraft(d);
99
+ return;
100
+ }
101
+ const [from, to] = d < draft ? [d, draft] : [draft, d];
102
+ set({ from, to });
103
+ setDraft(null);
104
+ close();
105
+ buttonRef.current?.focus();
106
+ };
107
+
108
+ // What the calendar shades: the finished range, or the one being drawn
109
+ const shown: DateRange = draft
110
+ ? hover && !sameDay(hover, draft)
111
+ ? hover < draft
112
+ ? { from: hover, to: draft }
113
+ : { from: draft, to: hover }
114
+ : { from: draft, to: draft }
115
+ : range;
116
+
117
+ const fmt = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" });
118
+ const text =
119
+ range.from && range.to
120
+ ? // formatRange drops the repeated month and year: "Aug 3 – 12, 2028"
121
+ fmt.formatRange(range.from, range.to)
122
+ : range.from
123
+ ? `${fmt.format(range.from)} –`
124
+ : null;
125
+
126
+ return (
127
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
128
+ {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
129
+ <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-72", className].join(" ")}>
130
+ {/* The field's error styling keys off aria-invalid, as Input's does */}
131
+ {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}
132
+ <button
133
+ ref={buttonRef}
134
+ type="button"
135
+ disabled={disabled}
136
+ onClick={toggle}
137
+ aria-haspopup="dialog"
138
+ aria-expanded={open}
139
+ aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
140
+ aria-invalid={error ? true : undefined}
141
+ aria-describedby={messageId}
142
+ data-open={open || undefined}
143
+ className={[
144
+ "field group/date relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",
145
+ clearable && range.from ? "pr-10" : "pr-4",
146
+ "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
147
+ ].join(" ")}
148
+ >
149
+ <CalendarIcon
150
+ className={[
151
+ "shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",
152
+ open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/date:text-ink-body",
153
+ ].join(" ")}
154
+ />
155
+ <span id={`${uid}-value`} className={["truncate tabular-nums", text ? "font-medium text-ink" : "text-ink-faint"].join(" ")}>
156
+ {text ?? placeholder}
157
+ </span>
158
+ </button>
159
+ {clearable && range.from && !disabled && (
160
+ <ClearKey
161
+ label="clear dates"
162
+ onClear={() => {
163
+ set(EMPTY);
164
+ buttonRef.current?.focus();
165
+ }}
166
+ />
167
+ )}
168
+ {name && (
169
+ <>
170
+ <input type="hidden" name={`${name}[from]`} value={range.from ? toISODate(range.from) : ""} />
171
+ <input type="hidden" name={`${name}[to]`} value={range.to ? toISODate(range.to) : ""} />
172
+ </>
173
+ )}
174
+ </div>
175
+
176
+ {mounted && (
177
+ <Portal>
178
+ <div
179
+ ref={setFloating}
180
+ role="dialog"
181
+ aria-label={label ? `choose ${label}` : "choose dates"}
182
+ className="max-w-[calc(100vw-16px)] rounded-[16px] p-1.5"
183
+ style={panelStyle}
184
+ >
185
+ <div
186
+ className="flex flex-col gap-3 rounded-[11px] bg-panel-face p-2.5 sm:flex-row"
187
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
188
+ >
189
+ {presets && presets.length > 0 && (
190
+ <div className="flex shrink-0 flex-row flex-wrap gap-1 border-line-strong pb-2 sm:w-32 sm:flex-col sm:border-r sm:pb-0 sm:pr-2">
191
+ {presets.map((p) => (
192
+ <button
193
+ key={p.label}
194
+ type="button"
195
+ onClick={() => {
196
+ const r = p.range();
197
+ set({ from: r.from && startOfDay(r.from), to: r.to && startOfDay(r.to) });
198
+ close();
199
+ buttonRef.current?.focus();
200
+ }}
201
+ className="cursor-pointer rounded-[8px] px-2.5 py-1.5 text-left text-[12.5px] text-ink-body transition-colors hover:bg-surface-active hover:text-ink focus-visible:outline-2 focus-visible:outline-ink"
202
+ >
203
+ {p.label}
204
+ </button>
205
+ ))}
206
+ </div>
207
+ )}
208
+ <div className="flex flex-col gap-2">
209
+ <Calendar
210
+ range={shown}
211
+ onChange={pickDay}
212
+ onDayHover={setHover}
213
+ months={singleMonth ? 1 : 2}
214
+ min={min}
215
+ max={max}
216
+ isDateDisabled={isDateDisabled}
217
+ weekStartsOn={weekStartsOn}
218
+ locale={locale}
219
+ autoFocus
220
+ />
221
+ <p aria-live="polite" className="px-1 text-[11.5px] text-ink-faint">
222
+ {draft ? "now pick the last day" : "pick the first day"}
223
+ </p>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </Portal>
228
+ )}
229
+ <FieldMessage id={messageId} error={error} hint={hint} />
230
+ </div>
231
+ );
232
+ }
@@ -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
 
@@ -42,16 +43,15 @@ export function Drawer({
42
43
  const uid = useId();
43
44
 
44
45
  /* ── mount / unmount ────────────────────────────────── */
46
+ /* Mounting on open and sliding out on close happen during render, so
47
+ neither waits for an extra commit; only the unmount is delayed */
48
+ if (open && !mounted) setMounted(true);
49
+ if (!open && visible) setVisible(false);
50
+
45
51
  useEffect(() => {
46
- if (open) {
47
- setMounted(true);
48
- return;
49
- }
50
- if (mounted) {
51
- setVisible(false);
52
- const t = setTimeout(() => setMounted(false), EXIT_MS);
53
- return () => clearTimeout(t);
54
- }
52
+ if (open || !mounted) return;
53
+ const t = setTimeout(() => setMounted(false), EXIT_MS);
54
+ return () => clearTimeout(t);
55
55
  }, [open, mounted]);
56
56
 
57
57
  /* ── trigger enter animation after mount ────────────── */
@@ -70,6 +70,7 @@ export function Drawer({
70
70
  const isRight = side === "right";
71
71
 
72
72
  return (
73
+ <Portal>
73
74
  <div className="fixed inset-0 z-90">
74
75
  <div
75
76
  aria-hidden
@@ -117,7 +118,7 @@ export function Drawer({
117
118
  }}
118
119
  >
119
120
  <div
120
- className="flex flex-col h-full bg-white rounded-[16px] overflow-hidden"
121
+ className="flex flex-col h-full bg-panel-face rounded-[16px] overflow-hidden"
121
122
  style={{ boxShadow: "var(--panel-face-shadow)" }}
122
123
  >
123
124
  <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">
@@ -125,13 +126,13 @@ export function Drawer({
125
126
  {title && (
126
127
  <h2
127
128
  id={`${uid}-title`}
128
- className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"
129
+ className="text-[16px] font-semibold text-ink tracking-[-0.015em] leading-snug"
129
130
  >
130
131
  {title}
131
132
  </h2>
132
133
  )}
133
134
  {description && (
134
- <p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">
135
+ <p id={`${uid}-desc`} className="text-[13px] text-ink-muted leading-relaxed">
135
136
  {description}
136
137
  </p>
137
138
  )}
@@ -140,7 +141,7 @@ export function Drawer({
140
141
  </div>
141
142
 
142
143
  {children && (
143
- <div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">
144
+ <div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-ink-body leading-relaxed">
144
145
  {children}
145
146
  </div>
146
147
  )}
@@ -149,8 +150,8 @@ export function Drawer({
149
150
  <div
150
151
  className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"
151
152
  style={{
152
- background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",
153
- boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",
153
+ background: "var(--panel-tray)",
154
+ boxShadow: "inset 0 1px 0 var(--hairline)",
154
155
  }}
155
156
  >
156
157
  {actions}
@@ -160,5 +161,6 @@ export function Drawer({
160
161
  </div>
161
162
  </div>
162
163
  </div>
164
+ </Portal>
163
165
  );
164
166
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";
4
4
  import { Kbd } from "./Kbd";
5
+ import { ENTER_FROM, LAYER, Portal, useAnchoredPosition } from "./Floating";
5
6
 
6
7
  type DropdownMenuPosition =
7
8
  | "bottom-start"
@@ -27,20 +28,6 @@ interface DropdownMenuProps {
27
28
  className?: string;
28
29
  }
29
30
 
30
- const positionStyles: Record<DropdownMenuPosition, string> = {
31
- "bottom-start": "top-full left-0 mt-2 origin-top-left",
32
- "bottom-end": "top-full right-0 mt-2 origin-top-right",
33
- "top-start": "bottom-full left-0 mb-2 origin-bottom-left",
34
- "top-end": "bottom-full right-0 mb-2 origin-bottom-right",
35
- };
36
-
37
- const enterFrom: Record<DropdownMenuPosition, string> = {
38
- "bottom-start": "-translate-y-1.5",
39
- "bottom-end": "-translate-y-1.5",
40
- "top-start": "translate-y-1.5",
41
- "top-end": "translate-y-1.5",
42
- };
43
-
44
31
  export function DropdownMenu({
45
32
  trigger,
46
33
  items,
@@ -51,15 +38,21 @@ export function DropdownMenu({
51
38
  const [open, setOpen] = useState(false);
52
39
  const [visible, setVisible] = useState(false);
53
40
  const [mounted, setMounted] = useState(false);
54
- // Where the menu actually opens: the requested side unless that runs off-screen
55
- const [placement, setPlacement] = useState(position);
56
- const [shift, setShift] = useState(0);
57
41
  const ref = useRef<HTMLDivElement>(null);
58
- const menuRef = useRef<HTMLDivElement>(null);
42
+ const menuRef = useRef<HTMLDivElement | null>(null);
59
43
  const uid = useId();
44
+ const [v, h] = position.split("-") as ["top" | "bottom", "start" | "end"];
45
+ // Portalled and pinned to the trigger; flips up or over when it runs out of room
46
+ const { setFloating, side } = useAnchoredPosition(ref, { open: mounted, side: v, align: h });
47
+ const attachMenu = useCallback(
48
+ (el: HTMLDivElement | null) => {
49
+ menuRef.current = el;
50
+ setFloating(el);
51
+ },
52
+ [setFloating],
53
+ );
60
54
 
61
- const enabledItems = () =>
62
- Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
55
+ const enabledItems = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
63
56
 
64
57
  const focusItem = (index: number) => {
65
58
  const list = enabledItems();
@@ -67,51 +60,20 @@ export function DropdownMenu({
67
60
  list[(index + list.length) % list.length].focus();
68
61
  };
69
62
 
70
- /* Measured as soon as the menu is attached, before it fades in.
71
- offsetWidth/Height ignore the enter transform, so the numbers are the
72
- menu's settled size. */
73
- const attachMenu = useCallback((menu: HTMLDivElement | null) => {
74
- menuRef.current = menu;
75
- const anchor = ref.current;
76
- if (!menu || !anchor) return;
77
- const gap = 8;
78
- const a = anchor.getBoundingClientRect();
79
- const w = menu.offsetWidth;
80
- const h = menu.offsetHeight;
81
- const vw = document.documentElement.clientWidth;
82
- const vh = window.innerHeight;
83
-
84
- let [v, side] = position.split("-") as ["top" | "bottom", "start" | "end"];
85
- const fitsStart = a.left + w <= vw - gap;
86
- const fitsEnd = a.right - w >= gap;
87
- if (side === "end" && !fitsEnd && fitsStart) side = "start";
88
- else if (side === "start" && !fitsStart && fitsEnd) side = "end";
89
-
90
- const fitsBelow = a.bottom + gap + h <= vh - gap;
91
- const fitsAbove = a.top - gap - h >= gap;
92
- if (v === "bottom" && !fitsBelow && fitsAbove) v = "top";
93
- else if (v === "top" && !fitsAbove && fitsBelow) v = "bottom";
94
-
95
- // Neither side fits (narrow screens): slide it back inside the viewport
96
- const left = side === "start" ? a.left : a.right - w;
97
- const nudge = Math.max(gap - left, 0) - Math.max(left + w - (vw - gap), 0);
98
-
99
- setPlacement(`${v}-${side}` as DropdownMenuPosition);
100
- setShift(nudge);
101
- }, [position]);
63
+ /* Mounted and faded out during render; the effect only times the rest */
64
+ if (open && !mounted) setMounted(true);
65
+ if (!open && visible) setVisible(false);
102
66
 
103
67
  useEffect(() => {
104
68
  if (open) {
105
- setMounted(true);
106
69
  requestAnimationFrame(() =>
107
70
  requestAnimationFrame(() => {
108
71
  setVisible(true);
109
72
  // Land on the first item so arrow keys work straight away
110
- focusItem(0);
73
+ enabledItems()[0]?.focus();
111
74
  })
112
75
  );
113
76
  } else {
114
- setVisible(false);
115
77
  const t = setTimeout(() => setMounted(false), 180);
116
78
  return () => clearTimeout(t);
117
79
  }
@@ -119,9 +81,9 @@ export function DropdownMenu({
119
81
 
120
82
  useEffect(() => {
121
83
  function onOutside(e: MouseEvent) {
122
- if (ref.current && !ref.current.contains(e.target as Node)) {
123
- setOpen(false);
124
- }
84
+ const t = e.target as Node;
85
+ if (ref.current?.contains(t) || menuRef.current?.contains(t)) return;
86
+ setOpen(false);
125
87
  }
126
88
  document.addEventListener("mousedown", onOutside);
127
89
  return () => document.removeEventListener("mousedown", onOutside);
@@ -174,11 +136,6 @@ export function DropdownMenu({
174
136
  <div
175
137
  data-menu-trigger
176
138
  onClick={() => {
177
- if (!open) {
178
- // Start each opening from the requested side; attachMenu flips if needed
179
- setPlacement(position);
180
- setShift(0);
181
- }
182
139
  setOpen(!open);
183
140
  }}
184
141
  className="inline-flex cursor-pointer"
@@ -192,28 +149,32 @@ export function DropdownMenu({
192
149
  : trigger}
193
150
  </div>
194
151
  {mounted && (
152
+ <Portal>
195
153
  <div
196
154
  ref={attachMenu}
197
155
  id={`${uid}-menu`}
198
156
  role="menu"
199
157
  onKeyDown={onMenuKeyDown}
200
- className={[
201
- "absolute z-50 min-w-[200px] rounded-[16px] p-1.5",
202
- "transition-[opacity,transform,scale,translate] duration-200 ease-[var(--ease-spring)]",
203
- visible
204
- ? "opacity-100 scale-100 translate-y-0"
205
- : ["opacity-0 scale-[0.96]", enterFrom[placement]].join(" "),
206
- positionStyles[placement],
207
- ].join(" ")}
208
- style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)", marginLeft: shift || undefined }}
158
+ className="min-w-[200px] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
159
+ style={{
160
+ position: "fixed",
161
+ top: 0,
162
+ left: 0,
163
+ zIndex: LAYER.floating,
164
+ transformOrigin: `${side === "top" ? "bottom" : "top"} ${h === "start" ? "left" : "right"}`,
165
+ opacity: visible ? 1 : 0,
166
+ transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,
167
+ background: "var(--panel-bezel)",
168
+ boxShadow: "var(--overlay-shadow)",
169
+ }}
209
170
  >
210
171
  <div
211
- className="bg-white rounded-[11px] p-1 flex flex-col"
172
+ className="bg-panel-face rounded-[11px] p-1 flex flex-col"
212
173
  style={{ boxShadow: "var(--panel-face-shadow)" }}
213
174
  >
214
175
  {items.map((item) => {
215
176
  if (item.divider) {
216
- return <div key={item.key} role="separator" className="h-px bg-neutral-100 my-1 mx-2" />;
177
+ return <div key={item.key} role="separator" className="h-px bg-line my-1 mx-2" />;
217
178
  }
218
179
 
219
180
  return (
@@ -230,15 +191,15 @@ export function DropdownMenu({
230
191
  "transition-[background,color] duration-100",
231
192
  "disabled:opacity-35 disabled:pointer-events-none",
232
193
  item.danger
233
- ? "text-red-600 focus:bg-red-50"
234
- : "text-neutral-700 focus:text-neutral-900 focus:bg-neutral-100/80",
194
+ ? "text-danger focus:bg-danger-wash"
195
+ : "text-ink-label focus:text-ink focus:bg-surface-active/80",
235
196
  ].join(" ")}
236
197
  >
237
198
  {item.icon && (
238
199
  <span
239
200
  className={[
240
201
  "shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",
241
- item.danger ? "text-red-500" : "text-neutral-400 group-focus/item:text-neutral-700",
202
+ item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",
242
203
  ].join(" ")}
243
204
  >
244
205
  {item.icon}
@@ -251,6 +212,7 @@ export function DropdownMenu({
251
212
  })}
252
213
  </div>
253
214
  </div>
215
+ </Portal>
254
216
  )}
255
217
  </div>
256
218
  );
@@ -21,7 +21,7 @@ export function EmptyState({
21
21
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
22
22
  >
23
23
  <div
24
- className="relative bg-white rounded-[15px] overflow-hidden flex flex-col items-center justify-center text-center py-14 px-6"
24
+ className="relative bg-panel-face rounded-[15px] overflow-hidden flex flex-col items-center justify-center text-center py-14 px-6"
25
25
  style={{ boxShadow: "var(--panel-face-shadow)" }}
26
26
  >
27
27
  {/* A faint dotted floor: the space is empty, not broken */}
@@ -29,24 +29,24 @@ export function EmptyState({
29
29
  aria-hidden
30
30
  className="pointer-events-none absolute inset-0"
31
31
  style={{
32
- backgroundImage: "radial-gradient(rgba(17,17,17,0.07) 1px, transparent 1px)",
32
+ backgroundImage: "radial-gradient(var(--dots) 1px, transparent 1px)",
33
33
  backgroundSize: "14px 14px",
34
- maskImage: "radial-gradient(60% 70% at 50% 40%, #000 0%, transparent 100%)",
35
- WebkitMaskImage: "radial-gradient(60% 70% at 50% 40%, #000 0%, transparent 100%)",
34
+ maskImage: "radial-gradient(60% 70% at 50% 40%, black 0%, transparent 100%)",
35
+ WebkitMaskImage: "radial-gradient(60% 70% at 50% 40%, black 0%, transparent 100%)",
36
36
  }}
37
37
  />
38
38
  {icon && (
39
39
  <div
40
40
  aria-hidden
41
- className="relative w-12 h-12 rounded-[14px] flex items-center justify-center text-neutral-500 mb-5"
42
- style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow), 0 6px 16px -6px rgba(17,17,17,0.15)" }}
41
+ className="relative w-12 h-12 rounded-[14px] flex items-center justify-center text-ink-muted mb-5"
42
+ style={{ background: "var(--face)", boxShadow: "var(--face-shadow), var(--face-drop)" }}
43
43
  >
44
44
  {icon}
45
45
  </div>
46
46
  )}
47
- <p className="relative text-[15px] font-semibold text-neutral-900 tracking-[-0.015em]">{title}</p>
47
+ <p className="relative text-[15px] font-semibold text-ink tracking-[-0.015em]">{title}</p>
48
48
  {description && (
49
- <p className="relative text-[13px] text-neutral-500 mt-1.5 max-w-xs leading-relaxed">{description}</p>
49
+ <p className="relative text-[13px] text-ink-muted mt-1.5 max-w-xs leading-relaxed">{description}</p>
50
50
  )}
51
51
  {action && <div className="relative mt-6">{action}</div>}
52
52
  </div>