@blurrow/ui 0.1.0 → 0.2.0

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 (53) hide show
  1. package/README.md +28 -0
  2. package/dist/cli.js +1477 -0
  3. package/dist/registry/Accordion.tsx +140 -0
  4. package/dist/registry/Alert.tsx +92 -0
  5. package/dist/registry/AuthorCard.tsx +67 -0
  6. package/dist/registry/Avatar.tsx +116 -0
  7. package/dist/registry/Badge.tsx +58 -0
  8. package/dist/registry/Banner.tsx +89 -0
  9. package/dist/registry/Breadcrumb.tsx +60 -0
  10. package/dist/registry/Button.tsx +372 -0
  11. package/dist/registry/ButtonGroup.tsx +82 -0
  12. package/dist/registry/Card.tsx +241 -0
  13. package/dist/registry/CardShell.tsx +78 -0
  14. package/dist/registry/Carousel.tsx +292 -0
  15. package/dist/registry/Checkbox.tsx +99 -0
  16. package/dist/registry/CloseButton.tsx +68 -0
  17. package/dist/registry/CodeBlock.tsx +202 -0
  18. package/dist/registry/CommandPalette.tsx +349 -0
  19. package/dist/registry/DataList.tsx +49 -0
  20. package/dist/registry/Drawer.tsx +164 -0
  21. package/dist/registry/DropdownMenu.tsx +257 -0
  22. package/dist/registry/EmptyState.tsx +55 -0
  23. package/dist/registry/Input.tsx +97 -0
  24. package/dist/registry/Kbd.tsx +24 -0
  25. package/dist/registry/Modal.tsx +128 -0
  26. package/dist/registry/Pagination.tsx +163 -0
  27. package/dist/registry/Popover.tsx +121 -0
  28. package/dist/registry/Progress.tsx +66 -0
  29. package/dist/registry/QuoteCard.tsx +93 -0
  30. package/dist/registry/Radio.tsx +202 -0
  31. package/dist/registry/ReactionBar.tsx +101 -0
  32. package/dist/registry/ReadingProgress.tsx +51 -0
  33. package/dist/registry/ReadingTime.tsx +35 -0
  34. package/dist/registry/Select.tsx +290 -0
  35. package/dist/registry/ShareButtons.tsx +134 -0
  36. package/dist/registry/Sidebar.tsx +182 -0
  37. package/dist/registry/Skeleton.tsx +83 -0
  38. package/dist/registry/SpotlightCard.tsx +106 -0
  39. package/dist/registry/Stat.tsx +82 -0
  40. package/dist/registry/StatCard.tsx +256 -0
  41. package/dist/registry/Steps.tsx +109 -0
  42. package/dist/registry/Table.tsx +121 -0
  43. package/dist/registry/TableOfContents.tsx +116 -0
  44. package/dist/registry/Tabs.tsx +136 -0
  45. package/dist/registry/TagInput.tsx +191 -0
  46. package/dist/registry/Textarea.tsx +84 -0
  47. package/dist/registry/Timeline.tsx +102 -0
  48. package/dist/registry/Toast.tsx +385 -0
  49. package/dist/registry/Toggle.tsx +99 -0
  50. package/dist/registry/Tooltip.tsx +212 -0
  51. package/dist/registry/styles.css +401 -0
  52. package/dist/registry/useModalBehavior.ts +77 -0
  53. package/package.json +23 -6
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { CloseButton } from "./CloseButton";
5
+ import { useModalBehavior } from "./useModalBehavior";
6
+
7
+ interface DrawerProps {
8
+ open: boolean;
9
+ onClose: () => void;
10
+ title?: string;
11
+ description?: string;
12
+ children?: React.ReactNode;
13
+ actions?: React.ReactNode;
14
+ side?: "left" | "right";
15
+ size?: "sm" | "md" | "lg";
16
+ className?: string;
17
+ }
18
+
19
+ const ENTER_MS = 380;
20
+ const EXIT_MS = 240;
21
+
22
+ const sizeMap = {
23
+ sm: "max-w-xs",
24
+ md: "max-w-md",
25
+ lg: "max-w-lg",
26
+ } as const;
27
+
28
+ export function Drawer({
29
+ open,
30
+ onClose,
31
+ title,
32
+ description,
33
+ children,
34
+ actions,
35
+ side = "right",
36
+ size = "md",
37
+ className = "",
38
+ }: DrawerProps) {
39
+ const [mounted, setMounted] = useState(false);
40
+ const [visible, setVisible] = useState(false);
41
+ const panelRef = useRef<HTMLDivElement>(null);
42
+ const uid = useId();
43
+
44
+ /* ── mount / unmount ────────────────────────────────── */
45
+ 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
+ }
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
+ <div className="fixed inset-0 z-90">
74
+ <div
75
+ aria-hidden
76
+ className="absolute inset-0 backdrop-blur-[6px] transition-opacity ease-out"
77
+ style={{
78
+ background: "var(--scrim)",
79
+ opacity: visible ? 1 : 0,
80
+ transitionDuration: `${visible ? ENTER_MS : EXIT_MS}ms`,
81
+ }}
82
+ onClick={onClose}
83
+ />
84
+
85
+ {/* Floats a few px off the viewport edge so it reads as a panel, not a wall */}
86
+ <div
87
+ className={[
88
+ "absolute inset-y-0 w-full p-2 flex",
89
+ isRight ? "right-0 justify-end" : "left-0 justify-start",
90
+ ].join(" ")}
91
+ style={{ pointerEvents: "none" }}
92
+ >
93
+ <div
94
+ ref={panelRef}
95
+ role="dialog"
96
+ aria-modal="true"
97
+ aria-labelledby={title ? `${uid}-title` : undefined}
98
+ aria-describedby={description ? `${uid}-desc` : undefined}
99
+ className={[
100
+ "relative h-full w-full rounded-[22px] p-1.5 flex flex-col pointer-events-auto",
101
+ sizeMap[size],
102
+ className,
103
+ ]
104
+ .filter(Boolean)
105
+ .join(" ")}
106
+ style={{
107
+ background: "var(--panel-bezel)",
108
+ boxShadow: "var(--modal-shadow)",
109
+ transform: visible
110
+ ? "translateX(0)"
111
+ : isRight
112
+ ? "translateX(calc(100% + 16px))"
113
+ : "translateX(calc(-100% - 16px))",
114
+ transition: `transform ${visible ? ENTER_MS : EXIT_MS}ms ${
115
+ visible ? "var(--ease-out-soft)" : "cubic-bezier(0.4,0,1,1)"
116
+ }`,
117
+ }}
118
+ >
119
+ <div
120
+ className="flex flex-col h-full bg-white rounded-[16px] overflow-hidden"
121
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
122
+ >
123
+ <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4 shrink-0">
124
+ <div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
125
+ {title && (
126
+ <h2
127
+ id={`${uid}-title`}
128
+ className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"
129
+ >
130
+ {title}
131
+ </h2>
132
+ )}
133
+ {description && (
134
+ <p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">
135
+ {description}
136
+ </p>
137
+ )}
138
+ </div>
139
+ <CloseButton label="Close drawer" onClick={onClose} />
140
+ </div>
141
+
142
+ {children && (
143
+ <div className="flex-1 overflow-y-auto scroll-thin px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">
144
+ {children}
145
+ </div>
146
+ )}
147
+
148
+ {actions && (
149
+ <div
150
+ className="mt-auto px-5 py-3.5 flex items-center justify-end gap-2 shrink-0"
151
+ style={{
152
+ background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",
153
+ boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",
154
+ }}
155
+ >
156
+ {actions}
157
+ </div>
158
+ )}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,257 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";
4
+ import { Kbd } from "./Kbd";
5
+
6
+ type DropdownMenuPosition =
7
+ | "bottom-start"
8
+ | "bottom-end"
9
+ | "top-start"
10
+ | "top-end";
11
+
12
+ export interface DropdownMenuItem {
13
+ key: string;
14
+ label: string;
15
+ icon?: ReactNode;
16
+ shortcut?: string;
17
+ danger?: boolean;
18
+ disabled?: boolean;
19
+ divider?: boolean;
20
+ }
21
+
22
+ interface DropdownMenuProps {
23
+ trigger: ReactNode;
24
+ items: DropdownMenuItem[];
25
+ position?: DropdownMenuPosition;
26
+ onSelect?: (key: string) => void;
27
+ className?: string;
28
+ }
29
+
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
+ export function DropdownMenu({
45
+ trigger,
46
+ items,
47
+ position = "bottom-end",
48
+ onSelect,
49
+ className = "",
50
+ }: DropdownMenuProps) {
51
+ const [open, setOpen] = useState(false);
52
+ const [visible, setVisible] = useState(false);
53
+ 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
+ const ref = useRef<HTMLDivElement>(null);
58
+ const menuRef = useRef<HTMLDivElement>(null);
59
+ const uid = useId();
60
+
61
+ const enabledItems = () =>
62
+ Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);
63
+
64
+ const focusItem = (index: number) => {
65
+ const list = enabledItems();
66
+ if (list.length === 0) return;
67
+ list[(index + list.length) % list.length].focus();
68
+ };
69
+
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]);
102
+
103
+ useEffect(() => {
104
+ if (open) {
105
+ setMounted(true);
106
+ requestAnimationFrame(() =>
107
+ requestAnimationFrame(() => {
108
+ setVisible(true);
109
+ // Land on the first item so arrow keys work straight away
110
+ focusItem(0);
111
+ })
112
+ );
113
+ } else {
114
+ setVisible(false);
115
+ const t = setTimeout(() => setMounted(false), 180);
116
+ return () => clearTimeout(t);
117
+ }
118
+ }, [open]);
119
+
120
+ useEffect(() => {
121
+ function onOutside(e: MouseEvent) {
122
+ if (ref.current && !ref.current.contains(e.target as Node)) {
123
+ setOpen(false);
124
+ }
125
+ }
126
+ document.addEventListener("mousedown", onOutside);
127
+ return () => document.removeEventListener("mousedown", onOutside);
128
+ }, []);
129
+
130
+ const returnFocus = () => {
131
+ ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();
132
+ };
133
+
134
+ const onMenuKeyDown = (e: React.KeyboardEvent) => {
135
+ const list = enabledItems();
136
+ const at = list.indexOf(document.activeElement as HTMLElement);
137
+ switch (e.key) {
138
+ case "ArrowDown":
139
+ e.preventDefault();
140
+ focusItem(at + 1);
141
+ break;
142
+ case "ArrowUp":
143
+ e.preventDefault();
144
+ focusItem(at - 1);
145
+ break;
146
+ case "Home":
147
+ e.preventDefault();
148
+ focusItem(0);
149
+ break;
150
+ case "End":
151
+ e.preventDefault();
152
+ focusItem(list.length - 1);
153
+ break;
154
+ case "Escape":
155
+ e.preventDefault();
156
+ setOpen(false);
157
+ returnFocus();
158
+ break;
159
+ case "Tab":
160
+ setOpen(false);
161
+ break;
162
+ }
163
+ };
164
+
165
+ function handleSelect(item: DropdownMenuItem) {
166
+ if (item.disabled) return;
167
+ onSelect?.(item.key);
168
+ setOpen(false);
169
+ returnFocus();
170
+ }
171
+
172
+ return (
173
+ <div ref={ref} className={["relative inline-flex", className].join(" ")}>
174
+ <div
175
+ data-menu-trigger
176
+ onClick={() => {
177
+ if (!open) {
178
+ // Start each opening from the requested side; attachMenu flips if needed
179
+ setPlacement(position);
180
+ setShift(0);
181
+ }
182
+ setOpen(!open);
183
+ }}
184
+ className="inline-flex cursor-pointer"
185
+ >
186
+ {React.isValidElement(trigger)
187
+ ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
188
+ "aria-haspopup": "menu",
189
+ "aria-expanded": open,
190
+ "aria-controls": open ? `${uid}-menu` : undefined,
191
+ })
192
+ : trigger}
193
+ </div>
194
+ {mounted && (
195
+ <div
196
+ ref={attachMenu}
197
+ id={`${uid}-menu`}
198
+ role="menu"
199
+ 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 }}
209
+ >
210
+ <div
211
+ className="bg-white rounded-[11px] p-1 flex flex-col"
212
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
213
+ >
214
+ {items.map((item) => {
215
+ if (item.divider) {
216
+ return <div key={item.key} role="separator" className="h-px bg-neutral-100 my-1 mx-2" />;
217
+ }
218
+
219
+ return (
220
+ <button
221
+ key={item.key}
222
+ type="button"
223
+ role="menuitem"
224
+ tabIndex={-1}
225
+ onClick={() => handleSelect(item)}
226
+ onMouseEnter={(e) => e.currentTarget.focus()}
227
+ disabled={item.disabled}
228
+ className={[
229
+ "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",
230
+ "transition-[background,color] duration-100",
231
+ "disabled:opacity-35 disabled:pointer-events-none",
232
+ item.danger
233
+ ? "text-red-600 focus:bg-red-50"
234
+ : "text-neutral-700 focus:text-neutral-900 focus:bg-neutral-100/80",
235
+ ].join(" ")}
236
+ >
237
+ {item.icon && (
238
+ <span
239
+ className={[
240
+ "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",
242
+ ].join(" ")}
243
+ >
244
+ {item.icon}
245
+ </span>
246
+ )}
247
+ <span className="flex-1 text-left">{item.label}</span>
248
+ {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
249
+ </button>
250
+ );
251
+ })}
252
+ </div>
253
+ </div>
254
+ )}
255
+ </div>
256
+ );
257
+ }
@@ -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-white 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(rgba(17,17,17,0.07) 1px, transparent 1px)",
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%)",
36
+ }}
37
+ />
38
+ {icon && (
39
+ <div
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)" }}
43
+ >
44
+ {icon}
45
+ </div>
46
+ )}
47
+ <p className="relative text-[15px] font-semibold text-neutral-900 tracking-[-0.015em]">{title}</p>
48
+ {description && (
49
+ <p className="relative text-[13px] text-neutral-500 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
+ }
@@ -0,0 +1,97 @@
1
+ import React from "react";
2
+
3
+ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
4
+ label?: string;
5
+ hint?: string;
6
+ error?: string;
7
+ fullWidth?: boolean;
8
+ iconLeft?: React.ReactNode;
9
+ iconRight?: React.ReactNode;
10
+ }
11
+
12
+ /* Fields are sunken wells (see --field tokens in globals.css). Raised
13
+ means "press me", sunken means "fill me" — that is the whole system. */
14
+
15
+ export function Input({
16
+ label,
17
+ hint,
18
+ error,
19
+ fullWidth = false,
20
+ iconLeft,
21
+ iconRight,
22
+ className = "",
23
+ id,
24
+ ...rest
25
+ }: InputProps) {
26
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
27
+ const messageId = inputId && (error || hint) ? `${inputId}-message` : undefined;
28
+
29
+ return (
30
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
31
+ {label && (
32
+ <label
33
+ 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"
35
+ >
36
+ {label}
37
+ </label>
38
+ )}
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
+ }}
73
+ />
74
+ </span>
75
+ {iconRight && (
76
+ <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"
78
+ >
79
+ {iconRight}
80
+ </span>
81
+ )}
82
+ </div>
83
+ {error && (
84
+ <p id={messageId} role="alert" className="text-[11.5px] text-red-600 font-medium flex items-center gap-1 mt-0.5">
85
+ <svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
86
+ <circle cx="12" cy="12" r="10" />
87
+ <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
88
+ </svg>
89
+ {error}
90
+ </p>
91
+ )}
92
+ {hint && !error && (
93
+ <p id={messageId} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,24 @@
1
+ import { ReactNode } from "react";
2
+
3
+ export function Kbd({
4
+ children,
5
+ className = "",
6
+ }: {
7
+ children: ReactNode;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <kbd
12
+ className={`inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-md text-[11px] font-semibold text-neutral-500 ${className}`}
13
+ style={{
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)",
19
+ }}
20
+ >
21
+ {children}
22
+ </kbd>
23
+ );
24
+ }