@horon-ai/react 0.1.0 → 0.1.2
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.
- package/package.json +2 -2
- package/src/components/agent-activity.tsx +81 -11
- package/src/components/agent-lane.tsx +32 -3
- package/src/components/agent-lanes-timeline.tsx +85 -7
- package/src/components/agent-panel.tsx +53 -7
- package/src/components/agent-steering-panel.tsx +73 -21
- package/src/components/approval-card.tsx +34 -2
- package/src/components/avatar.tsx +20 -4
- package/src/components/badge.tsx +24 -3
- package/src/components/banner.tsx +19 -3
- package/src/components/bot-instructions-panel.tsx +92 -9
- package/src/components/bot-list-row.tsx +13 -1
- package/src/components/bot-memory-panel.tsx +58 -3
- package/src/components/bottom-sheet.tsx +61 -7
- package/src/components/breadcrumb-header.tsx +32 -2
- package/src/components/breadcrumb.tsx +31 -1
- package/src/components/button.tsx +46 -1
- package/src/components/calendar.tsx +76 -6
- package/src/components/card.tsx +30 -1
- package/src/components/chat-bubble.tsx +32 -3
- package/src/components/chat-controls.tsx +60 -7
- package/src/components/checkbox.tsx +22 -1
- package/src/components/chip.tsx +29 -2
- package/src/components/choice-card.tsx +25 -1
- package/src/components/code-panel.tsx +28 -3
- package/src/components/combobox.tsx +52 -2
- package/src/components/command-palette.tsx +89 -10
- package/src/components/composer.tsx +28 -1
- package/src/components/data-table.tsx +198 -16
- package/src/components/date-field.tsx +31 -2
- package/src/components/dialog.tsx +45 -3
- package/src/components/directory-card.tsx +23 -2
- package/src/components/document-extract-panel.tsx +83 -5
- package/src/components/empty-state.tsx +21 -2
- package/src/components/field.tsx +49 -3
- package/src/components/file-drop.tsx +39 -4
- package/src/components/filter-bar.tsx +39 -2
- package/src/components/flue-background.tsx +91 -21
- package/src/components/horon-icon.tsx +50 -3
- package/src/components/horon-wordmark.tsx +26 -1
- package/src/components/icon-button.tsx +38 -1
- package/src/components/input-group.tsx +57 -6
- package/src/components/input.tsx +48 -2
- package/src/components/integration-row.tsx +43 -2
- package/src/components/kbd.tsx +37 -4
- package/src/components/knowledge-base-panel.tsx +40 -2
- package/src/components/liquid-glass.tsx +16 -0
- package/src/components/list-detail.tsx +89 -7
- package/src/components/marketplace-card.tsx +40 -4
- package/src/components/mcp-server-row.tsx +49 -3
- package/src/components/menu.tsx +83 -3
- package/src/components/message-extras.tsx +54 -18
- package/src/components/modal.tsx +71 -4
- package/src/components/orchestrator-panel.tsx +48 -4
- package/src/components/page-header.tsx +26 -3
- package/src/components/plan-card.tsx +28 -1
- package/src/components/plan-step.tsx +41 -1
- package/src/components/plugin-row.tsx +61 -3
- package/src/components/progress.tsx +32 -2
- package/src/components/radio-group.tsx +55 -2
- package/src/components/research-run-panel.tsx +73 -3
- package/src/components/schedule.tsx +163 -12
- package/src/components/segmented-control.tsx +58 -3
- package/src/components/select.tsx +60 -0
- package/src/components/settings-list.tsx +66 -8
- package/src/components/sidebar.tsx +214 -23
- package/src/components/skeleton.tsx +61 -4
- package/src/components/slider.tsx +30 -0
- package/src/components/source-row.tsx +43 -4
- package/src/components/spinner.tsx +29 -2
- package/src/components/split-button.tsx +37 -3
- package/src/components/switch.tsx +31 -1
- package/src/components/tab-bar.tsx +48 -3
- package/src/components/tabs.tsx +59 -5
- package/src/components/task-row.tsx +49 -3
- package/src/components/textarea-field.tsx +36 -2
- package/src/components/textarea.tsx +35 -2
- package/src/components/toast.tsx +52 -3
- package/src/components/tool-call.tsx +91 -17
- package/src/components/tool-calls-header.tsx +32 -1
- package/src/components/tool-permissions.tsx +50 -2
- package/src/components/tooltip.tsx +33 -1
- package/src/components/top-bar.tsx +35 -2
- package/src/components/usage-meter.tsx +28 -2
- package/src/components/workspace-menu.tsx +46 -1
- package/src/hooks/index.ts +8 -0
- package/src/{lib → hooks}/use-active-indicator.ts +44 -1
- package/src/hooks/use-color-scheme.ts +50 -0
- package/src/hooks/use-copy-to-clipboard.ts +55 -0
- package/src/hooks/use-device.ts +69 -0
- package/src/hooks/use-hotkey.ts +56 -0
- package/src/hooks/use-media-query.ts +43 -0
- package/src/hooks/use-platform.ts +68 -0
- package/src/hooks/use-reduced-motion.ts +25 -0
- package/src/index.ts +44 -0
- package/src/lib/cn.ts +13 -1
- package/src/lib/cursor-ghost.ts +16 -6
- package/src/lib/platform.ts +236 -0
|
@@ -4,11 +4,16 @@ import type { ComponentProps, ReactNode } from "react";
|
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { LiquidGlass } from "./liquid-glass";
|
|
6
6
|
|
|
7
|
+
/** Material of the chat controls: `solid` on raised surfaces, `glass` over imagery and glass panels. */
|
|
7
8
|
export type ChatControlSurface = "solid" | "glass";
|
|
8
9
|
|
|
9
10
|
type ButtonProps = Omit<ComponentProps<"button">, "className"> & {
|
|
11
|
+
/** Extra classes merged onto the control button. */
|
|
10
12
|
className?: string;
|
|
11
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* `solid` (default) on raised surfaces, `glass` over imagery and glass panels.
|
|
15
|
+
* @default "solid"
|
|
16
|
+
*/
|
|
12
17
|
surface?: ChatControlSurface;
|
|
13
18
|
};
|
|
14
19
|
|
|
@@ -20,7 +25,22 @@ const surfaceClass: Record<ChatControlSurface, string> = {
|
|
|
20
25
|
solid: "border border-border-default bg-bg-surface hover:bg-bg-subtle active:bg-interactive-secondary-hover",
|
|
21
26
|
};
|
|
22
27
|
|
|
23
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Figma `Jump to latest` (Chat controls 88:659): a 44px clear-glass circle with a 20px caret.
|
|
30
|
+
*
|
|
31
|
+
* A square icon button with `aria-label` (default `"Jump to latest"`) that surfaces with the
|
|
32
|
+
* `motion-rise` entrance and forwards `onClick`; the caller's handler performs the scroll to the
|
|
33
|
+
* newest message.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* import { JumpToLatest } from "@horon-ai/react";
|
|
38
|
+
*
|
|
39
|
+
* function Example() {
|
|
40
|
+
* return <JumpToLatest surface="glass" onClick={() => scrollToLatest()} />;
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
24
44
|
export function JumpToLatest({
|
|
25
45
|
surface = "solid",
|
|
26
46
|
className,
|
|
@@ -40,7 +60,12 @@ export function JumpToLatest({
|
|
|
40
60
|
);
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Figma `New messages` (88:659): a 40px clear-glass pill with a 16px caret and a label.
|
|
65
|
+
*
|
|
66
|
+
* A peer of {@link JumpToLatest} for the "there are unread messages above" case; the child is the label
|
|
67
|
+
* and all button attributes are forwarded.
|
|
68
|
+
*/
|
|
44
69
|
export function NewMessagesPill({ surface = "solid", className, children, ...props }: ButtonProps) {
|
|
45
70
|
return (
|
|
46
71
|
<button
|
|
@@ -58,21 +83,49 @@ export function NewMessagesPill({ surface = "solid", className, children, ...pro
|
|
|
58
83
|
/** Bar heights of the Figma waveform, in px. */
|
|
59
84
|
const WAVEFORM = [8, 14, 22, 12, 26, 18, 10, 20, 28, 16, 9, 18, 24, 12, 7, 15, 21, 11];
|
|
60
85
|
|
|
86
|
+
/** Props of {@link VoiceInput}. */
|
|
61
87
|
export type VoiceInputProps = {
|
|
62
88
|
/** Elapsed time, such as "0:07". */
|
|
63
89
|
elapsed: ReactNode;
|
|
64
|
-
/** 0-1 levels for the 18 bars; defaults to the Figma waveform. */
|
|
90
|
+
/** 0-1 levels for the 18 bars; defaults to the Figma waveform. Each level is scaled to 4–28px. */
|
|
65
91
|
levels?: number[];
|
|
66
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Animates the waveform while true; set false once recording stops.
|
|
94
|
+
* @default true
|
|
95
|
+
*/
|
|
67
96
|
recording?: boolean;
|
|
97
|
+
/** Fires when the red stop button is pressed. */
|
|
68
98
|
onStop?: () => void;
|
|
99
|
+
/**
|
|
100
|
+
* Accessible label of the stop button.
|
|
101
|
+
* @default "Stop recording"
|
|
102
|
+
*/
|
|
69
103
|
stopLabel?: string;
|
|
70
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* `solid` (default) on raised surfaces, `glass` over imagery and glass panels.
|
|
106
|
+
* @default "solid"
|
|
107
|
+
*/
|
|
71
108
|
surface?: ChatControlSurface;
|
|
109
|
+
/** Extra classes merged onto the pill. */
|
|
72
110
|
className?: string;
|
|
73
111
|
};
|
|
74
112
|
|
|
75
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Figma `Voice input` (88:659): a 52px pill with a red stop button, waveform and timer.
|
|
115
|
+
*
|
|
116
|
+
* The 18 bars keep the Figma heights unless `levels` is passed, in which case each 0-1 level maps to a
|
|
117
|
+
* 4–28px bar. With `recording` the bars run the `wave` animation on a 120ms stagger; the whole waveform
|
|
118
|
+
* is `aria-hidden`, so only the stop button and the elapsed text reach assistive technology.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```tsx
|
|
122
|
+
* import { VoiceInput } from "@horon-ai/react";
|
|
123
|
+
*
|
|
124
|
+
* function Example() {
|
|
125
|
+
* return <VoiceInput elapsed="0:07" recording onStop={() => stopRecording()} />;
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
76
129
|
export function VoiceInput({
|
|
77
130
|
elapsed,
|
|
78
131
|
levels,
|
|
@@ -5,15 +5,20 @@ import type { ComponentProps, ReactNode } from "react";
|
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import type { ControlSize } from "../lib/size";
|
|
7
7
|
|
|
8
|
+
/** Box size of {@link Checkbox}; identical to {@link ControlSize}. */
|
|
8
9
|
export type CheckboxSize = ControlSize;
|
|
9
10
|
|
|
11
|
+
/** Props of {@link Checkbox}. */
|
|
10
12
|
export type CheckboxProps = Omit<ComponentProps<typeof BaseCheckbox.Root>, "className"> & {
|
|
13
|
+
/** Label rendered after the box inside the same clickable `<label>`. */
|
|
11
14
|
label?: ReactNode;
|
|
12
15
|
/**
|
|
13
16
|
* `md` is the 20px Figma Checkbox, `sm` the 18px box of the Data table (192:9046),
|
|
14
17
|
* `lg` the 24px box.
|
|
18
|
+
* @default "md"
|
|
15
19
|
*/
|
|
16
20
|
size?: CheckboxSize;
|
|
21
|
+
/** Extra classes merged onto the label wrapper. */
|
|
17
22
|
className?: string;
|
|
18
23
|
};
|
|
19
24
|
|
|
@@ -36,7 +41,23 @@ const indicatorClass: Record<CheckboxSize, string> = {
|
|
|
36
41
|
lg: "[&_svg]:size-4",
|
|
37
42
|
};
|
|
38
43
|
|
|
39
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Figma `Checkbox` (58:217): a 20px box, 6px radius, with a 10px gap to its label.
|
|
46
|
+
*
|
|
47
|
+
* A Base UI checkbox: `checked` makes it controlled, otherwise it keeps its own state, and
|
|
48
|
+
* `defaultChecked` / `onCheckedChange` / `indeterminate` work as in Base UI. Checked and indeterminate
|
|
49
|
+
* boxes fill with the brand color and show a check or a dash; the wrapper `<label>` dims the whole control
|
|
50
|
+
* when the input is disabled.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* import { Checkbox } from "@horon-ai/react";
|
|
55
|
+
*
|
|
56
|
+
* function Example() {
|
|
57
|
+
* return <Checkbox label="Remember this chat" defaultChecked />;
|
|
58
|
+
* }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
40
61
|
export function Checkbox({ label, size = "md", className, ...props }: CheckboxProps) {
|
|
41
62
|
return (
|
|
42
63
|
<label
|
package/src/components/chip.tsx
CHANGED
|
@@ -5,11 +5,17 @@ import { Children, type ComponentProps, useState } from "react";
|
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import type { ControlSize } from "../lib/size";
|
|
7
7
|
|
|
8
|
+
/** Height and label rung of {@link Chip}; identical to {@link ControlSize} (`sm` 28px, `md` 32px, `lg` 36px). */
|
|
8
9
|
export type ChipSize = ControlSize;
|
|
9
10
|
|
|
11
|
+
/** Props of {@link Chip}. */
|
|
10
12
|
export type ChipProps = Omit<ComponentProps<typeof Toggle>, "className"> & {
|
|
11
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* `md` is the 32px Figma Chip; `sm` is 28px and `lg` 36px.
|
|
15
|
+
* @default "md"
|
|
16
|
+
*/
|
|
12
17
|
size?: ChipSize;
|
|
18
|
+
/** Extra classes merged onto the toggle button. */
|
|
13
19
|
className?: string;
|
|
14
20
|
};
|
|
15
21
|
|
|
@@ -32,7 +38,28 @@ const checkIconClass: Record<ChipSize, string> = {
|
|
|
32
38
|
lg: "size-4",
|
|
33
39
|
};
|
|
34
40
|
|
|
35
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Figma `Chip` (61:302): a 32px solid toggle, no glass; selected shows a check, the brand border and brand text.
|
|
43
|
+
*
|
|
44
|
+
* A Base UI toggle: `pressed` makes it controlled, otherwise it keeps its own state and reports through
|
|
45
|
+
* `onPressedChange`. The check is in a `0`-width slot that opens on press, so an unselected chip never
|
|
46
|
+
* reserves the icon space; the slot only animates after the first toggle, so a chip that mounts pressed
|
|
47
|
+
* does not flash it. An element child is laid out in a row at the label gap.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* import { Chip } from "@horon-ai/react";
|
|
52
|
+
*
|
|
53
|
+
* function Example() {
|
|
54
|
+
* return (
|
|
55
|
+
* <div className="flex flex-wrap gap-2">
|
|
56
|
+
* <Chip defaultPressed>Documents</Chip>
|
|
57
|
+
* <Chip>Images</Chip>
|
|
58
|
+
* </div>
|
|
59
|
+
* );
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
36
63
|
export function Chip({ size = "md", className, children, onPressedChange, ...props }: ChipProps) {
|
|
37
64
|
// The exit animation only plays after the first toggle, so a chip that mounts unpressed never flashes the check.
|
|
38
65
|
const [toggled, setToggled] = useState(false);
|
|
@@ -2,13 +2,20 @@ import { Radio as BaseRadio } from "@base-ui/react/radio";
|
|
|
2
2
|
import type { ComponentProps, ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
+
/**
|
|
6
|
+
* Props for {@link ChoiceCard}: a radio root plus the card's own title, description and badge slots.
|
|
7
|
+
* `value`, `disabled` and the other radio props pass straight through to the Base UI radio root.
|
|
8
|
+
*/
|
|
5
9
|
export type ChoiceCardProps = Omit<ComponentProps<typeof BaseRadio.Root>, "className" | "children" | "title"> & {
|
|
10
|
+
/** Card heading on the title line; `label-md` semibold primary text, wrapped onto as many lines as the card width allows. */
|
|
6
11
|
title: ReactNode;
|
|
12
|
+
/** Optional second line under the title, rendered as `caption` secondary text. */
|
|
7
13
|
description?: ReactNode;
|
|
8
14
|
/** Pill at the end of the title line, e.g. "Recommended": 22px, 8px padding, 18% sky with brand text (Paper Mobile connection). */
|
|
9
15
|
badge?: ReactNode;
|
|
10
|
-
/** `brand` (default) is 18% sky with brand text; `neutral` is 6% ink with secondary text. */
|
|
16
|
+
/** `brand` (default) is 18% sky with brand text; `neutral` is 6% ink with secondary text. @default "brand" */
|
|
11
17
|
badgeTone?: "brand" | "neutral";
|
|
18
|
+
/** Extra classes on the card root, merged after the layout, border and state classes. */
|
|
12
19
|
className?: string;
|
|
13
20
|
};
|
|
14
21
|
|
|
@@ -16,6 +23,23 @@ export type ChoiceCardProps = Omit<ComponentProps<typeof BaseRadio.Root>, "class
|
|
|
16
23
|
* Paper `Choice card` (1VXM-0 selected, 1VXR-0 default): a 252px solid card, padding 14, radius 16, gap 12,
|
|
17
24
|
* with a 1px default border that turns brand when selected; no glass.
|
|
18
25
|
* Use inside `RadioGroup`. The 18px radio is a ring: 1.5px grey when off, a 5px ink ring around an 8px core when on.
|
|
26
|
+
*
|
|
27
|
+
* The card is a Base UI radio root, so it belongs to a single-select group: arrow keys move the selection,
|
|
28
|
+
* `value` / `defaultValue` on the group pick the card, and `disabled` drops the card to 40% opacity and blocks
|
|
29
|
+
* the click. The radio mark is decorative; the accessible name is the card's own text.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <RadioGroup defaultValue="team">
|
|
34
|
+
* <ChoiceCard value="solo" title="Solo" description="One workspace for yourself." />
|
|
35
|
+
* <ChoiceCard
|
|
36
|
+
* value="team"
|
|
37
|
+
* title="Team"
|
|
38
|
+
* description="Shared bots, memory and billing."
|
|
39
|
+
* badge="Recommended"
|
|
40
|
+
* />
|
|
41
|
+
* </RadioGroup>
|
|
42
|
+
* ```
|
|
19
43
|
*/
|
|
20
44
|
export function ChoiceCard({ title, description, badge, badgeTone = "brand", className, ...props }: ChoiceCardProps) {
|
|
21
45
|
return (
|
|
@@ -2,12 +2,26 @@ import type { ComponentProps } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Props for {@link CodePanel}: the `<pre>` attributes (`children` carries the code) plus a merged `className`. */
|
|
6
|
+
export type CodePanelProps = Omit<ComponentProps<"pre">, "className"> & {
|
|
7
|
+
/** Extra classes on the `<pre>`, merged after the panel surface and type classes. */
|
|
8
|
+
className?: string;
|
|
9
|
+
};
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Paper `Code block` (1VV9-0), the light variant: a 10px-radius neutral-50 panel with a 1px hairline,
|
|
9
13
|
* 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
14
|
* Chat surfaces keep the ink `CodeBlock` from message-extras.
|
|
15
|
+
*
|
|
16
|
+
* The `<pre>` scrolls horizontally instead of wrapping (`whitespace-pre`), and the dark theme swaps the
|
|
17
|
+
* panel to neutral-800 with a stronger border.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <CodePanel>
|
|
22
|
+
* {"---\ntitle: Weekly digest\n---"}
|
|
23
|
+
* </CodePanel>
|
|
24
|
+
* ```
|
|
11
25
|
*/
|
|
12
26
|
export function CodePanel({ className, ...props }: CodePanelProps) {
|
|
13
27
|
return (
|
|
@@ -21,7 +35,18 @@ export function CodePanel({ className, ...props }: CodePanelProps) {
|
|
|
21
35
|
);
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A frontmatter key or other highlighted token inside a `CodePanel`.
|
|
40
|
+
*
|
|
41
|
+
* Part of {@link CodePanel}. Renders an inline `span` in the key colour, so it only changes the colour of
|
|
42
|
+
* the text it wraps; extra classes can override or add weight.
|
|
43
|
+
*/
|
|
44
|
+
export function CodeKey({
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: Omit<ComponentProps<"span">, "className"> & {
|
|
48
|
+
/** Extra classes on the span, merged after the key colour. */
|
|
49
|
+
className?: string;
|
|
50
|
+
}) {
|
|
26
51
|
return <span className={cn("text-syntax-key", className)} {...props} />;
|
|
27
52
|
}
|
|
@@ -7,9 +7,12 @@ import type { ControlSize } from "../lib/size";
|
|
|
7
7
|
import { fieldBoxClass, fieldSizeClass, fieldTextClass, type InputSurface } from "./input";
|
|
8
8
|
import { inputGroupSegmentClass, useInputGroup } from "./input-group";
|
|
9
9
|
|
|
10
|
+
/** Height and text rung, shared with the other controls: `sm` 32px, `md` 40px, `lg` 48px. */
|
|
10
11
|
export type ComboboxSize = ControlSize;
|
|
11
12
|
|
|
13
|
+
/** One option in the list; the `value` is what the field reports and submits. */
|
|
12
14
|
export type ComboboxItemData = {
|
|
15
|
+
/** Stable identity, reported by `value` / `onValueChange` and submitted with the form. */
|
|
13
16
|
value: string;
|
|
14
17
|
/** Row text and the text the search matches. */
|
|
15
18
|
label: string;
|
|
@@ -19,46 +22,71 @@ export type ComboboxItemData = {
|
|
|
19
22
|
trailing?: ReactNode;
|
|
20
23
|
/** Extra search terms that are not shown, such as `KR` or `Korea`. */
|
|
21
24
|
keywords?: readonly string[];
|
|
25
|
+
/** Dims the row to 40% and makes it unselectable. */
|
|
22
26
|
disabled?: boolean;
|
|
23
27
|
};
|
|
24
28
|
|
|
29
|
+
/** Props shared by both value modes; `multiple` selects the mode in {@link ComboboxProps}. */
|
|
25
30
|
type ComboboxBaseProps = {
|
|
31
|
+
/** Options in list order, in the shape of {@link ComboboxItemData}. */
|
|
26
32
|
items: readonly ComboboxItemData[];
|
|
33
|
+
/** Text while nothing is chosen, and the field's `aria-label` in the `field` variant. @default "Search" */
|
|
27
34
|
placeholder?: string;
|
|
28
|
-
/** Placeholder of the search box inside the popup (`segment` variant). */
|
|
35
|
+
/** Placeholder of the search box inside the popup (`segment` variant). @default "Search" */
|
|
29
36
|
searchPlaceholder?: string;
|
|
30
|
-
/** Shown in the popup when nothing matches the search. */
|
|
37
|
+
/** Shown in the popup when nothing matches the search. @default "No results" */
|
|
31
38
|
emptyLabel?: ReactNode;
|
|
39
|
+
/** Rung of the field, rows and chips. @default "md" */
|
|
32
40
|
size?: ComboboxSize;
|
|
41
|
+
/** Surface of the field; the popup follows it. @default "solid" */
|
|
33
42
|
surface?: InputSurface;
|
|
34
43
|
/**
|
|
35
44
|
* `field` is a searchable field (the default). `segment` is the tinted prefix or suffix of an
|
|
36
45
|
* `InputGroup`: a button that opens a popup with the search box on top, taking the group's size.
|
|
46
|
+
* @default "field"
|
|
37
47
|
*/
|
|
38
48
|
variant?: "field" | "segment";
|
|
49
|
+
/** Turns the field or chips border danger red and sets `aria-invalid`; a `segment` trigger gets neither, because the wrapping `InputGroup` owns that border. */
|
|
39
50
|
invalid?: boolean;
|
|
51
|
+
/** Blocks the field; in a `segment` it also inherits `disabled` from the wrapping `InputGroup`. */
|
|
40
52
|
disabled?: boolean;
|
|
41
53
|
/** Form field name; the submitted value is the item `value`. */
|
|
42
54
|
name?: string;
|
|
55
|
+
/** Extra classes on the field or trigger, merged after the surface and size classes. */
|
|
43
56
|
className?: string;
|
|
57
|
+
/** Accessible name of the text field or segment trigger; the placeholder covers it in the `field` variant. */
|
|
44
58
|
"aria-label"?: string;
|
|
45
59
|
};
|
|
46
60
|
|
|
61
|
+
/** Single-select combobox: `value` / `onValueChange` carry one item `value` or `null`. */
|
|
47
62
|
export type ComboboxSingleProps = ComboboxBaseProps & {
|
|
63
|
+
/** Single-select mode; the default when the prop is omitted. */
|
|
48
64
|
multiple?: false;
|
|
65
|
+
/** Controlled choice, as an item `value`; `null` means nothing is chosen. */
|
|
49
66
|
value?: string | null;
|
|
67
|
+
/** Starting choice of the uncontrolled field. */
|
|
50
68
|
defaultValue?: string | null;
|
|
69
|
+
/** Fires with the picked item `value`, or `null` when the field is cleared. */
|
|
51
70
|
onValueChange?: (value: string | null) => void;
|
|
52
71
|
};
|
|
53
72
|
|
|
73
|
+
/** Multi-select combobox: the chosen items show as removable chips and the value is an array of item `value`s. */
|
|
54
74
|
export type ComboboxMultipleProps = ComboboxBaseProps & {
|
|
55
75
|
/** Chosen items show as removable chips in the field. */
|
|
56
76
|
multiple: true;
|
|
77
|
+
/** Controlled selection, in the order chosen. */
|
|
57
78
|
value?: readonly string[];
|
|
79
|
+
/** Starting selection of the uncontrolled field. */
|
|
58
80
|
defaultValue?: readonly string[];
|
|
81
|
+
/** Fires with the full next selection as item `value`s, in the order chosen. */
|
|
59
82
|
onValueChange?: (value: string[]) => void;
|
|
60
83
|
};
|
|
61
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Props for {@link Combobox}, a discriminated union on `multiple`: without it the field is single-select
|
|
87
|
+
* and its value is `string | null`; with `multiple: true` it is multi-select and its value is `readonly string[]`.
|
|
88
|
+
* Narrow on `props.multiple` before reading `value` or `onValueChange`.
|
|
89
|
+
*/
|
|
62
90
|
export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;
|
|
63
91
|
|
|
64
92
|
const itemClass: Record<ComboboxSize, string> = {
|
|
@@ -96,6 +124,28 @@ const shortLabel = (item: ComboboxItemData) => item.selectedLabel ?? item.label;
|
|
|
96
124
|
* `multiple`, several items shown as chips. The popup matches `Select`: the veil, rows with
|
|
97
125
|
* a check. `variant="segment"` puts it in an `InputGroup`, such as a country code before a
|
|
98
126
|
* phone number.
|
|
127
|
+
*
|
|
128
|
+
* Filtering is a case-insensitive substring match over each item's `label`, `value` and `keywords`,
|
|
129
|
+
* with the query trimmed first; a blank query keeps every item. The chosen item's `selectedLabel`
|
|
130
|
+
* (falling back to `label`) is what the field shows, while the form and `onValueChange` carry `value`.
|
|
131
|
+
* The popup portals to the body, opens 4px below the field and is at least as wide as it; the list scrolls
|
|
132
|
+
* past 256px and the panel caps at 320px. In `segment` form the popup is a 256px column with its own search
|
|
133
|
+
* box on top; on `multiple` the field grows and wraps as chips fill it instead of fixing its height.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```tsx
|
|
137
|
+
* const [country, setCountry] = useState<string | null>("+82");
|
|
138
|
+
*
|
|
139
|
+
* <Combobox
|
|
140
|
+
* aria-label="Country code"
|
|
141
|
+
* value={country}
|
|
142
|
+
* onValueChange={setCountry}
|
|
143
|
+
* items={[
|
|
144
|
+
* { value: "+82", label: "+82 South Korea", keywords: ["KR", "Korea"] },
|
|
145
|
+
* { value: "+1", label: "+1 United States", keywords: ["US"] },
|
|
146
|
+
* ]}
|
|
147
|
+
* />
|
|
148
|
+
* ```
|
|
99
149
|
*/
|
|
100
150
|
export function Combobox(props: ComboboxProps) {
|
|
101
151
|
const {
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { Autocomplete } from "@base-ui/react/autocomplete";
|
|
2
2
|
import { Dialog } from "@base-ui/react/dialog";
|
|
3
|
-
import { MagnifyingGlass } from "@phosphor-icons/react";
|
|
3
|
+
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
|
|
6
|
+
import { useDevice } from "../hooks/use-device";
|
|
6
7
|
import { cn } from "../lib/cn";
|
|
7
8
|
import { Kbd } from "./kbd";
|
|
8
9
|
|
|
10
|
+
/** Close control shape for pointers that never reach a key: a 28px icon tile instead of the esc chip. */
|
|
11
|
+
const touchCloseClass =
|
|
12
|
+
"flex size-7 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary hover:bg-overlay-ink-6 hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand [&_svg]:size-4";
|
|
13
|
+
|
|
14
|
+
/** One selectable row of the palette. */
|
|
9
15
|
export type CommandItem = {
|
|
16
|
+
/** Stable identity and the React key of the row; keep it unique across the palette. */
|
|
10
17
|
value: string;
|
|
18
|
+
/** Row text; the query also matches it, together with `keywords`. */
|
|
11
19
|
label: string;
|
|
20
|
+
/** Extra search terms that are not shown, such as an abbreviation or a path. */
|
|
12
21
|
keywords?: string;
|
|
13
22
|
/** 28px leading tile: an initial or a 16px icon. */
|
|
14
23
|
tile?: ReactNode;
|
|
@@ -16,33 +25,78 @@ export type CommandItem = {
|
|
|
16
25
|
tileClassName?: string;
|
|
17
26
|
/** Caption after the label, such as the workspace. */
|
|
18
27
|
meta?: ReactNode;
|
|
19
|
-
/** Right-side hint, usually `<Kbd
|
|
28
|
+
/** Right-side hint, usually `<Kbd>`; hidden when the device shows no keyboard hints. */
|
|
20
29
|
hint?: ReactNode;
|
|
30
|
+
/** Runs when the row is clicked, before the palette closes itself. */
|
|
21
31
|
onSelect?: () => void;
|
|
22
32
|
};
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
/** A titled block of rows; groups are rendered in the order given. */
|
|
35
|
+
export type CommandGroup = {
|
|
36
|
+
/** Section heading, `caption` secondary text above the rows. */
|
|
37
|
+
label: string;
|
|
38
|
+
/** Rows of this section. */
|
|
39
|
+
items: CommandItem[];
|
|
40
|
+
};
|
|
25
41
|
|
|
42
|
+
/** Props for {@link CommandPalette}: the dialog state, the grouped rows and the footer hints. */
|
|
26
43
|
export type CommandPaletteProps = {
|
|
44
|
+
/** Controlled open state; leave it out to let the palette open from its `trigger`. */
|
|
27
45
|
open?: boolean;
|
|
46
|
+
/** Called with the next open state when the palette opens, closes on Escape, closes on select or its backdrop is pressed. */
|
|
28
47
|
onOpenChange?: (open: boolean) => void;
|
|
48
|
+
/** Sections of rows, in render order. */
|
|
29
49
|
groups: CommandGroup[];
|
|
50
|
+
/** Search field placeholder; it also names the dialog (`aria-label`). @default "Search" */
|
|
30
51
|
placeholder?: string;
|
|
31
52
|
/** Shown when the query has no matches; defaults to "No results". */
|
|
32
53
|
empty?: ReactNode;
|
|
33
|
-
/** Footer key hints, such as `[["↑↓", "Move"], ["↵", "Open"]]
|
|
54
|
+
/** Footer key hints, such as `[["↑↓", "Move"], ["↵", "Open"]]`; hidden when the device shows no keyboard hints. */
|
|
34
55
|
hints?: [ReactNode, string][];
|
|
56
|
+
/** Element rendered inside the dialog root as the opener, usually a `DialogTrigger`. */
|
|
35
57
|
trigger?: ReactNode;
|
|
36
58
|
/**
|
|
37
59
|
* `solid` (default) is an opaque palette with a default border. `glass` puts it on the veil (`veil-overlay`).
|
|
60
|
+
* @default "solid"
|
|
38
61
|
*/
|
|
39
62
|
surface?: "solid" | "glass";
|
|
63
|
+
/** Extra classes on the popup, merged after the surface, radius and motion classes. */
|
|
40
64
|
className?: string;
|
|
41
65
|
};
|
|
42
66
|
|
|
43
67
|
/**
|
|
44
68
|
* Figma `Command palette` (110:676): a 560px palette, 24px radius, with a 48px search row,
|
|
45
69
|
* grouped 44px results and a key-hint footer.
|
|
70
|
+
*
|
|
71
|
+
* A Base UI dialog on a 32% scrim with a 6px blur, anchored to the top of the viewport 96px down rather
|
|
72
|
+
* than centred. It always renders {@link CommandPaletteBody} with its own close control — an `esc` chip on
|
|
73
|
+
* devices that show keyboard hints, a 28px `X` tile on touch — and every row select reports
|
|
74
|
+
* `onOpenChange(false)`. Row hints and the footer hints disappear on those touch devices too. The body
|
|
75
|
+
* owns filtering and keyboard movement.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```tsx
|
|
79
|
+
* const [open, setOpen] = useState(false);
|
|
80
|
+
*
|
|
81
|
+
* <CommandPalette
|
|
82
|
+
* open={open}
|
|
83
|
+
* onOpenChange={setOpen}
|
|
84
|
+
* trigger={<DialogTrigger render={<Button variant="solid" />}>Search</DialogTrigger>}
|
|
85
|
+
* groups={[
|
|
86
|
+
* {
|
|
87
|
+
* label: "Bots",
|
|
88
|
+
* items: [
|
|
89
|
+
* { value: "weekly", label: "Weekly digest", meta: "Product", hint: <Kbd>↵</Kbd> },
|
|
90
|
+
* { value: "inbox", label: "Inbox triage", meta: "Ops" },
|
|
91
|
+
* ],
|
|
92
|
+
* },
|
|
93
|
+
* ]}
|
|
94
|
+
* hints={[
|
|
95
|
+
* ["↑↓", "Move"],
|
|
96
|
+
* ["↵", "Open"],
|
|
97
|
+
* ]}
|
|
98
|
+
* />
|
|
99
|
+
* ```
|
|
46
100
|
*/
|
|
47
101
|
export function CommandPalette({
|
|
48
102
|
open,
|
|
@@ -55,6 +109,7 @@ export function CommandPalette({
|
|
|
55
109
|
surface = "solid",
|
|
56
110
|
className,
|
|
57
111
|
}: CommandPaletteProps) {
|
|
112
|
+
const { keyboardHints } = useDevice();
|
|
58
113
|
return (
|
|
59
114
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
60
115
|
{trigger}
|
|
@@ -76,13 +131,16 @@ export function CommandPalette({
|
|
|
76
131
|
placeholder={placeholder}
|
|
77
132
|
empty={empty}
|
|
78
133
|
closeControl={
|
|
79
|
-
<Dialog.Close
|
|
80
|
-
|
|
134
|
+
<Dialog.Close
|
|
135
|
+
aria-label="Close"
|
|
136
|
+
className={keyboardHints ? "motion-press cursor-pointer" : touchCloseClass}
|
|
137
|
+
>
|
|
138
|
+
{keyboardHints ? <Kbd keys="Escape" /> : <X aria-hidden />}
|
|
81
139
|
</Dialog.Close>
|
|
82
140
|
}
|
|
83
141
|
onSelect={() => onOpenChange?.(false)}
|
|
84
142
|
/>
|
|
85
|
-
{hints && hints.length > 0 && (
|
|
143
|
+
{keyboardHints && hints && hints.length > 0 && (
|
|
86
144
|
<div className="relative flex flex-wrap items-center gap-x-4 gap-y-1 px-3 pt-2 pb-1">
|
|
87
145
|
{hints.map(([key, label]) => (
|
|
88
146
|
<span key={label} className="flex items-center gap-1.5 text-caption text-text-secondary">
|
|
@@ -99,17 +157,32 @@ export function CommandPalette({
|
|
|
99
157
|
);
|
|
100
158
|
}
|
|
101
159
|
|
|
160
|
+
/** Props for {@link CommandPaletteBody}: the rows plus the close control and the select callback the host owns. */
|
|
102
161
|
export type CommandPaletteBodyProps = {
|
|
162
|
+
/** Sections of rows, in render order. */
|
|
103
163
|
groups: CommandGroup[];
|
|
164
|
+
/** Search field placeholder. @default "Search" */
|
|
104
165
|
placeholder?: string;
|
|
166
|
+
/** Shown when the query has no matches. @default "No results" */
|
|
105
167
|
empty?: ReactNode;
|
|
168
|
+
/** Accessible name of the fallback `esc` button, used when no `closeControl` is passed. @default "Close" */
|
|
106
169
|
closeLabel?: string;
|
|
107
170
|
/** A ready close control; the dialog passes its own `Dialog.Close` so uncontrolled palettes still close. */
|
|
108
171
|
closeControl?: ReactNode;
|
|
172
|
+
/** Makes the fallback `esc` button close the palette; without both this and `closeControl` no button renders. */
|
|
109
173
|
onClose?: () => void;
|
|
174
|
+
/** Called after a row's own `onSelect`, so the host can close its dialog. */
|
|
110
175
|
onSelect?: () => void;
|
|
111
176
|
};
|
|
112
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The search row, groups and result rows of a command palette.
|
|
180
|
+
*
|
|
181
|
+
* Part of {@link CommandPalette}. Renders a Base UI `Autocomplete` that is always open and inline: the
|
|
182
|
+
* query filters on label plus keywords (case-insensitive), the first row is highlighted as soon as the
|
|
183
|
+
* list renders, and arrow keys move through the rows while Enter picks the highlighted one. Escape is left
|
|
184
|
+
* to the host's close control, which is why this part owns no dialog state.
|
|
185
|
+
*/
|
|
113
186
|
export function CommandPaletteBody({
|
|
114
187
|
groups,
|
|
115
188
|
placeholder = "Search",
|
|
@@ -119,6 +192,7 @@ export function CommandPaletteBody({
|
|
|
119
192
|
onClose,
|
|
120
193
|
onSelect,
|
|
121
194
|
}: CommandPaletteBodyProps) {
|
|
195
|
+
const { keyboardHints } = useDevice();
|
|
122
196
|
return (
|
|
123
197
|
<Autocomplete.Root
|
|
124
198
|
open
|
|
@@ -138,8 +212,13 @@ export function CommandPaletteBody({
|
|
|
138
212
|
/>
|
|
139
213
|
{closeControl ??
|
|
140
214
|
(onClose === undefined ? null : (
|
|
141
|
-
<button
|
|
142
|
-
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
aria-label={closeLabel}
|
|
218
|
+
className={keyboardHints ? "motion-press cursor-pointer" : touchCloseClass}
|
|
219
|
+
onClick={onClose}
|
|
220
|
+
>
|
|
221
|
+
{keyboardHints ? <Kbd keys="Escape" /> : <X aria-hidden />}
|
|
143
222
|
</button>
|
|
144
223
|
))}
|
|
145
224
|
</div>
|
|
@@ -179,7 +258,7 @@ export function CommandPaletteBody({
|
|
|
179
258
|
<span className="truncate text-label-md text-text-primary">{item.label}</span>
|
|
180
259
|
{item.meta && <span className="shrink-0 text-caption text-text-secondary">{item.meta}</span>}
|
|
181
260
|
<span className="flex-1" />
|
|
182
|
-
{item.hint}
|
|
261
|
+
{keyboardHints && item.hint}
|
|
183
262
|
</Autocomplete.Item>
|
|
184
263
|
)}
|
|
185
264
|
</Autocomplete.Collection>
|
|
@@ -5,21 +5,34 @@ import { cn } from "../lib/cn";
|
|
|
5
5
|
import type { ControlSize } from "../lib/size";
|
|
6
6
|
import { IconButton } from "./icon-button";
|
|
7
7
|
|
|
8
|
+
/** Height and text rung of the composer: `sm` 32px, `md` 40px, `lg` 48px, shared with the other controls. */
|
|
8
9
|
export type ComposerSize = ControlSize;
|
|
10
|
+
/** `solid` is the opaque bordered pill; `glass` is the veil field for imagery and glass panels. */
|
|
9
11
|
export type ComposerSurface = "solid" | "glass";
|
|
10
12
|
|
|
13
|
+
/** Props for {@link Composer}: the message value, the send callback and the two side actions. */
|
|
11
14
|
export type ComposerProps = {
|
|
15
|
+
/** Rung of the field and its icon buttons. @default "md" */
|
|
12
16
|
size?: ComposerSize;
|
|
13
|
-
/** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
|
|
17
|
+
/** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. @default "solid" */
|
|
14
18
|
surface?: ComposerSurface;
|
|
19
|
+
/** Field placeholder; it doubles as the `aria-label` because the field has no visible label. @default "Message" */
|
|
15
20
|
placeholder?: string;
|
|
21
|
+
/** Controlled text. Leave it out to let the composer own the value and still report changes. */
|
|
16
22
|
value?: string;
|
|
23
|
+
/** Starting text of the uncontrolled composer. @default "" */
|
|
17
24
|
defaultValue?: string;
|
|
25
|
+
/** Fires on every keystroke with the next full text, controlled or not. */
|
|
18
26
|
onValueChange?: (value: string) => void;
|
|
27
|
+
/** Fires with the full (untrimmed) text when the form submits, from Enter or the send button; whitespace-only text blocks the submit. The field is cleared after. */
|
|
19
28
|
onSubmit?: (value: string) => void;
|
|
29
|
+
/** Fires when the plus button is pressed; the composer itself opens nothing. */
|
|
20
30
|
onAttach?: () => void;
|
|
31
|
+
/** Fires when the microphone button is pressed, i.e. while the field is empty. */
|
|
21
32
|
onVoice?: () => void;
|
|
33
|
+
/** Disables the field and both icon buttons without changing the layout. */
|
|
22
34
|
disabled?: boolean;
|
|
35
|
+
/** Extra classes on the form, merged after the surface and size classes. */
|
|
23
36
|
className?: string;
|
|
24
37
|
};
|
|
25
38
|
|
|
@@ -39,6 +52,20 @@ const textClass: Record<ComposerSize, string> = {
|
|
|
39
52
|
* Figma `Composer` (63:462): a regular-glass pill with an attach button, the
|
|
40
53
|
* message field, and a voice button that turns into the ink send button once
|
|
41
54
|
* there is text.
|
|
55
|
+
*
|
|
56
|
+
* A real `form`, so Enter submits and the browser handles implicit submission; a value that is only
|
|
57
|
+
* whitespace blocks the send and leaves the typed text in place. The last slot swaps between the voice
|
|
58
|
+
* button (empty field) and the primary send button (`motion-swap`), and only the visible one is focusable.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* <Composer
|
|
63
|
+
* placeholder="Ask anything"
|
|
64
|
+
* onAttach={() => openFilePicker()}
|
|
65
|
+
* onVoice={() => startDictation()}
|
|
66
|
+
* onSubmit={(text) => sendMessage(text)}
|
|
67
|
+
* />
|
|
68
|
+
* ```
|
|
42
69
|
*/
|
|
43
70
|
export function Composer({
|
|
44
71
|
size = "md",
|