@horon-ai/react 0.0.0-stage → 0.1.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 (102) hide show
  1. package/README.md +168 -2
  2. package/package.json +50 -5
  3. package/src/assets/sidebar-photo.jpg +0 -0
  4. package/src/components/agent-activity.tsx +204 -0
  5. package/src/components/agent-lane.tsx +80 -0
  6. package/src/components/agent-lanes-timeline.tsx +312 -0
  7. package/src/components/agent-panel.tsx +125 -0
  8. package/src/components/agent-steering-panel.tsx +276 -0
  9. package/src/components/approval-card.tsx +89 -0
  10. package/src/components/avatar.tsx +104 -0
  11. package/src/components/badge.tsx +63 -0
  12. package/src/components/banner.tsx +103 -0
  13. package/src/components/bot-instructions-panel.tsx +369 -0
  14. package/src/components/bot-list-row.tsx +42 -0
  15. package/src/components/bot-memory-panel.tsx +229 -0
  16. package/src/components/bottom-sheet.tsx +140 -0
  17. package/src/components/breadcrumb-header.tsx +87 -0
  18. package/src/components/breadcrumb.tsx +86 -0
  19. package/src/components/button.tsx +146 -0
  20. package/src/components/calendar.tsx +410 -0
  21. package/src/components/card.tsx +79 -0
  22. package/src/components/chat-bubble.tsx +36 -0
  23. package/src/components/chat-controls.tsx +118 -0
  24. package/src/components/checkbox.tsx +73 -0
  25. package/src/components/chip.tsx +81 -0
  26. package/src/components/choice-card.tsx +55 -0
  27. package/src/components/code-panel.tsx +27 -0
  28. package/src/components/combobox.tsx +312 -0
  29. package/src/components/command-palette.tsx +206 -0
  30. package/src/components/composer.tsx +124 -0
  31. package/src/components/data-table.tsx +429 -0
  32. package/src/components/date-field.tsx +100 -0
  33. package/src/components/dialog.tsx +93 -0
  34. package/src/components/directory-card.tsx +59 -0
  35. package/src/components/document-extract-panel.tsx +398 -0
  36. package/src/components/empty-state.tsx +29 -0
  37. package/src/components/field.tsx +78 -0
  38. package/src/components/file-drop.tsx +89 -0
  39. package/src/components/filter-bar.tsx +83 -0
  40. package/src/components/flue-background.tsx +388 -0
  41. package/src/components/horon-icon.tsx +280 -0
  42. package/src/components/horon-wordmark.tsx +26 -0
  43. package/src/components/icon-button.tsx +61 -0
  44. package/src/components/input-group.tsx +112 -0
  45. package/src/components/input.tsx +72 -0
  46. package/src/components/integration-row.tsx +103 -0
  47. package/src/components/kbd.tsx +42 -0
  48. package/src/components/knowledge-base-panel.tsx +185 -0
  49. package/src/components/liquid-glass.tsx +24 -0
  50. package/src/components/list-detail.tsx +275 -0
  51. package/src/components/marketplace-card.tsx +97 -0
  52. package/src/components/mcp-server-row.tsx +122 -0
  53. package/src/components/menu.tsx +165 -0
  54. package/src/components/message-extras.tsx +146 -0
  55. package/src/components/modal.tsx +108 -0
  56. package/src/components/orchestrator-panel.tsx +175 -0
  57. package/src/components/page-header.tsx +48 -0
  58. package/src/components/plan-card.tsx +70 -0
  59. package/src/components/plan-step.tsx +111 -0
  60. package/src/components/plugin-row.tsx +128 -0
  61. package/src/components/progress.tsx +21 -0
  62. package/src/components/radio-group.tsx +84 -0
  63. package/src/components/research-run-panel.tsx +232 -0
  64. package/src/components/schedule.tsx +332 -0
  65. package/src/components/segmented-control.tsx +136 -0
  66. package/src/components/select.tsx +154 -0
  67. package/src/components/settings-list.tsx +99 -0
  68. package/src/components/sidebar.tsx +542 -0
  69. package/src/components/skeleton.tsx +75 -0
  70. package/src/components/slider.tsx +29 -0
  71. package/src/components/source-row.tsx +112 -0
  72. package/src/components/spinner.tsx +29 -0
  73. package/src/components/split-button.tsx +72 -0
  74. package/src/components/switch.tsx +79 -0
  75. package/src/components/tab-bar.tsx +90 -0
  76. package/src/components/tabs.tsx +162 -0
  77. package/src/components/task-row.tsx +159 -0
  78. package/src/components/textarea-field.tsx +127 -0
  79. package/src/components/textarea.tsx +47 -0
  80. package/src/components/toast.tsx +128 -0
  81. package/src/components/tool-call.tsx +215 -0
  82. package/src/components/tool-calls-header.tsx +65 -0
  83. package/src/components/tool-permissions.tsx +105 -0
  84. package/src/components/tooltip.tsx +33 -0
  85. package/src/components/top-bar.tsx +65 -0
  86. package/src/components/usage-meter.tsx +47 -0
  87. package/src/components/workspace-menu.tsx +77 -0
  88. package/src/hooks/index.ts +8 -0
  89. package/src/hooks/use-active-indicator.ts +60 -0
  90. package/src/hooks/use-color-scheme.ts +27 -0
  91. package/src/hooks/use-copy-to-clipboard.ts +32 -0
  92. package/src/hooks/use-device.ts +41 -0
  93. package/src/hooks/use-hotkey.ts +35 -0
  94. package/src/hooks/use-media-query.ts +25 -0
  95. package/src/hooks/use-platform.ts +39 -0
  96. package/src/hooks/use-reduced-motion.ts +8 -0
  97. package/src/index.ts +99 -0
  98. package/src/lib/cn.ts +25 -0
  99. package/src/lib/cursor-ghost.ts +244 -0
  100. package/src/lib/platform.ts +169 -0
  101. package/src/lib/size.ts +10 -0
  102. package/src/styles/globals.css +564 -0
@@ -0,0 +1,332 @@
1
+ import { Select as BaseSelect } from "@base-ui/react/select";
2
+ import { Toggle } from "@base-ui/react/toggle";
3
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
4
+ import { CalendarBlank, CaretDown, Check, Clock, Repeat } from "@phosphor-icons/react";
5
+ import type { ReactNode } from "react";
6
+
7
+ import { cn } from "../lib/cn";
8
+ import type { ControlSize } from "../lib/size";
9
+ import { LiquidGlass } from "./liquid-glass";
10
+
11
+ export type PickerFieldSize = ControlSize;
12
+
13
+ export type PickerFieldProps = {
14
+ items: { value: string; label: ReactNode }[];
15
+ value?: string;
16
+ defaultValue?: string;
17
+ onValueChange?: (value: string | null) => void;
18
+ /** Leading 16px icon; the Time field uses a clock. */
19
+ icon?: ReactNode;
20
+ /**
21
+ * `solid` (default) is the opaque surface field with a default border, `glass` the veil field,
22
+ * `inset` the 4% ink field inside the schedule card.
23
+ */
24
+ surface?: "solid" | "glass" | "inset";
25
+ /** Field size: `sm` 32px, `md` 40px (default), `lg` 48px; scales width, icon and menu rows. */
26
+ size?: PickerFieldSize;
27
+ "aria-label"?: string;
28
+ className?: string;
29
+ };
30
+
31
+ /** The Figma widths (144/160/176px) are the maximum: the field shrinks with a narrow card instead of pushing past it. */
32
+ const triggerSizeClass: Record<PickerFieldSize, string> = {
33
+ sm: "h-8 w-36 max-w-full gap-1.5 px-3",
34
+ md: "h-10 w-40 max-w-full gap-2 px-3.5",
35
+ lg: "h-12 w-44 max-w-full gap-2.5 px-5",
36
+ };
37
+
38
+ const triggerIconSizeClass: Record<PickerFieldSize, string> = {
39
+ sm: "[&>svg]:size-4",
40
+ md: "[&>svg]:size-4",
41
+ lg: "[&>svg]:size-5",
42
+ };
43
+
44
+ /** The same text rungs as `Input` and `Select` (14/16/18), so a picker and a field line up in a form row. */
45
+ const valueSizeClass: Record<PickerFieldSize, string> = {
46
+ sm: "text-body-sm",
47
+ md: "text-body-md",
48
+ lg: "text-body-lg",
49
+ };
50
+
51
+ const caretSizeClass: Record<PickerFieldSize, string> = {
52
+ sm: "size-4",
53
+ md: "size-4",
54
+ lg: "size-5",
55
+ };
56
+
57
+ const popupSizeClass: Record<PickerFieldSize, string> = {
58
+ sm: "p-1",
59
+ md: "p-1.5",
60
+ lg: "p-2",
61
+ };
62
+
63
+ /** The same popup rows as `Select`, so the two pickers share one list style. */
64
+ const itemSizeClass: Record<PickerFieldSize, string> = {
65
+ sm: "h-8 gap-2 px-2.5 text-label-sm",
66
+ md: "h-10 gap-2.5 px-3 text-label-md",
67
+ lg: "h-12 gap-3 px-4 text-label-lg",
68
+ };
69
+
70
+ const itemIndicatorSizeClass: Record<PickerFieldSize, string> = {
71
+ sm: "[&_svg]:size-4",
72
+ md: "[&_svg]:size-4",
73
+ lg: "[&_svg]:size-5",
74
+ };
75
+
76
+ /** Figma `Time field` (Routine schedule 110:765): a 160px picker on the same 32/40/48px and 14/16/18 text rungs as `Input`. */
77
+ export function PickerField({
78
+ items,
79
+ icon,
80
+ surface = "solid",
81
+ size = "md",
82
+ className,
83
+ "aria-label": ariaLabel,
84
+ onValueChange,
85
+ ...props
86
+ }: PickerFieldProps) {
87
+ return (
88
+ <BaseSelect.Root items={items} onValueChange={(next) => onValueChange?.(next)} {...props}>
89
+ <BaseSelect.Trigger
90
+ aria-label={ariaLabel}
91
+ className={cn(
92
+ "relative flex max-w-full min-w-0 cursor-pointer items-center rounded-md text-left motion-colors",
93
+ "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-text-brand",
94
+ triggerSizeClass[size],
95
+ surface === "glass"
96
+ ? "group veil-field"
97
+ : surface === "inset"
98
+ ? "group bg-overlay-ink-4"
99
+ : "group border border-border-default bg-bg-surface",
100
+ className,
101
+ )}
102
+ >
103
+ <span className={cn("relative flex shrink-0 text-text-secondary", triggerIconSizeClass[size])}>
104
+ {icon ?? <Clock />}
105
+ </span>
106
+ <BaseSelect.Value className={cn("relative min-w-0 flex-1 truncate text-text-primary", valueSizeClass[size])} />
107
+ <CaretDown
108
+ aria-hidden
109
+ className={cn(
110
+ "relative shrink-0 text-text-secondary transition-transform duration-200 ease-out-quint group-data-popup-open:rotate-180",
111
+ caretSizeClass[size],
112
+ )}
113
+ />
114
+ </BaseSelect.Trigger>
115
+ <BaseSelect.Portal>
116
+ <BaseSelect.Positioner sideOffset={8} alignItemWithTrigger={false} className="z-50 outline-none">
117
+ <BaseSelect.Popup
118
+ className={cn(
119
+ "relative max-h-72 max-w-[calc(100vw-2rem)] min-w-(--anchor-width) motion-popup overflow-y-auto rounded-xl outline-none",
120
+ surface === "solid" ? "border border-border-default bg-bg-surface shadow-popover" : "veil-overlay",
121
+ popupSizeClass[size],
122
+ )}
123
+ >
124
+ <BaseSelect.List className="relative flex flex-col gap-0.5">
125
+ {items.map((item) => (
126
+ <BaseSelect.Item
127
+ key={item.value}
128
+ value={item.value}
129
+ className={cn(
130
+ "flex cursor-pointer items-center rounded-md text-text-primary motion-colors outline-none select-none data-highlighted:bg-overlay-ink-6 data-selected:bg-overlay-ink-6",
131
+ itemSizeClass[size],
132
+ )}
133
+ >
134
+ <BaseSelect.ItemText className="min-w-0 flex-1">{item.label}</BaseSelect.ItemText>
135
+ <BaseSelect.ItemIndicator className={cn("flex shrink-0", itemIndicatorSizeClass[size])}>
136
+ <Check className="animate-pop-in" />
137
+ </BaseSelect.ItemIndicator>
138
+ </BaseSelect.Item>
139
+ ))}
140
+ </BaseSelect.List>
141
+ </BaseSelect.Popup>
142
+ </BaseSelect.Positioner>
143
+ </BaseSelect.Portal>
144
+ </BaseSelect.Root>
145
+ );
146
+ }
147
+
148
+ export type WeekdayPickerSize = ControlSize;
149
+
150
+ export type WeekdayPickerProps = {
151
+ /** One entry per day, in display order. */
152
+ days: { value: string; label: string; name: string }[];
153
+ value?: string[];
154
+ defaultValue?: string[];
155
+ onValueChange?: (value: string[]) => void;
156
+ /** Day circle size: `sm` 32px, `md` 36px (default), `lg` 44px. */
157
+ size?: WeekdayPickerSize;
158
+ "aria-label"?: string;
159
+ className?: string;
160
+ };
161
+
162
+ const weekdayGroupSizeClass: Record<WeekdayPickerSize, string> = {
163
+ sm: "gap-1",
164
+ md: "gap-1.5",
165
+ lg: "gap-2",
166
+ };
167
+
168
+ /** The Figma day circle (32/36/44px) never shrinks; the group wraps instead, so the targets keep their size. */
169
+ const weekdayToggleSizeClass: Record<WeekdayPickerSize, string> = {
170
+ sm: "size-8 text-label-sm",
171
+ md: "size-9 text-label-md",
172
+ lg: "size-11 text-label-lg",
173
+ };
174
+
175
+ /** Figma `Weekday picker` (110:765): 36px round toggles, ink when on and 6% ink when off. */
176
+ export function WeekdayPicker({
177
+ days,
178
+ value,
179
+ defaultValue,
180
+ onValueChange,
181
+ size = "md",
182
+ className,
183
+ ...props
184
+ }: WeekdayPickerProps) {
185
+ return (
186
+ <ToggleGroup
187
+ multiple
188
+ value={value}
189
+ defaultValue={defaultValue}
190
+ onValueChange={(next) => onValueChange?.(next)}
191
+ aria-label={props["aria-label"] ?? "Days"}
192
+ className={cn("flex flex-wrap items-center", weekdayGroupSizeClass[size], className)}
193
+ >
194
+ {days.map((day) => (
195
+ <Toggle
196
+ key={day.value}
197
+ value={day.value}
198
+ aria-label={day.name}
199
+ className={cn(
200
+ "flex shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-overlay-ink-6 text-text-secondary",
201
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
202
+ "data-pressed:bg-interactive-primary data-pressed:text-interactive-on-primary",
203
+ weekdayToggleSizeClass[size],
204
+ )}
205
+ >
206
+ {day.label}
207
+ </Toggle>
208
+ ))}
209
+ </ToggleGroup>
210
+ );
211
+ }
212
+
213
+ export type ScheduleCardProps = {
214
+ title: ReactNode;
215
+ /** The "Next run" line at the bottom. */
216
+ nextRun?: ReactNode;
217
+ children: ReactNode;
218
+ /** Card size: `sm` 340px, `md` 380px (default), `lg` 420px; scales padding, gaps and text. */
219
+ size?: ControlSize;
220
+ /** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass card. */
221
+ surface?: "solid" | "glass";
222
+ className?: string;
223
+ };
224
+
225
+ /** The Figma card widths (340/380/420px) are the maximum: on a narrow screen the card folds to the column width. */
226
+ const cardSizeClass: Record<ControlSize, string> = {
227
+ sm: "w-full max-w-85 gap-3 p-4",
228
+ md: "w-full max-w-95 gap-4 p-5",
229
+ lg: "w-full max-w-105 gap-4 p-5",
230
+ };
231
+
232
+ const cardHeaderSizeClass: Record<ControlSize, string> = {
233
+ sm: "gap-2",
234
+ md: "gap-2.5",
235
+ lg: "gap-3",
236
+ };
237
+
238
+ const cardIconSizeClass: Record<ControlSize, string> = {
239
+ sm: "size-4",
240
+ md: "size-4",
241
+ lg: "size-5",
242
+ };
243
+
244
+ const cardTitleSizeClass: Record<ControlSize, string> = {
245
+ sm: "text-label-md",
246
+ md: "text-label-lg",
247
+ lg: "text-label-lg",
248
+ };
249
+
250
+ const nextRunSizeClass: Record<ControlSize, string> = {
251
+ sm: "gap-1.5 px-2.5 py-2 text-label-md",
252
+ md: "gap-2 px-3 py-2 text-label-md",
253
+ lg: "gap-2.5 px-3.5 py-3 text-label-lg",
254
+ };
255
+
256
+ const nextRunIconSizeClass: Record<ControlSize, string> = {
257
+ sm: "size-3.5",
258
+ md: "size-4",
259
+ lg: "size-5",
260
+ };
261
+
262
+ /** Figma `Routine schedule card` (110:765): a 380px card, 20px padding, 16px gaps, solid or thick glass. */
263
+ export function ScheduleCard({
264
+ title,
265
+ nextRun,
266
+ children,
267
+ size = "md",
268
+ surface = "solid",
269
+ className,
270
+ }: ScheduleCardProps) {
271
+ return (
272
+ <section
273
+ className={cn(
274
+ "relative flex flex-col items-stretch overflow-clip rounded-xl",
275
+ surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
276
+ cardSizeClass[size],
277
+ className,
278
+ )}
279
+ >
280
+ {surface === "glass" && <LiquidGlass />}
281
+ <div className={cn("relative flex items-center", cardHeaderSizeClass[size])}>
282
+ <Repeat aria-hidden className={cn("shrink-0 text-text-primary", cardIconSizeClass[size])} />
283
+ <h3 className={cn("min-w-0 flex-1 text-text-primary", cardTitleSizeClass[size])}>{title}</h3>
284
+ </div>
285
+ {children}
286
+ {nextRun && (
287
+ <div
288
+ className={cn(
289
+ "relative flex items-center rounded-md bg-accent-sky-500-16 text-text-primary",
290
+ nextRunSizeClass[size],
291
+ )}
292
+ >
293
+ <CalendarBlank aria-hidden className={cn("shrink-0", nextRunIconSizeClass[size])} />
294
+ {nextRun}
295
+ </div>
296
+ )}
297
+ </section>
298
+ );
299
+ }
300
+
301
+ /** A captioned block inside the schedule card, such as "Repeat" or "Days"; the caption-to-control gap is `Field`'s. */
302
+ const sectionSizeClass: Record<ControlSize, string> = {
303
+ sm: "gap-1",
304
+ md: "gap-2",
305
+ lg: "gap-2",
306
+ };
307
+
308
+ const sectionLabelSizeClass: Record<ControlSize, string> = {
309
+ sm: "text-caption",
310
+ md: "text-caption",
311
+ lg: "text-label-sm",
312
+ };
313
+
314
+ export function ScheduleSection({
315
+ label,
316
+ children,
317
+ /** Matches the size of the controls inside the section. */
318
+ size = "md",
319
+ className,
320
+ }: {
321
+ label: ReactNode;
322
+ children: ReactNode;
323
+ size?: ControlSize;
324
+ className?: string;
325
+ }) {
326
+ return (
327
+ <div className={cn("relative flex min-w-0 flex-col", sectionSizeClass[size], className)}>
328
+ <span className={cn("text-text-secondary", sectionLabelSizeClass[size])}>{label}</span>
329
+ {children}
330
+ </div>
331
+ );
332
+ }
@@ -0,0 +1,136 @@
1
+ import { Toggle } from "@base-ui/react/toggle";
2
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
3
+ import { type ReactNode, useRef } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import type { ControlSize } from "../lib/size";
7
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
8
+ import { LiquidGlass } from "./liquid-glass";
9
+
10
+ /** `tab` keeps Paper's page-tab rung beside the three control sizes. */
11
+ export type SegmentedControlSize = ControlSize | "tab";
12
+ export type SegmentedControlSurface = "solid" | "glass" | "inset" | "inset-faint" | "subtle";
13
+
14
+ export type SegmentedControlProps = {
15
+ items: { value: string; label: ReactNode; disabled?: boolean }[];
16
+ value?: string;
17
+ defaultValue?: string;
18
+ onValueChange?: (value: string) => void;
19
+ /**
20
+ * `md`: Segmented control (62:379), 36px track, 30px segments, 16px padding.
21
+ * `sm`: one rung down, a 32px track with 26px segments and 10px padding.
22
+ * `lg`: Segmented filter (88:723), 44px track, 36px segments.
23
+ * `tab`: Paper page tabs (Projects Active/Archived), 4px track padding and gap, 32px segments with 14px padding.
24
+ */
25
+ size?: SegmentedControlSize;
26
+ /**
27
+ * `solid` (default) is the opaque surface track with a default border and a 10% ink selection.
28
+ * `glass` is regular glass. `inset` is the 5% ink track inside the routine card,
29
+ * `inset-faint` the 4% ink track inside the header filter. `subtle` is the bg-subtle page-tab tray whose
30
+ * selection is a plain white pill.
31
+ */
32
+ surface?: SegmentedControlSurface;
33
+ /** Segments share the width equally instead of hugging their labels. */
34
+ fill?: boolean;
35
+ "aria-label"?: string;
36
+ className?: string;
37
+ };
38
+
39
+ const trackClass: Record<SegmentedControlSize, string> = {
40
+ sm: "p-[3px]",
41
+ md: "h-9 p-[3px]",
42
+ lg: "h-11 p-1",
43
+ tab: "gap-1 p-1",
44
+ };
45
+
46
+ const segmentClass: Record<SegmentedControlSize, string> = {
47
+ sm: "h-6.5 px-2.5 text-label-sm",
48
+ md: "h-7.5 px-4 text-label-md",
49
+ lg: "h-9 px-4 text-label-lg",
50
+ tab: "h-8 px-3.5 text-label-md",
51
+ };
52
+
53
+ const surfaceClass: Record<SegmentedControlSurface, string> = {
54
+ solid: "border border-border-default bg-bg-surface",
55
+ glass: "overflow-clip bg-glass-regular",
56
+ inset: "bg-overlay-ink-5",
57
+ "inset-faint": "bg-overlay-ink-4",
58
+ subtle: "bg-bg-subtle",
59
+ };
60
+
61
+ /**
62
+ * Single-choice segmented control. The selection is one pill that slides to the
63
+ * pressed segment. On the glass track it is a glass pill over the glass track,
64
+ * thick fill and a 0 1 3 8% shadow, the iOS tab bar selection; dark mode keeps a
65
+ * 10% fill. On the solid and inset tracks it is 10% ink. The pill is the same on
66
+ * every rung, as in `Tabs`.
67
+ */
68
+ export function SegmentedControl({
69
+ items,
70
+ value,
71
+ defaultValue,
72
+ onValueChange,
73
+ size = "md",
74
+ surface = "solid",
75
+ fill = size === "md",
76
+ className,
77
+ ...props
78
+ }: SegmentedControlProps) {
79
+ const track = useRef<HTMLDivElement>(null);
80
+ const { rect, animate } = useActiveIndicator(track, "[data-pressed]");
81
+ return (
82
+ <ToggleGroup
83
+ ref={track}
84
+ value={value === undefined ? undefined : [value]}
85
+ defaultValue={defaultValue === undefined ? undefined : [defaultValue]}
86
+ onValueChange={(next) => {
87
+ if (next[0] !== undefined) onValueChange?.(next[0]);
88
+ }}
89
+ className={cn(
90
+ // Segments keep the padding their rung gives them; when the set is wider
91
+ // than the container the track scrolls instead of spilling the pills.
92
+ "relative flex max-w-full min-w-0 items-center gap-0.5 rounded-full",
93
+ trackClass[size],
94
+ surfaceClass[surface],
95
+ "overflow-x-auto overflow-y-clip",
96
+ className,
97
+ )}
98
+ aria-label={props["aria-label"]}
99
+ >
100
+ {surface === "glass" && <LiquidGlass />}
101
+ <span
102
+ aria-hidden
103
+ className={cn(
104
+ "pointer-events-none absolute overflow-clip rounded-full",
105
+ surface === "glass"
106
+ ? "bg-glass-thick shadow-[0_1px_3px_rgba(0,0,0,0.08)] dark:bg-overlay-ink-10"
107
+ : surface === "subtle"
108
+ ? "bg-bg-default dark:bg-overlay-ink-10"
109
+ : "bg-overlay-ink-10",
110
+ animate && "motion-indicator",
111
+ )}
112
+ style={rect ? { ...rect, opacity: 1 } : { opacity: 0 }}
113
+ >
114
+ {surface === "glass" && <LiquidGlass />}
115
+ </span>
116
+ {items.map((item) => (
117
+ <Toggle
118
+ key={item.value}
119
+ value={item.value}
120
+ disabled={item.disabled}
121
+ className={cn(
122
+ "relative flex min-w-0 cursor-pointer items-center justify-center rounded-full whitespace-nowrap text-text-secondary motion-colors",
123
+ "hover:text-text-primary data-disabled:cursor-not-allowed data-disabled:opacity-40 data-pressed:text-text-primary",
124
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
125
+ segmentClass[size],
126
+ // Filled segments share the track; hugging segments keep the padding their
127
+ // rung gives them and let the track scroll when the set is wider than the row.
128
+ fill ? "flex-1" : "shrink-0",
129
+ )}
130
+ >
131
+ <span className="text-trim">{item.label}</span>
132
+ </Toggle>
133
+ ))}
134
+ </ToggleGroup>
135
+ );
136
+ }
@@ -0,0 +1,154 @@
1
+ import { Select as BaseSelect } from "@base-ui/react/select";
2
+ import { CaretDown, Check } from "@phosphor-icons/react";
3
+ import type { ComponentProps, ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import type { ControlSize } from "../lib/size";
7
+ import { fieldBoxClass, fieldSizeClass, fieldTextClass, type InputSurface } from "./input";
8
+ import { inputGroupSegmentClass, useInputGroup } from "./input-group";
9
+
10
+ export type SelectSize = ControlSize;
11
+ export type SelectItemData = {
12
+ value: string;
13
+ label: ReactNode;
14
+ /** Shorter text for the trigger once chosen, such as `+82` for "+82 South Korea". */
15
+ selectedLabel?: ReactNode;
16
+ disabled?: boolean;
17
+ };
18
+
19
+ export type SelectProps = Omit<ComponentProps<typeof BaseSelect.Root>, "items" | "children"> & {
20
+ items: SelectItemData[];
21
+ placeholder?: ReactNode;
22
+ size?: SelectSize;
23
+ surface?: InputSurface;
24
+ invalid?: boolean;
25
+ /**
26
+ * `chip` is the Paper filter chip: a 32px bg-subtle pill, 12px padding, 13/18 medium, sized to its label.
27
+ * `segment` is the tinted prefix or suffix of an `InputGroup`; it takes the group's size and disabled state.
28
+ */
29
+ variant?: "field" | "chip" | "segment";
30
+ showIndicator?: boolean;
31
+ className?: string;
32
+ "aria-label"?: string;
33
+ };
34
+
35
+ const itemClass: Record<SelectSize, string> = {
36
+ sm: "h-8 gap-2 px-2.5 text-label-sm",
37
+ md: "h-10 gap-2.5 px-3 text-label-md",
38
+ lg: "h-12 gap-3 px-4 text-label-lg",
39
+ };
40
+
41
+ const itemIconClass: Record<SelectSize, string> = {
42
+ sm: "[&_svg]:size-4",
43
+ md: "[&_svg]:size-4",
44
+ lg: "[&_svg]:size-5",
45
+ };
46
+
47
+ /** The field box rings on `:focus-within` (`fieldBoxClass.base`); a chip or segment is the focused element itself, so it carries its own `focus-visible` ring. */
48
+ const triggerFocusClass = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand";
49
+
50
+ /**
51
+ * Figma `Select` (58:162) trigger with the `Select list` (88:495) popup: a field with the
52
+ * same text rung as `Input` (14/16/18) and a 16/20px caret; the surface never changes the
53
+ * text size. The popup is the veil (`veil-overlay`), 20px radius, 40px rows; the chosen row
54
+ * is 6% ink with a check. `variant="chip"` keeps its single 32px chip size;
55
+ * `variant="segment"` follows its `InputGroup`.
56
+ */
57
+ export function Select({
58
+ items,
59
+ placeholder = "Select",
60
+ size: ownSize = "md",
61
+ surface = "solid",
62
+ invalid,
63
+ variant = "field",
64
+ showIndicator = true,
65
+ disabled,
66
+ className,
67
+ "aria-label": ariaLabel,
68
+ ...props
69
+ }: SelectProps) {
70
+ const group = useInputGroup();
71
+ const segment = variant === "segment";
72
+ const field = variant === "field";
73
+ const size = segment && group ? group.size : ownSize;
74
+ const popupSurface = segment && group ? group.surface : surface;
75
+ return (
76
+ <BaseSelect.Root items={items} disabled={disabled ?? (segment ? group?.disabled : undefined)} {...props}>
77
+ <BaseSelect.Trigger
78
+ aria-label={ariaLabel}
79
+ aria-invalid={invalid || undefined}
80
+ data-invalid={invalid ? "" : undefined}
81
+ className={cn(
82
+ field
83
+ ? cn(
84
+ fieldBoxClass.base,
85
+ fieldBoxClass[surface],
86
+ fieldSizeClass[size],
87
+ "w-60 max-w-full",
88
+ surface !== "glass" && "hover:not-data-disabled:not-data-invalid:border-border-strong",
89
+ )
90
+ : segment
91
+ ? cn(inputGroupSegmentClass[size], "text-left data-disabled:text-text-disabled", triggerFocusClass)
92
+ : cn(
93
+ "relative inline-flex h-8 items-center gap-1 rounded-full bg-bg-subtle px-3 text-label-md hover:not-data-disabled:bg-overlay-ink-8",
94
+ triggerFocusClass,
95
+ ),
96
+ "group cursor-pointer text-left motion-colors data-disabled:cursor-not-allowed",
97
+ className,
98
+ )}
99
+ >
100
+ <BaseSelect.Value
101
+ className={cn(
102
+ "relative min-w-0 truncate data-placeholder:text-text-placeholder",
103
+ segment ? "" : "flex-1 text-text-primary group-data-disabled:text-text-disabled",
104
+ field ? fieldTextClass[size] : "",
105
+ )}
106
+ >
107
+ {(value: string | null) => {
108
+ const item = items.find((entry) => entry.value === value);
109
+ return item ? (item.selectedLabel ?? item.label) : placeholder;
110
+ }}
111
+ </BaseSelect.Value>
112
+ {showIndicator ? (
113
+ <BaseSelect.Icon
114
+ className={cn(
115
+ "relative flex shrink-0 text-text-secondary transition-transform duration-200 ease-out-quint group-data-popup-open:rotate-180",
116
+ variant === "chip" && "[&_svg]:size-4",
117
+ )}
118
+ >
119
+ <CaretDown />
120
+ </BaseSelect.Icon>
121
+ ) : null}
122
+ </BaseSelect.Trigger>
123
+ <BaseSelect.Portal>
124
+ <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className="z-50 outline-none">
125
+ <BaseSelect.Popup
126
+ className={cn(
127
+ "relative max-w-[calc(100vw-2rem)] min-w-(--anchor-width) motion-popup overflow-clip rounded-xl p-1.5 outline-none",
128
+ popupSurface === "glass" ? "veil-overlay" : "border border-border-default bg-bg-surface shadow-popover",
129
+ )}
130
+ >
131
+ <BaseSelect.List className="relative flex flex-col gap-0.5">
132
+ {items.map((item) => (
133
+ <BaseSelect.Item
134
+ key={item.value}
135
+ value={item.value}
136
+ disabled={item.disabled}
137
+ className={cn(
138
+ "flex cursor-pointer items-center rounded-md text-text-primary motion-colors outline-none select-none data-disabled:cursor-not-allowed data-disabled:opacity-40 data-highlighted:bg-overlay-ink-6 data-selected:bg-overlay-ink-6",
139
+ itemClass[size],
140
+ )}
141
+ >
142
+ <BaseSelect.ItemText className="min-w-0 flex-1">{item.label}</BaseSelect.ItemText>
143
+ <BaseSelect.ItemIndicator className={cn("flex shrink-0 text-text-primary", itemIconClass[size])}>
144
+ <Check />
145
+ </BaseSelect.ItemIndicator>
146
+ </BaseSelect.Item>
147
+ ))}
148
+ </BaseSelect.List>
149
+ </BaseSelect.Popup>
150
+ </BaseSelect.Positioner>
151
+ </BaseSelect.Portal>
152
+ </BaseSelect.Root>
153
+ );
154
+ }