@horon-ai/react 0.0.0-stage → 0.1.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 (93) 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 +202 -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 +121 -0
  8. package/src/components/agent-steering-panel.tsx +274 -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 +365 -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 +191 -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 +402 -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 +35 -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 +156 -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 +224 -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/index.ts +86 -0
  89. package/src/lib/cn.ts +25 -0
  90. package/src/lib/cursor-ghost.ts +244 -0
  91. package/src/lib/size.ts +10 -0
  92. package/src/lib/use-active-indicator.ts +60 -0
  93. package/src/styles/globals.css +564 -0
@@ -0,0 +1,73 @@
1
+ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
2
+ import { Check, Minus } 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
+
8
+ export type CheckboxSize = ControlSize;
9
+
10
+ export type CheckboxProps = Omit<ComponentProps<typeof BaseCheckbox.Root>, "className"> & {
11
+ label?: ReactNode;
12
+ /**
13
+ * `md` is the 20px Figma Checkbox, `sm` the 18px box of the Data table (192:9046),
14
+ * `lg` the 24px box.
15
+ */
16
+ size?: CheckboxSize;
17
+ className?: string;
18
+ };
19
+
20
+ const labelClass: Record<CheckboxSize, string> = {
21
+ sm: "gap-2 text-body-sm",
22
+ md: "gap-2.5 text-body-md",
23
+ lg: "gap-3 text-body-lg",
24
+ };
25
+
26
+ /** One 6px corner (`rounded-xs`, the Figma radius) at every size; only the box grows with the rung. */
27
+ const boxClass: Record<CheckboxSize, string> = {
28
+ sm: "size-4.5 rounded-xs",
29
+ md: "size-5 rounded-xs",
30
+ lg: "size-6 rounded-xs",
31
+ };
32
+
33
+ const indicatorClass: Record<CheckboxSize, string> = {
34
+ sm: "[&_svg]:size-3",
35
+ md: "[&_svg]:size-3.5",
36
+ lg: "[&_svg]:size-4",
37
+ };
38
+
39
+ /** Figma `Checkbox` (58:217): a 20px box, 6px radius, with a 10px gap to its label. */
40
+ export function Checkbox({ label, size = "md", className, ...props }: CheckboxProps) {
41
+ return (
42
+ <label
43
+ className={cn(
44
+ "group inline-flex max-w-full min-w-0 cursor-pointer items-center text-text-primary has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-40",
45
+ labelClass[size],
46
+ className,
47
+ )}
48
+ >
49
+ <BaseCheckbox.Root
50
+ className={cn(
51
+ "flex shrink-0 items-center justify-center border-[1.5px] border-border-strong bg-bg-surface motion-colors",
52
+ boxClass[size],
53
+ "hover:not-data-disabled:border-text-secondary",
54
+ "data-checked:border-interactive-primary data-checked:bg-interactive-primary data-indeterminate:border-interactive-primary data-indeterminate:bg-interactive-primary",
55
+ "hover:not-data-disabled:data-checked:border-interactive-primary-hover hover:not-data-disabled:data-checked:bg-interactive-primary-hover hover:not-data-disabled:data-indeterminate:border-interactive-primary-hover hover:not-data-disabled:data-indeterminate:bg-interactive-primary-hover",
56
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
57
+ )}
58
+ {...props}
59
+ >
60
+ <BaseCheckbox.Indicator
61
+ className={cn(
62
+ "flex animate-pop-in text-interactive-on-primary data-ending-style:animate-scale-out",
63
+ indicatorClass[size],
64
+ )}
65
+ render={(indicatorProps, state) => (
66
+ <span {...indicatorProps}>{state.indeterminate ? <Minus /> : <Check />}</span>
67
+ )}
68
+ />
69
+ </BaseCheckbox.Root>
70
+ {label}
71
+ </label>
72
+ );
73
+ }
@@ -0,0 +1,81 @@
1
+ import { Toggle } from "@base-ui/react/toggle";
2
+ import { Check } from "@phosphor-icons/react";
3
+ import { Children, type ComponentProps, useState } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import type { ControlSize } from "../lib/size";
7
+
8
+ export type ChipSize = ControlSize;
9
+
10
+ export type ChipProps = Omit<ComponentProps<typeof Toggle>, "className"> & {
11
+ /** `md` is the 32px Figma Chip; `sm` is 28px and `lg` 36px. */
12
+ size?: ChipSize;
13
+ className?: string;
14
+ };
15
+
16
+ const sizeClass: Record<ChipSize, string> = {
17
+ sm: "h-7 px-2.5 text-label-sm",
18
+ md: "h-8 px-3 text-label-md",
19
+ lg: "h-9 px-3.5 text-label-lg",
20
+ };
21
+
22
+ /** The slot is 0px wide until pressed, then opens to icon + the label gap (6 / 8 / 10px). */
23
+ const checkSlotClass: Record<ChipSize, string> = {
24
+ sm: "group-data-pressed:w-[1.125rem]",
25
+ md: "group-data-pressed:w-[1.375rem]",
26
+ lg: "group-data-pressed:w-[1.625rem]",
27
+ };
28
+
29
+ const checkIconClass: Record<ChipSize, string> = {
30
+ sm: "size-3",
31
+ md: "size-3.5",
32
+ lg: "size-4",
33
+ };
34
+
35
+ /** Figma `Chip` (61:302): a 32px solid toggle, no glass; selected shows a check, the brand border and brand text. */
36
+ export function Chip({ size = "md", className, children, onPressedChange, ...props }: ChipProps) {
37
+ // The exit animation only plays after the first toggle, so a chip that mounts unpressed never flashes the check.
38
+ const [toggled, setToggled] = useState(false);
39
+ // Preflight makes every `svg` a block box, so an icon passed as a child would stack above the
40
+ // label and clip. Element children get a row at the label gap; text keeps the plain span.
41
+ const hasElementChild = Children.toArray(children).some(
42
+ (child) => typeof child !== "string" && typeof child !== "number",
43
+ );
44
+ return (
45
+ <Toggle
46
+ className={cn(
47
+ "group relative inline-flex shrink-0 motion-press cursor-pointer items-center justify-center rounded-full border whitespace-nowrap",
48
+ sizeClass[size],
49
+ "border-border-default bg-bg-surface text-text-primary hover:not-data-disabled:bg-bg-subtle",
50
+ "data-pressed:border-border-brand data-pressed:text-text-brand",
51
+ "data-disabled:cursor-not-allowed data-disabled:opacity-40",
52
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
53
+ className,
54
+ )}
55
+ onPressedChange={(pressed, eventDetails) => {
56
+ setToggled(true);
57
+ onPressedChange?.(pressed, eventDetails);
58
+ }}
59
+ {...props}
60
+ >
61
+ <span
62
+ aria-hidden
63
+ className={cn(
64
+ "relative flex w-0 shrink-0 overflow-hidden transition-[width] duration-150 ease-out",
65
+ checkSlotClass[size],
66
+ )}
67
+ >
68
+ <Check
69
+ className={cn(
70
+ "opacity-0 group-data-pressed:opacity-100",
71
+ toggled && "animate-pop-out group-data-pressed:animate-pop-in",
72
+ checkIconClass[size],
73
+ )}
74
+ />
75
+ </span>
76
+ <span className={cn("relative text-trim", hasElementChild && "inline-flex items-center gap-1.5")}>
77
+ {children}
78
+ </span>
79
+ </Toggle>
80
+ );
81
+ }
@@ -0,0 +1,55 @@
1
+ import { Radio as BaseRadio } from "@base-ui/react/radio";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ export type ChoiceCardProps = Omit<ComponentProps<typeof BaseRadio.Root>, "className" | "children" | "title"> & {
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ /** Pill at the end of the title line, e.g. "Recommended": 22px, 8px padding, 18% sky with brand text (Paper Mobile connection). */
9
+ badge?: ReactNode;
10
+ /** `brand` (default) is 18% sky with brand text; `neutral` is 6% ink with secondary text. */
11
+ badgeTone?: "brand" | "neutral";
12
+ className?: string;
13
+ };
14
+
15
+ /**
16
+ * Paper `Choice card` (1VXM-0 selected, 1VXR-0 default): a 252px solid card, padding 14, radius 16, gap 12,
17
+ * with a 1px default border that turns brand when selected; no glass.
18
+ * Use inside `RadioGroup`. The 18px radio is a ring: 1.5px grey when off, a 5px ink ring around an 8px core when on.
19
+ */
20
+ export function ChoiceCard({ title, description, badge, badgeTone = "brand", className, ...props }: ChoiceCardProps) {
21
+ return (
22
+ <BaseRadio.Root
23
+ className={cn(
24
+ "group relative flex w-full max-w-[252px] cursor-pointer items-start gap-3 overflow-clip rounded-2xl border border-border-default bg-bg-surface p-3.5 text-left motion-colors data-checked:border-border-brand data-unchecked:hover:border-border-strong",
25
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
26
+ "data-disabled:cursor-not-allowed data-disabled:opacity-40",
27
+ className,
28
+ )}
29
+ {...props}
30
+ >
31
+ <span
32
+ aria-hidden
33
+ className="relative mt-px flex size-[18px] shrink-0 items-center justify-center rounded-full border-[1.5px] border-border-dashed motion-colors group-data-checked:border-interactive-primary group-data-checked:shadow-[inset_0_0_0_3.5px_var(--color-interactive-primary)]"
34
+ >
35
+ <BaseRadio.Indicator className="size-2 animate-pop-in rounded-full bg-interactive-on-primary data-unchecked:hidden" />
36
+ </span>
37
+ <span className="relative flex min-w-0 flex-1 flex-col gap-1">
38
+ <span className="flex items-center gap-2.5">
39
+ <span className="min-w-0 flex-1 text-label-md font-semibold text-text-primary">{title}</span>
40
+ {badge && (
41
+ <span
42
+ className={cn(
43
+ "inline-flex h-[22px] shrink-0 items-center rounded-full px-2 text-label-sm",
44
+ badgeTone === "brand" ? "bg-accent-sky-500-18 text-text-brand" : "bg-overlay-ink-6 text-text-secondary",
45
+ )}
46
+ >
47
+ {badge}
48
+ </span>
49
+ )}
50
+ </span>
51
+ {description && <span className="text-caption text-text-secondary">{description}</span>}
52
+ </span>
53
+ </BaseRadio.Root>
54
+ );
55
+ }
@@ -0,0 +1,27 @@
1
+ import type { ComponentProps } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type CodePanelProps = Omit<ComponentProps<"pre">, "className"> & { className?: string };
6
+
7
+ /**
8
+ * Paper `Code block` (1VV9-0), the light variant: a 10px-radius neutral-50 panel with a 1px hairline,
9
+ * Geist Mono 12px on a 21px line pitch (Paper rows are 19px with a 2px gap), no header bar or copy button. Wrap keys in `CodeKey`; plain text reads as the string color.
10
+ * Chat surfaces keep the ink `CodeBlock` from message-extras.
11
+ */
12
+ export function CodePanel({ className, ...props }: CodePanelProps) {
13
+ return (
14
+ <pre
15
+ className={cn(
16
+ "font-normal m-0 max-w-full overflow-x-auto rounded-[10px] border border-border-default bg-neutral-50 px-4 py-3.5 font-mono text-[12px]/[21px] whitespace-pre text-syntax-string dark:border-border-strong dark:bg-neutral-800",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ /** A frontmatter key or other highlighted token inside a `CodePanel`. */
25
+ export function CodeKey({ className, ...props }: Omit<ComponentProps<"span">, "className"> & { className?: string }) {
26
+ return <span className={cn("text-syntax-key", className)} {...props} />;
27
+ }
@@ -0,0 +1,312 @@
1
+ import { Combobox as BaseCombobox } from "@base-ui/react/combobox";
2
+ import { CaretDown, Check, MagnifyingGlass, X } from "@phosphor-icons/react";
3
+ import { useRef, type 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 ComboboxSize = ControlSize;
11
+
12
+ export type ComboboxItemData = {
13
+ value: string;
14
+ /** Row text and the text the search matches. */
15
+ label: string;
16
+ /** Shorter text shown once chosen, such as `+82` for "+82 South Korea". */
17
+ selectedLabel?: string;
18
+ /** Secondary text on the right of the row, such as a dial code. */
19
+ trailing?: ReactNode;
20
+ /** Extra search terms that are not shown, such as `KR` or `Korea`. */
21
+ keywords?: readonly string[];
22
+ disabled?: boolean;
23
+ };
24
+
25
+ type ComboboxBaseProps = {
26
+ items: readonly ComboboxItemData[];
27
+ placeholder?: string;
28
+ /** Placeholder of the search box inside the popup (`segment` variant). */
29
+ searchPlaceholder?: string;
30
+ /** Shown in the popup when nothing matches the search. */
31
+ emptyLabel?: ReactNode;
32
+ size?: ComboboxSize;
33
+ surface?: InputSurface;
34
+ /**
35
+ * `field` is a searchable field (the default). `segment` is the tinted prefix or suffix of an
36
+ * `InputGroup`: a button that opens a popup with the search box on top, taking the group's size.
37
+ */
38
+ variant?: "field" | "segment";
39
+ invalid?: boolean;
40
+ disabled?: boolean;
41
+ /** Form field name; the submitted value is the item `value`. */
42
+ name?: string;
43
+ className?: string;
44
+ "aria-label"?: string;
45
+ };
46
+
47
+ export type ComboboxSingleProps = ComboboxBaseProps & {
48
+ multiple?: false;
49
+ value?: string | null;
50
+ defaultValue?: string | null;
51
+ onValueChange?: (value: string | null) => void;
52
+ };
53
+
54
+ export type ComboboxMultipleProps = ComboboxBaseProps & {
55
+ /** Chosen items show as removable chips in the field. */
56
+ multiple: true;
57
+ value?: readonly string[];
58
+ defaultValue?: readonly string[];
59
+ onValueChange?: (value: string[]) => void;
60
+ };
61
+
62
+ export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;
63
+
64
+ const itemClass: Record<ComboboxSize, string> = {
65
+ sm: "h-8 gap-2 px-2.5 text-label-sm [&_svg]:size-4",
66
+ md: "h-10 gap-2.5 px-3 text-label-md [&_svg]:size-4",
67
+ lg: "h-12 gap-3 px-4 text-label-lg [&_svg]:size-5",
68
+ };
69
+
70
+ const chipClass: Record<ComboboxSize, string> = {
71
+ sm: "h-5 gap-0.5 pr-0.5 pl-1.5 text-label-sm [&_svg]:size-3",
72
+ md: "h-6 gap-1 pr-1 pl-2 text-label-sm [&_svg]:size-3.5",
73
+ lg: "h-7 gap-1 pr-1 pl-2.5 text-label-md [&_svg]:size-4",
74
+ };
75
+
76
+ /** Multiple chips wrap, so the field grows from its rung height instead of fixing it. */
77
+ const multiFieldClass: Record<ComboboxSize, string> = {
78
+ sm: "min-h-8 flex-wrap gap-1 px-2 py-1",
79
+ md: "min-h-10 flex-wrap gap-1.5 px-2.5 py-1.5",
80
+ lg: "min-h-12 flex-wrap gap-2 px-3 py-2",
81
+ };
82
+
83
+ /** The popup follows the field's surface (a segment takes its group's): glass is the veil, every solid kind is solid. */
84
+ const popupClass =
85
+ "relative max-h-80 min-w-(--anchor-width) max-w-[calc(100vw-2rem)] motion-popup overflow-clip rounded-xl p-1.5 outline-none";
86
+ const popupSurfaceClass = (surface: InputSurface) =>
87
+ surface === "glass" ? "veil-overlay" : "border border-border-default bg-bg-surface shadow-popover";
88
+
89
+ const findItem = (items: readonly ComboboxItemData[], value: string | null | undefined) =>
90
+ value == null ? null : (items.find((item) => item.value === value) ?? null);
91
+
92
+ const shortLabel = (item: ComboboxItemData) => item.selectedLabel ?? item.label;
93
+
94
+ /**
95
+ * Search and choose: a field you type into to narrow the list, then pick one item or, with
96
+ * `multiple`, several items shown as chips. The popup matches `Select`: the veil, rows with
97
+ * a check. `variant="segment"` puts it in an `InputGroup`, such as a country code before a
98
+ * phone number.
99
+ */
100
+ export function Combobox(props: ComboboxProps) {
101
+ const {
102
+ items,
103
+ placeholder = "Search",
104
+ searchPlaceholder = "Search",
105
+ emptyLabel = "No results",
106
+ size: ownSize = "md",
107
+ surface = "solid",
108
+ variant = "field",
109
+ invalid,
110
+ disabled,
111
+ name,
112
+ className,
113
+ "aria-label": ariaLabel,
114
+ } = props;
115
+ const group = useInputGroup();
116
+ const segment = variant === "segment";
117
+ const size = segment && group ? group.size : ownSize;
118
+ const popupSurface = segment && group ? group.surface : surface;
119
+ const anchor = useRef<HTMLDivElement>(null);
120
+
121
+ const list = (
122
+ <BaseCombobox.Portal>
123
+ <BaseCombobox.Positioner
124
+ anchor={segment ? undefined : anchor}
125
+ sideOffset={4}
126
+ align="start"
127
+ className="z-50 outline-none"
128
+ >
129
+ <BaseCombobox.Popup
130
+ className={cn(
131
+ popupClass,
132
+ popupSurfaceClass(popupSurface),
133
+ segment && "flex w-64 max-w-[calc(100vw-2rem)] flex-col gap-1",
134
+ )}
135
+ >
136
+ {segment && (
137
+ <div className={cn(fieldBoxClass.base, fieldBoxClass.solid, fieldSizeClass[size], "shrink-0")}>
138
+ <MagnifyingGlass aria-hidden className="relative shrink-0 text-text-secondary" />
139
+ <BaseCombobox.Input
140
+ placeholder={searchPlaceholder}
141
+ className={cn("relative", fieldBoxClass.text, fieldTextClass[size])}
142
+ />
143
+ </div>
144
+ )}
145
+ <BaseCombobox.Empty
146
+ className={cn(
147
+ "relative px-3 text-text-secondary empty:hidden",
148
+ size === "lg" ? "py-3 text-body-md" : "py-2 text-body-sm",
149
+ )}
150
+ >
151
+ {emptyLabel}
152
+ </BaseCombobox.Empty>
153
+ <BaseCombobox.List className="relative flex max-h-64 flex-col gap-0.5 overflow-y-auto">
154
+ {(item: ComboboxItemData) => (
155
+ <BaseCombobox.Item
156
+ key={item.value}
157
+ value={item}
158
+ disabled={item.disabled}
159
+ className={cn(
160
+ "flex shrink-0 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",
161
+ itemClass[size],
162
+ )}
163
+ >
164
+ <span className="min-w-0 flex-1 truncate">{item.label}</span>
165
+ {item.trailing && <span className="shrink-0 text-text-secondary">{item.trailing}</span>}
166
+ <BaseCombobox.ItemIndicator className="flex shrink-0 text-text-primary">
167
+ <Check />
168
+ </BaseCombobox.ItemIndicator>
169
+ </BaseCombobox.Item>
170
+ )}
171
+ </BaseCombobox.List>
172
+ </BaseCombobox.Popup>
173
+ </BaseCombobox.Positioner>
174
+ </BaseCombobox.Portal>
175
+ );
176
+
177
+ const shared = {
178
+ items,
179
+ name,
180
+ disabled: disabled ?? (segment ? group?.disabled : undefined),
181
+ itemToStringLabel: shortLabel,
182
+ itemToStringValue: (item: ComboboxItemData) => item.value,
183
+ isItemEqualToValue: (item: ComboboxItemData, value: ComboboxItemData) => item.value === value.value,
184
+ filter: (item: ComboboxItemData, query: string) => {
185
+ const needle = query.trim().toLowerCase();
186
+ if (!needle) return true;
187
+ return [item.label, item.value, ...(item.keywords ?? [])].some((text) => text.toLowerCase().includes(needle));
188
+ },
189
+ };
190
+
191
+ const body = (
192
+ <>
193
+ {segment ? (
194
+ <BaseCombobox.Trigger
195
+ aria-label={ariaLabel}
196
+ className={cn(
197
+ inputGroupSegmentClass[size],
198
+ "group cursor-pointer text-left outline-none data-disabled:cursor-not-allowed data-disabled:text-text-disabled",
199
+ className,
200
+ )}
201
+ >
202
+ <BaseCombobox.Value>
203
+ {(value: ComboboxItemData | ComboboxItemData[] | null) => {
204
+ const chosen = Array.isArray(value) ? value : value ? [value] : [];
205
+ return chosen.length ? (
206
+ <span className="truncate">{chosen.map(shortLabel).join(", ")}</span>
207
+ ) : (
208
+ <span className="text-text-placeholder">{placeholder}</span>
209
+ );
210
+ }}
211
+ </BaseCombobox.Value>
212
+ <CaretDown
213
+ aria-hidden
214
+ className="shrink-0 transition-transform duration-200 ease-out-quint group-data-popup-open:rotate-180"
215
+ />
216
+ </BaseCombobox.Trigger>
217
+ ) : props.multiple ? (
218
+ <BaseCombobox.Chips
219
+ ref={anchor}
220
+ data-invalid={invalid ? "" : undefined}
221
+ className={cn(
222
+ fieldBoxClass.base,
223
+ fieldBoxClass[surface],
224
+ multiFieldClass[size],
225
+ "w-60 max-w-full cursor-text data-disabled:cursor-not-allowed",
226
+ className,
227
+ )}
228
+ >
229
+ <BaseCombobox.Value>
230
+ {(value: ComboboxItemData[]) => (
231
+ <>
232
+ {value.map((item) => (
233
+ <BaseCombobox.Chip
234
+ key={item.value}
235
+ className={cn(
236
+ "relative flex shrink-0 animate-pop-in items-center rounded-full bg-overlay-ink-6 text-text-primary outline-none data-highlighted:bg-overlay-ink-12",
237
+ chipClass[size],
238
+ )}
239
+ >
240
+ {shortLabel(item)}
241
+ <BaseCombobox.ChipRemove
242
+ aria-label={`Remove ${item.label}`}
243
+ className="flex cursor-pointer rounded-full p-0.5 text-text-secondary hover:bg-overlay-ink-10 hover:text-text-primary"
244
+ >
245
+ <X />
246
+ </BaseCombobox.ChipRemove>
247
+ </BaseCombobox.Chip>
248
+ ))}
249
+ <BaseCombobox.Input
250
+ aria-label={ariaLabel}
251
+ aria-invalid={invalid || undefined}
252
+ placeholder={value.length ? undefined : placeholder}
253
+ className={cn("relative min-w-16", fieldBoxClass.text, fieldTextClass[size])}
254
+ />
255
+ </>
256
+ )}
257
+ </BaseCombobox.Value>
258
+ </BaseCombobox.Chips>
259
+ ) : (
260
+ <BaseCombobox.InputGroup
261
+ ref={anchor}
262
+ data-invalid={invalid ? "" : undefined}
263
+ className={cn(fieldBoxClass.base, fieldBoxClass[surface], fieldSizeClass[size], "w-60 max-w-full", className)}
264
+ >
265
+ <MagnifyingGlass aria-hidden className="relative shrink-0 text-text-secondary" />
266
+ <BaseCombobox.Input
267
+ aria-label={ariaLabel}
268
+ aria-invalid={invalid || undefined}
269
+ placeholder={placeholder}
270
+ className={cn("relative", fieldBoxClass.text, fieldTextClass[size])}
271
+ />
272
+ <BaseCombobox.Trigger
273
+ aria-label="Show options"
274
+ className="group relative flex shrink-0 cursor-pointer text-text-secondary outline-none"
275
+ >
276
+ <CaretDown
277
+ aria-hidden
278
+ className="transition-transform duration-200 ease-out-quint group-data-popup-open:rotate-180"
279
+ />
280
+ </BaseCombobox.Trigger>
281
+ </BaseCombobox.InputGroup>
282
+ )}
283
+ {list}
284
+ </>
285
+ );
286
+
287
+ if (props.multiple) {
288
+ const toItems = (values: readonly string[] | undefined) =>
289
+ values?.map((value) => findItem(items, value)).filter((item) => item !== null);
290
+ return (
291
+ <BaseCombobox.Root
292
+ {...shared}
293
+ multiple
294
+ value={toItems(props.value)}
295
+ defaultValue={toItems(props.defaultValue)}
296
+ onValueChange={(next) => props.onValueChange?.(next.map((item) => item.value))}
297
+ >
298
+ {body}
299
+ </BaseCombobox.Root>
300
+ );
301
+ }
302
+ return (
303
+ <BaseCombobox.Root
304
+ {...shared}
305
+ value={props.value === undefined ? undefined : findItem(items, props.value)}
306
+ defaultValue={props.defaultValue === undefined ? undefined : findItem(items, props.defaultValue)}
307
+ onValueChange={(next) => props.onValueChange?.(next?.value ?? null)}
308
+ >
309
+ {body}
310
+ </BaseCombobox.Root>
311
+ );
312
+ }