@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,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
+ }
@@ -0,0 +1,166 @@
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 DrawerProps {
9
+ open: boolean;
10
+ onClose: () => void;
11
+ title?: string;
12
+ description?: string;
13
+ children?: React.ReactNode;
14
+ actions?: React.ReactNode;
15
+ side?: "left" | "right";
16
+ size?: "sm" | "md" | "lg";
17
+ className?: string;
18
+ }
19
+
20
+ const ENTER_MS = 380;
21
+ const EXIT_MS = 240;
22
+
23
+ const sizeMap = {
24
+ sm: "max-w-xs",
25
+ md: "max-w-md",
26
+ lg: "max-w-lg",
27
+ } as const;
28
+
29
+ export function Drawer({
30
+ open,
31
+ onClose,
32
+ title,
33
+ description,
34
+ children,
35
+ actions,
36
+ side = "right",
37
+ size = "md",
38
+ className = "",
39
+ }: DrawerProps) {
40
+ const [mounted, setMounted] = useState(false);
41
+ const [visible, setVisible] = useState(false);
42
+ const panelRef = useRef<HTMLDivElement>(null);
43
+ const uid = useId();
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
+
51
+ useEffect(() => {
52
+ if (open || !mounted) return;
53
+ const t = setTimeout(() => setMounted(false), EXIT_MS);
54
+ return () => clearTimeout(t);
55
+ }, [open, mounted]);
56
+
57
+ /* ── trigger enter animation after mount ────────────── */
58
+ useLayoutEffect(() => {
59
+ if (!mounted || !open || visible) return;
60
+ void panelRef.current?.getBoundingClientRect();
61
+ const id = requestAnimationFrame(() => setVisible(true));
62
+ return () => cancelAnimationFrame(id);
63
+ }, [mounted, open, visible]);
64
+
65
+ /* ── Escape, focus trap + restore, scroll lock ─────── */
66
+ useModalBehavior({ open, ready: visible, ref: panelRef, onClose });
67
+
68
+ if (!mounted) return null;
69
+
70
+ const isRight = side === "right";
71
+
72
+ return (
73
+ <Portal>
74
+ <div className="fixed inset-0 z-90">
75
+ <div
76
+ aria-hidden
77
+ className="absolute inset-0 backdrop-blur-[6px] transition-opacity ease-out"
78
+ style={{
79
+ background: "var(--scrim)",
80
+ opacity: visible ? 1 : 0,
81
+ transitionDuration: `${visible ? ENTER_MS : EXIT_MS}ms`,
82
+ }}
83
+ onClick={onClose}
84
+ />
85
+
86
+ {/* Floats a few px off the viewport edge so it reads as a panel, not a wall */}
87
+ <div
88
+ className={[
89
+ "absolute inset-y-0 w-full p-2 flex",
90
+ isRight ? "right-0 justify-end" : "left-0 justify-start",
91
+ ].join(" ")}
92
+ style={{ pointerEvents: "none" }}
93
+ >
94
+ <div
95
+ ref={panelRef}
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-labelledby={title ? `${uid}-title` : undefined}
99
+ aria-describedby={description ? `${uid}-desc` : undefined}
100
+ className={[
101
+ "relative h-full w-full rounded-[22px] p-1.5 flex flex-col pointer-events-auto",
102
+ sizeMap[size],
103
+ className,
104
+ ]
105
+ .filter(Boolean)
106
+ .join(" ")}
107
+ style={{
108
+ background: "var(--panel-bezel)",
109
+ boxShadow: "var(--modal-shadow)",
110
+ transform: visible
111
+ ? "translateX(0)"
112
+ : isRight
113
+ ? "translateX(calc(100% + 16px))"
114
+ : "translateX(calc(-100% - 16px))",
115
+ transition: `transform ${visible ? ENTER_MS : EXIT_MS}ms ${
116
+ visible ? "var(--ease-out-soft)" : "cubic-bezier(0.4,0,1,1)"
117
+ }`,
118
+ }}
119
+ >
120
+ <div
121
+ className="flex flex-col h-full bg-panel-face rounded-[16px] overflow-hidden"
122
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
123
+ >
124
+ <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">
125
+ <div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
126
+ {title && (
127
+ <h2
128
+ id={`${uid}-title`}
129
+ className="text-[16px] font-semibold text-ink tracking-[-0.015em] leading-snug"
130
+ >
131
+ {title}
132
+ </h2>
133
+ )}
134
+ {description && (
135
+ <p id={`${uid}-desc`} className="text-[13px] text-ink-muted leading-relaxed">
136
+ {description}
137
+ </p>
138
+ )}
139
+ </div>
140
+ <CloseButton label="Close drawer" onClick={onClose} />
141
+ </div>
142
+
143
+ {children && (
144
+ <div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-ink-body leading-relaxed">
145
+ {children}
146
+ </div>
147
+ )}
148
+
149
+ {actions && (
150
+ <div
151
+ className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"
152
+ style={{
153
+ background: "var(--panel-tray)",
154
+ boxShadow: "inset 0 1px 0 var(--hairline)",
155
+ }}
156
+ >
157
+ {actions}
158
+ </div>
159
+ )}
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </Portal>
165
+ );
166
+ }
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";
4
+ import { Kbd } from "./Kbd";
5
+ import { ENTER_FROM, LAYER, Portal, useAnchoredPosition } from "./Floating";
6
+
7
+ type DropdownMenuPosition =
8
+ | "bottom-start"
9
+ | "bottom-end"
10
+ | "top-start"
11
+ | "top-end";
12
+
13
+ export interface DropdownMenuItem {
14
+ key: string;
15
+ label: string;
16
+ icon?: ReactNode;
17
+ shortcut?: string;
18
+ danger?: boolean;
19
+ disabled?: boolean;
20
+ divider?: boolean;
21
+ }
22
+
23
+ interface DropdownMenuProps {
24
+ trigger: ReactNode;
25
+ items: DropdownMenuItem[];
26
+ position?: DropdownMenuPosition;
27
+ onSelect?: (key: string) => void;
28
+ className?: string;
29
+ }
30
+
31
+ export function DropdownMenu({
32
+ trigger,
33
+ items,
34
+ position = "bottom-end",
35
+ onSelect,
36
+ className = "",
37
+ }: DropdownMenuProps) {
38
+ const [open, setOpen] = useState(false);
39
+ const [visible, setVisible] = useState(false);
40
+ const [mounted, setMounted] = useState(false);
41
+ const ref = useRef<HTMLDivElement>(null);
42
+ const menuRef = useRef<HTMLDivElement | null>(null);
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
+ );
54
+
55
+ const enabledItems = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
56
+
57
+ const focusItem = (index: number) => {
58
+ const list = enabledItems();
59
+ if (list.length === 0) return;
60
+ list[(index + list.length) % list.length].focus();
61
+ };
62
+
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);
66
+
67
+ useEffect(() => {
68
+ if (open) {
69
+ requestAnimationFrame(() =>
70
+ requestAnimationFrame(() => {
71
+ setVisible(true);
72
+ // Land on the first item so arrow keys work straight away
73
+ enabledItems()[0]?.focus();
74
+ })
75
+ );
76
+ } else {
77
+ const t = setTimeout(() => setMounted(false), 180);
78
+ return () => clearTimeout(t);
79
+ }
80
+ }, [open]);
81
+
82
+ useEffect(() => {
83
+ function onOutside(e: MouseEvent) {
84
+ const t = e.target as Node;
85
+ if (ref.current?.contains(t) || menuRef.current?.contains(t)) return;
86
+ setOpen(false);
87
+ }
88
+ document.addEventListener("mousedown", onOutside);
89
+ return () => document.removeEventListener("mousedown", onOutside);
90
+ }, []);
91
+
92
+ const returnFocus = () => {
93
+ ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();
94
+ };
95
+
96
+ const onMenuKeyDown = (e: React.KeyboardEvent) => {
97
+ const list = enabledItems();
98
+ const at = list.indexOf(document.activeElement as HTMLElement);
99
+ switch (e.key) {
100
+ case "ArrowDown":
101
+ e.preventDefault();
102
+ focusItem(at + 1);
103
+ break;
104
+ case "ArrowUp":
105
+ e.preventDefault();
106
+ focusItem(at - 1);
107
+ break;
108
+ case "Home":
109
+ e.preventDefault();
110
+ focusItem(0);
111
+ break;
112
+ case "End":
113
+ e.preventDefault();
114
+ focusItem(list.length - 1);
115
+ break;
116
+ case "Escape":
117
+ e.preventDefault();
118
+ setOpen(false);
119
+ returnFocus();
120
+ break;
121
+ case "Tab":
122
+ setOpen(false);
123
+ break;
124
+ }
125
+ };
126
+
127
+ function handleSelect(item: DropdownMenuItem) {
128
+ if (item.disabled) return;
129
+ onSelect?.(item.key);
130
+ setOpen(false);
131
+ returnFocus();
132
+ }
133
+
134
+ return (
135
+ <div ref={ref} className={["relative inline-flex", className].join(" ")}>
136
+ <div
137
+ data-menu-trigger
138
+ onClick={() => {
139
+ setOpen(!open);
140
+ }}
141
+ className="inline-flex cursor-pointer"
142
+ >
143
+ {React.isValidElement(trigger)
144
+ ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
145
+ "aria-haspopup": "menu",
146
+ "aria-expanded": open,
147
+ "aria-controls": open ? `${uid}-menu` : undefined,
148
+ })
149
+ : trigger}
150
+ </div>
151
+ {mounted && (
152
+ <Portal>
153
+ <div
154
+ ref={attachMenu}
155
+ id={`${uid}-menu`}
156
+ role="menu"
157
+ onKeyDown={onMenuKeyDown}
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
+ }}
170
+ >
171
+ <div
172
+ className="bg-panel-face rounded-[11px] p-1 flex flex-col"
173
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
174
+ >
175
+ {items.map((item) => {
176
+ if (item.divider) {
177
+ return <div key={item.key} role="separator" className="h-px bg-line my-1 mx-2" />;
178
+ }
179
+
180
+ return (
181
+ <button
182
+ key={item.key}
183
+ type="button"
184
+ role="menuitem"
185
+ tabIndex={-1}
186
+ onClick={() => handleSelect(item)}
187
+ onMouseEnter={(e) => e.currentTarget.focus()}
188
+ disabled={item.disabled}
189
+ className={[
190
+ "group/item w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[8px] text-[13px] font-medium cursor-pointer outline-none",
191
+ "transition-[background,color] duration-100",
192
+ "disabled:opacity-35 disabled:pointer-events-none",
193
+ item.danger
194
+ ? "text-danger focus:bg-danger-wash"
195
+ : "text-ink-label focus:text-ink focus:bg-surface-active/80",
196
+ ].join(" ")}
197
+ >
198
+ {item.icon && (
199
+ <span
200
+ className={[
201
+ "shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",
202
+ item.danger ? "text-danger-soft" : "text-ink-faint group-focus/item:text-ink-label",
203
+ ].join(" ")}
204
+ >
205
+ {item.icon}
206
+ </span>
207
+ )}
208
+ <span className="flex-1 text-left">{item.label}</span>
209
+ {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
210
+ </button>
211
+ );
212
+ })}
213
+ </div>
214
+ </div>
215
+ </Portal>
216
+ )}
217
+ </div>
218
+ );
219
+ }
@@ -0,0 +1,55 @@
1
+ import React from "react";
2
+
3
+ interface EmptyStateProps {
4
+ icon?: React.ReactNode;
5
+ title: string;
6
+ description?: string;
7
+ action?: React.ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ export function EmptyState({
12
+ icon,
13
+ title,
14
+ description,
15
+ action,
16
+ className = "",
17
+ }: EmptyStateProps) {
18
+ return (
19
+ <div
20
+ className={["rounded-[20px] p-1.5", className].filter(Boolean).join(" ")}
21
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
22
+ >
23
+ <div
24
+ className="relative bg-panel-face rounded-[15px] overflow-hidden flex flex-col items-center justify-center text-center py-14 px-6"
25
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
26
+ >
27
+ {/* A faint dotted floor: the space is empty, not broken */}
28
+ <div
29
+ aria-hidden
30
+ className="pointer-events-none absolute inset-0"
31
+ style={{
32
+ backgroundImage: "radial-gradient(var(--dots) 1px, transparent 1px)",
33
+ backgroundSize: "14px 14px",
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
+ }}
37
+ />
38
+ {icon && (
39
+ <div
40
+ aria-hidden
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
+ >
44
+ {icon}
45
+ </div>
46
+ )}
47
+ <p className="relative text-[15px] font-semibold text-ink tracking-[-0.015em]">{title}</p>
48
+ {description && (
49
+ <p className="relative text-[13px] text-ink-muted mt-1.5 max-w-xs leading-relaxed">{description}</p>
50
+ )}
51
+ {action && <div className="relative mt-6">{action}</div>}
52
+ </div>
53
+ </div>
54
+ );
55
+ }