@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.
- package/README.md +168 -2
- package/package.json +50 -5
- package/src/assets/sidebar-photo.jpg +0 -0
- package/src/components/agent-activity.tsx +204 -0
- package/src/components/agent-lane.tsx +80 -0
- package/src/components/agent-lanes-timeline.tsx +312 -0
- package/src/components/agent-panel.tsx +125 -0
- package/src/components/agent-steering-panel.tsx +276 -0
- package/src/components/approval-card.tsx +89 -0
- package/src/components/avatar.tsx +104 -0
- package/src/components/badge.tsx +63 -0
- package/src/components/banner.tsx +103 -0
- package/src/components/bot-instructions-panel.tsx +369 -0
- package/src/components/bot-list-row.tsx +42 -0
- package/src/components/bot-memory-panel.tsx +229 -0
- package/src/components/bottom-sheet.tsx +140 -0
- package/src/components/breadcrumb-header.tsx +87 -0
- package/src/components/breadcrumb.tsx +86 -0
- package/src/components/button.tsx +146 -0
- package/src/components/calendar.tsx +410 -0
- package/src/components/card.tsx +79 -0
- package/src/components/chat-bubble.tsx +36 -0
- package/src/components/chat-controls.tsx +118 -0
- package/src/components/checkbox.tsx +73 -0
- package/src/components/chip.tsx +81 -0
- package/src/components/choice-card.tsx +55 -0
- package/src/components/code-panel.tsx +27 -0
- package/src/components/combobox.tsx +312 -0
- package/src/components/command-palette.tsx +206 -0
- package/src/components/composer.tsx +124 -0
- package/src/components/data-table.tsx +429 -0
- package/src/components/date-field.tsx +100 -0
- package/src/components/dialog.tsx +93 -0
- package/src/components/directory-card.tsx +59 -0
- package/src/components/document-extract-panel.tsx +398 -0
- package/src/components/empty-state.tsx +29 -0
- package/src/components/field.tsx +78 -0
- package/src/components/file-drop.tsx +89 -0
- package/src/components/filter-bar.tsx +83 -0
- package/src/components/flue-background.tsx +388 -0
- package/src/components/horon-icon.tsx +280 -0
- package/src/components/horon-wordmark.tsx +26 -0
- package/src/components/icon-button.tsx +61 -0
- package/src/components/input-group.tsx +112 -0
- package/src/components/input.tsx +72 -0
- package/src/components/integration-row.tsx +103 -0
- package/src/components/kbd.tsx +42 -0
- package/src/components/knowledge-base-panel.tsx +185 -0
- package/src/components/liquid-glass.tsx +24 -0
- package/src/components/list-detail.tsx +275 -0
- package/src/components/marketplace-card.tsx +97 -0
- package/src/components/mcp-server-row.tsx +122 -0
- package/src/components/menu.tsx +165 -0
- package/src/components/message-extras.tsx +146 -0
- package/src/components/modal.tsx +108 -0
- package/src/components/orchestrator-panel.tsx +175 -0
- package/src/components/page-header.tsx +48 -0
- package/src/components/plan-card.tsx +70 -0
- package/src/components/plan-step.tsx +111 -0
- package/src/components/plugin-row.tsx +128 -0
- package/src/components/progress.tsx +21 -0
- package/src/components/radio-group.tsx +84 -0
- package/src/components/research-run-panel.tsx +232 -0
- package/src/components/schedule.tsx +332 -0
- package/src/components/segmented-control.tsx +136 -0
- package/src/components/select.tsx +154 -0
- package/src/components/settings-list.tsx +99 -0
- package/src/components/sidebar.tsx +542 -0
- package/src/components/skeleton.tsx +75 -0
- package/src/components/slider.tsx +29 -0
- package/src/components/source-row.tsx +112 -0
- package/src/components/spinner.tsx +29 -0
- package/src/components/split-button.tsx +72 -0
- package/src/components/switch.tsx +79 -0
- package/src/components/tab-bar.tsx +90 -0
- package/src/components/tabs.tsx +162 -0
- package/src/components/task-row.tsx +159 -0
- package/src/components/textarea-field.tsx +127 -0
- package/src/components/textarea.tsx +47 -0
- package/src/components/toast.tsx +128 -0
- package/src/components/tool-call.tsx +215 -0
- package/src/components/tool-calls-header.tsx +65 -0
- package/src/components/tool-permissions.tsx +105 -0
- package/src/components/tooltip.tsx +33 -0
- package/src/components/top-bar.tsx +65 -0
- package/src/components/usage-meter.tsx +47 -0
- package/src/components/workspace-menu.tsx +77 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/use-active-indicator.ts +60 -0
- package/src/hooks/use-color-scheme.ts +27 -0
- package/src/hooks/use-copy-to-clipboard.ts +32 -0
- package/src/hooks/use-device.ts +41 -0
- package/src/hooks/use-hotkey.ts +35 -0
- package/src/hooks/use-media-query.ts +25 -0
- package/src/hooks/use-platform.ts +39 -0
- package/src/hooks/use-reduced-motion.ts +8 -0
- package/src/index.ts +99 -0
- package/src/lib/cn.ts +25 -0
- package/src/lib/cursor-ghost.ts +244 -0
- package/src/lib/platform.ts +169 -0
- package/src/lib/size.ts +10 -0
- package/src/styles/globals.css +564 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
5
|
+
import { Switch, type SwitchProps } from "./switch";
|
|
6
|
+
|
|
7
|
+
type WithClass<T> = Omit<T, "className"> & { className?: string };
|
|
8
|
+
|
|
9
|
+
export type SettingsListProps = WithClass<ComponentProps<"div">> & {
|
|
10
|
+
/** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass card. */
|
|
11
|
+
surface?: "solid" | "glass";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Paper `Settings list` (1VD8-0): a 16px-radius card of divided rows, 20px inline padding, solid or thick glass. */
|
|
15
|
+
export function SettingsList({ surface = "solid", className, children, ...props }: SettingsListProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={cn(
|
|
19
|
+
"relative flex min-w-0 flex-col overflow-clip rounded-2xl px-5 [&>:not(.liquid-layers)]:relative",
|
|
20
|
+
surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
{surface === "glass" && <LiquidGlass />}
|
|
26
|
+
{children}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SettingsRowProps = Omit<ComponentProps<"div">, "className" | "title"> & {
|
|
32
|
+
title: ReactNode;
|
|
33
|
+
description?: ReactNode;
|
|
34
|
+
/** Leading slot before the title, usually a `SettingsRowIcon`; the row tightens to 12px gap and 13px block padding. */
|
|
35
|
+
leading?: ReactNode;
|
|
36
|
+
/** Trailing control slot: a `Switch`, a `SettingsKeys` pair, a button. */
|
|
37
|
+
control?: ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** One row: title and subtitle on the left, control on the right, 1px divider except on the last row. */
|
|
42
|
+
export function SettingsRow({ title, description, leading, control, className, ...props }: SettingsRowProps) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className={cn(
|
|
46
|
+
"flex min-w-0 flex-wrap items-center border-b border-border-default last:border-b-0",
|
|
47
|
+
leading ? "gap-x-3 gap-y-2 py-[13px]" : "gap-x-4 gap-y-2 py-4",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{leading}
|
|
53
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
54
|
+
<span className="text-label-md wrap-break-word text-text-primary">{title}</span>
|
|
55
|
+
{description && <span className="text-body-sm wrap-break-word text-text-secondary">{description}</span>}
|
|
56
|
+
</div>
|
|
57
|
+
{control && <div className="flex shrink-0 items-center">{control}</div>}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Paper settings row icon tile: 32px, 9px radius, subtle fill, 16px glyph centered. */
|
|
63
|
+
export function SettingsRowIcon({ className, ...props }: WithClass<ComponentProps<"span">>) {
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-bg-subtle text-text-primary [&_svg]:size-4",
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A `Switch` pre-wired for a row's control slot (md, 44x26). */
|
|
77
|
+
export function SettingsSwitch(props: SwitchProps) {
|
|
78
|
+
return <Switch size="md" {...props} />;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One keycap: 26px high, 6px radius, subtle fill with a 1px hairline (replaces `Kbd` in settings rows). */
|
|
82
|
+
export function SettingsKey({ className, children, ...props }: WithClass<ComponentProps<"kbd">>) {
|
|
83
|
+
return (
|
|
84
|
+
<kbd
|
|
85
|
+
className={cn(
|
|
86
|
+
"inline-flex h-6.5 items-center rounded-xs border border-border-default bg-bg-subtle px-2 font-sans text-label-sm text-text-primary",
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
<span className="text-trim">{children}</span>
|
|
92
|
+
</kbd>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A keycap pair or chord, 4px apart. */
|
|
97
|
+
export function SettingsKeys({ className, ...props }: WithClass<ComponentProps<"span">>) {
|
|
98
|
+
return <span className={cn("inline-flex items-center gap-1", className)} {...props} />;
|
|
99
|
+
}
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { Collapsible } from "@base-ui/react/collapsible";
|
|
2
|
+
import { CaretDown, CaretLeft, CaretRight, CaretUpDown, DotsThree, PlusCircle } from "@phosphor-icons/react";
|
|
3
|
+
import {
|
|
4
|
+
Children,
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type PointerEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
useEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
|
|
15
|
+
import { cn } from "../lib/cn";
|
|
16
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
17
|
+
|
|
18
|
+
type DivProps = Omit<ComponentProps<"div">, "className"> & { className?: string };
|
|
19
|
+
type ButtonProps = Omit<ComponentProps<"button">, "className"> & { className?: string };
|
|
20
|
+
|
|
21
|
+
export type SidebarProps = Omit<ComponentProps<"nav">, "className"> & {
|
|
22
|
+
className?: string;
|
|
23
|
+
/**
|
|
24
|
+
* `glass` (default) is the thick-glass column. `photo` is the Paper app sidebar: the Horon photo
|
|
25
|
+
* on #EEF3F8 under a 55% white veil, with the thick glass kept for dark mode. `plain` paints no
|
|
26
|
+
* fill or glass, so the column takes the color of the layout behind it, divided by a 1px border.
|
|
27
|
+
*/
|
|
28
|
+
surface?: "glass" | "photo" | "plain";
|
|
29
|
+
/**
|
|
30
|
+
* Drag the right edge to change the width, from the `md` breakpoint (768px) up.
|
|
31
|
+
* Below `md` the handle is hidden and the sidebar keeps its fixed 280px width.
|
|
32
|
+
*/
|
|
33
|
+
resizable?: boolean;
|
|
34
|
+
/** Starting width in px when `resizable`. Double-clicking the handle returns to it. */
|
|
35
|
+
defaultWidth?: number;
|
|
36
|
+
minWidth?: number;
|
|
37
|
+
maxWidth?: number;
|
|
38
|
+
/** Called with the clamped width as it changes, during a drag too. */
|
|
39
|
+
onWidthChange?: (width: number) => void;
|
|
40
|
+
resizeLabel?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const RESIZE_STEP = 16;
|
|
44
|
+
|
|
45
|
+
function clamp(value: number, min: number, max: number): number {
|
|
46
|
+
return Math.round(Math.min(max, Math.max(min, value)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Figma `Sidebar` (88:848): a 280px thick-glass column with a 1px right border, 16/12/12px padding.
|
|
51
|
+
* With `resizable`, an 8px handle on the right edge drags the width between `minWidth` and `maxWidth`.
|
|
52
|
+
*/
|
|
53
|
+
export function Sidebar({
|
|
54
|
+
className,
|
|
55
|
+
style,
|
|
56
|
+
children,
|
|
57
|
+
surface = "glass",
|
|
58
|
+
resizable = false,
|
|
59
|
+
defaultWidth = 280,
|
|
60
|
+
minWidth = 240,
|
|
61
|
+
maxWidth = 400,
|
|
62
|
+
onWidthChange,
|
|
63
|
+
resizeLabel = "Resize sidebar",
|
|
64
|
+
...props
|
|
65
|
+
}: SidebarProps) {
|
|
66
|
+
const [width, setWidth] = useState(() => clamp(defaultWidth, minWidth, maxWidth));
|
|
67
|
+
const [dragging, setDragging] = useState(false);
|
|
68
|
+
const drag = useRef<{ x: number; width: number } | null>(null);
|
|
69
|
+
|
|
70
|
+
const resize = (next: number) => {
|
|
71
|
+
const clamped = clamp(next, minWidth, maxWidth);
|
|
72
|
+
if (clamped === width) return;
|
|
73
|
+
setWidth(clamped);
|
|
74
|
+
onWidthChange?.(clamped);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Keep the col-resize cursor and stop text selection while the pointer is off the handle mid-drag.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!dragging) return undefined;
|
|
80
|
+
const root = document.documentElement.style;
|
|
81
|
+
const previous = { cursor: root.cursor, userSelect: root.userSelect };
|
|
82
|
+
root.cursor = "col-resize";
|
|
83
|
+
root.userSelect = "none";
|
|
84
|
+
return () => {
|
|
85
|
+
root.cursor = previous.cursor;
|
|
86
|
+
root.userSelect = previous.userSelect;
|
|
87
|
+
};
|
|
88
|
+
}, [dragging]);
|
|
89
|
+
|
|
90
|
+
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
91
|
+
if (event.button !== 0) return;
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
94
|
+
drag.current = { x: event.clientX, width };
|
|
95
|
+
setDragging(true);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
99
|
+
if (!drag.current) return;
|
|
100
|
+
resize(drag.current.width + event.clientX - drag.current.x);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const endDrag = () => {
|
|
104
|
+
drag.current = null;
|
|
105
|
+
setDragging(false);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
109
|
+
const next = {
|
|
110
|
+
ArrowLeft: width - RESIZE_STEP,
|
|
111
|
+
ArrowRight: width + RESIZE_STEP,
|
|
112
|
+
Home: minWidth,
|
|
113
|
+
End: maxWidth,
|
|
114
|
+
}[event.key];
|
|
115
|
+
if (next === undefined) return;
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
resize(next);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<nav
|
|
122
|
+
className={cn(
|
|
123
|
+
// The column keeps its 280px design width where there is room; a definite-width
|
|
124
|
+
// parent below that caps it, so it never runs past the container.
|
|
125
|
+
"relative flex w-70 max-w-full min-w-0 flex-col items-stretch gap-0.5 overflow-clip px-3 pt-4 pb-3",
|
|
126
|
+
resizable && "transition-[width] duration-200 ease-out-quint data-resizing:transition-none",
|
|
127
|
+
surface === "glass" && "bg-glass-thick",
|
|
128
|
+
surface === "photo" && "bg-sidebar-photo dark:bg-glass-thick dark:bg-none",
|
|
129
|
+
surface === "plain" && "border-r border-border-default",
|
|
130
|
+
resizable && "md:w-(--sidebar-width)",
|
|
131
|
+
className,
|
|
132
|
+
)}
|
|
133
|
+
style={resizable ? ({ ...style, "--sidebar-width": `${width}px` } as CSSProperties) : style}
|
|
134
|
+
data-resizing={(resizable && dragging) || undefined}
|
|
135
|
+
{...props}
|
|
136
|
+
>
|
|
137
|
+
{surface === "glass" && <LiquidGlass />}
|
|
138
|
+
{children}
|
|
139
|
+
{resizable && (
|
|
140
|
+
<div
|
|
141
|
+
role="separator"
|
|
142
|
+
aria-orientation="vertical"
|
|
143
|
+
aria-label={resizeLabel}
|
|
144
|
+
aria-valuenow={width}
|
|
145
|
+
aria-valuemin={minWidth}
|
|
146
|
+
aria-valuemax={maxWidth}
|
|
147
|
+
tabIndex={0}
|
|
148
|
+
data-dragging={dragging || undefined}
|
|
149
|
+
onPointerDown={onPointerDown}
|
|
150
|
+
onPointerMove={onPointerMove}
|
|
151
|
+
onPointerUp={endDrag}
|
|
152
|
+
onPointerCancel={endDrag}
|
|
153
|
+
onLostPointerCapture={endDrag}
|
|
154
|
+
onKeyDown={onKeyDown}
|
|
155
|
+
onDoubleClick={() => resize(defaultWidth)}
|
|
156
|
+
className="group/resize absolute inset-y-0 right-0 z-10 hidden w-2 cursor-col-resize touch-none outline-none md:block"
|
|
157
|
+
>
|
|
158
|
+
<span
|
|
159
|
+
aria-hidden
|
|
160
|
+
className="absolute inset-y-0 right-0 w-0.5 transition-colors duration-150 group-hover/resize:bg-overlay-ink-16 group-focus-visible/resize:bg-text-brand group-data-dragging/resize:bg-text-brand"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</nav>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type SidebarHeaderProps = {
|
|
169
|
+
/** Workspace initial shown on the 28px ink tile. */
|
|
170
|
+
initial: ReactNode;
|
|
171
|
+
name: ReactNode;
|
|
172
|
+
/** Props for the workspace switch button, such as a `MenuTrigger` render target. */
|
|
173
|
+
switchProps?: ButtonProps;
|
|
174
|
+
/** Replaces the default switch, such as a `WorkspaceMenu` whose trigger is a `SidebarWorkspaceSwitch`. */
|
|
175
|
+
switchSlot?: ReactNode;
|
|
176
|
+
onCollapse?: () => void;
|
|
177
|
+
collapseLabel?: string;
|
|
178
|
+
className?: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export type SidebarWorkspaceSwitchProps = Omit<ButtonProps, "name"> & { initial: ReactNode; name: ReactNode };
|
|
182
|
+
|
|
183
|
+
/** The workspace switch button: 28px ink tile, name and up-down caret. Highlights while its menu is open. */
|
|
184
|
+
export function SidebarWorkspaceSwitch({ initial, name, className, ...props }: SidebarWorkspaceSwitchProps) {
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className={cn(
|
|
189
|
+
"flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-[10px] pr-1 pl-2 text-left motion-colors hover:bg-overlay-ink-6 data-popup-open:bg-overlay-ink-6",
|
|
190
|
+
"focus-visible:outline-2 focus-visible:outline-text-brand",
|
|
191
|
+
className,
|
|
192
|
+
)}
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-interactive-primary text-label-md text-text-inverse">
|
|
196
|
+
{initial}
|
|
197
|
+
</span>
|
|
198
|
+
<span className="min-w-0 flex-1 truncate text-label-lg text-text-primary">{name}</span>
|
|
199
|
+
<CaretUpDown aria-hidden className="size-3.5 shrink-0 text-text-secondary" />
|
|
200
|
+
</button>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Header: workspace switch (tile, name, up-down caret) and an optional 28px collapse button. 10px gap below. */
|
|
205
|
+
export function SidebarHeader({
|
|
206
|
+
initial,
|
|
207
|
+
name,
|
|
208
|
+
switchProps,
|
|
209
|
+
switchSlot,
|
|
210
|
+
onCollapse,
|
|
211
|
+
collapseLabel = "Collapse sidebar",
|
|
212
|
+
className,
|
|
213
|
+
}: SidebarHeaderProps) {
|
|
214
|
+
return (
|
|
215
|
+
<div className={cn("relative mb-2.5 flex h-9 items-center gap-1.5", className)}>
|
|
216
|
+
{switchSlot ?? <SidebarWorkspaceSwitch {...switchProps} name={name} initial={initial} />}
|
|
217
|
+
{onCollapse && (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
aria-label={collapseLabel}
|
|
221
|
+
onClick={onCollapse}
|
|
222
|
+
className="relative flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-sm text-text-secondary motion-colors after:absolute after:top-1/2 after:left-1/2 after:min-h-11 after:min-w-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-[''] hover:bg-overlay-ink-6 hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand active:bg-overlay-ink-10"
|
|
223
|
+
>
|
|
224
|
+
<CaretLeft aria-hidden className="size-4" />
|
|
225
|
+
</button>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type SidebarItemCommonProps = {
|
|
232
|
+
/** 18px icon, or a `SidebarProjectTile`. */
|
|
233
|
+
icon?: ReactNode;
|
|
234
|
+
/** Count on the right, such as unread items. */
|
|
235
|
+
count?: ReactNode;
|
|
236
|
+
/** Trailing 7px red dot for attention. */
|
|
237
|
+
attention?: boolean;
|
|
238
|
+
current?: boolean;
|
|
239
|
+
className?: string;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** With `href`, the row is a real link and takes anchor props; without it, a `type="button"` button. */
|
|
243
|
+
export type SidebarItemProps =
|
|
244
|
+
| (SidebarItemCommonProps & { href: string } & Omit<ComponentProps<"a">, "className" | "href" | "type">)
|
|
245
|
+
| (SidebarItemCommonProps & { href?: undefined } & ButtonProps);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Figma sidebar `Item`: 32px row, 12px radius, 10px gap; the current item is 6% ink.
|
|
249
|
+
* Pass `href` to render an `<a>` with the same look.
|
|
250
|
+
*/
|
|
251
|
+
export function SidebarItem({ icon, count, attention, current, className, children, ...props }: SidebarItemProps) {
|
|
252
|
+
const classes = cn(
|
|
253
|
+
"relative flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-label-md text-text-primary",
|
|
254
|
+
"motion-colors",
|
|
255
|
+
current ? "bg-overlay-ink-6" : "hover:bg-overlay-ink-4",
|
|
256
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
|
|
257
|
+
className,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const content = (
|
|
261
|
+
<>
|
|
262
|
+
{icon && (
|
|
263
|
+
<span data-slot="icon" className="flex shrink-0 [&>svg]:size-4.5">
|
|
264
|
+
{icon}
|
|
265
|
+
</span>
|
|
266
|
+
)}
|
|
267
|
+
<span className="min-w-0 flex-1 truncate">{children}</span>
|
|
268
|
+
{count !== undefined && <span className="shrink-0 text-body-sm text-text-secondary">{count}</span>}
|
|
269
|
+
{attention && <span aria-label="Needs attention" className="size-[7px] shrink-0 rounded-full bg-text-danger" />}
|
|
270
|
+
</>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (props.href !== undefined) {
|
|
274
|
+
const { href, ...anchorProps } = props;
|
|
275
|
+
return (
|
|
276
|
+
<a href={href} aria-current={current ? "page" : undefined} className={classes} {...anchorProps}>
|
|
277
|
+
{content}
|
|
278
|
+
</a>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<button type="button" aria-current={current ? "page" : undefined} className={classes} {...props}>
|
|
284
|
+
{content}
|
|
285
|
+
</button>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A 22px rounded tile with a 14px inverse icon, for project rows. Set the fill with `className`. */
|
|
290
|
+
export function SidebarProjectTile({
|
|
291
|
+
className,
|
|
292
|
+
...props
|
|
293
|
+
}: Omit<ComponentProps<"span">, "className"> & { className?: string }) {
|
|
294
|
+
return (
|
|
295
|
+
<span
|
|
296
|
+
className={cn(
|
|
297
|
+
"flex size-5.5 items-center justify-center rounded-xs bg-interactive-primary text-text-inverse [&>svg]:size-3.5",
|
|
298
|
+
className,
|
|
299
|
+
)}
|
|
300
|
+
{...props}
|
|
301
|
+
/>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export type SidebarGroupProps = {
|
|
306
|
+
label: ReactNode;
|
|
307
|
+
defaultOpen?: boolean;
|
|
308
|
+
children: ReactNode;
|
|
309
|
+
className?: string;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** A collapsible group: 28px label in secondary text with a 14px caret, 14px above. */
|
|
313
|
+
export function SidebarGroup({ label, defaultOpen = true, children, className }: SidebarGroupProps) {
|
|
314
|
+
return (
|
|
315
|
+
<Collapsible.Root defaultOpen={defaultOpen} className={cn("relative mt-3.5 flex flex-col gap-0.5", className)}>
|
|
316
|
+
<Collapsible.Trigger className="group flex h-7 w-fit cursor-pointer items-center gap-1 px-2.5 text-label-md text-text-secondary motion-colors hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand">
|
|
317
|
+
{label}
|
|
318
|
+
<CaretDown
|
|
319
|
+
aria-hidden
|
|
320
|
+
className="size-3.5 transition-transform duration-200 ease-out-quint group-not-data-panel-open:-rotate-90 group-data-panel-open:rotate-0"
|
|
321
|
+
/>
|
|
322
|
+
</Collapsible.Trigger>
|
|
323
|
+
<Collapsible.Panel className="flex motion-collapse flex-col gap-0.5">{children}</Collapsible.Panel>
|
|
324
|
+
</Collapsible.Root>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const ROW = 32;
|
|
329
|
+
const GAP = 2;
|
|
330
|
+
|
|
331
|
+
/** Connector path of the Figma `Tree`: one 1.5px line with a 9px curve into the middle of each child row. */
|
|
332
|
+
function branchesPath(count: number): string {
|
|
333
|
+
const centers = Array.from({ length: count }, (_, index) => ROW / 2 + index * (ROW + GAP));
|
|
334
|
+
const last = centers.at(-1) ?? 0;
|
|
335
|
+
const trunk = `M0.75 0V${last - 9}`;
|
|
336
|
+
const branches = centers.map((center) => {
|
|
337
|
+
const start = center - 9;
|
|
338
|
+
return `M0.75 ${start}C0.75 ${start + 4.909} 4.841 ${center} 9.75 ${center}H13.75`;
|
|
339
|
+
});
|
|
340
|
+
return [trunk, ...branches].join("");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Figma `Tree`: nested sidebar items with 16px icons, 20px in, joined to the parent by curved connectors. */
|
|
344
|
+
export function SidebarTree({ children, className }: { children: ReactNode; className?: string }) {
|
|
345
|
+
const count = Children.count(children);
|
|
346
|
+
const height = ROW / 2 + (count - 1) * (ROW + GAP) + 0.75;
|
|
347
|
+
return (
|
|
348
|
+
<div className={cn("relative flex items-start pl-5 [&_[data-slot=icon]>svg]:size-4", className)}>
|
|
349
|
+
<svg
|
|
350
|
+
aria-hidden
|
|
351
|
+
width="16"
|
|
352
|
+
height={height}
|
|
353
|
+
viewBox={`0 0 16 ${height}`}
|
|
354
|
+
fill="none"
|
|
355
|
+
className="shrink-0 overflow-visible"
|
|
356
|
+
>
|
|
357
|
+
<path d={branchesPath(count)} strokeWidth="1.5" className="stroke-overlay-ink-16" />
|
|
358
|
+
</svg>
|
|
359
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">{children}</div>
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Pushes the account card to the bottom. */
|
|
365
|
+
export function SidebarSpacer() {
|
|
366
|
+
return <div aria-hidden className="min-h-px flex-1" />;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export type SidebarAccountProps = ButtonProps & {
|
|
370
|
+
initials: ReactNode;
|
|
371
|
+
name: ReactNode;
|
|
372
|
+
email?: ReactNode;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** Figma sidebar `Account`: a 54px row with a 36px sky avatar tile and an up-down caret. */
|
|
376
|
+
export function SidebarAccount({ initials, name, email, className, ...props }: SidebarAccountProps) {
|
|
377
|
+
return (
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
className={cn(
|
|
381
|
+
"relative flex w-full cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-1.5 text-left motion-colors hover:bg-overlay-ink-4 data-popup-open:bg-overlay-ink-6",
|
|
382
|
+
"focus-visible:outline-2 focus-visible:outline-text-brand",
|
|
383
|
+
className,
|
|
384
|
+
)}
|
|
385
|
+
{...props}
|
|
386
|
+
>
|
|
387
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-sky-200 text-label-md text-surface-ink">
|
|
388
|
+
{initials}
|
|
389
|
+
</span>
|
|
390
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
391
|
+
<span className="truncate text-label-md text-text-primary">{name}</span>
|
|
392
|
+
{email && <span className="truncate text-caption text-text-secondary">{email}</span>}
|
|
393
|
+
</span>
|
|
394
|
+
<CaretUpDown aria-hidden className="size-4 shrink-0 text-text-primary" />
|
|
395
|
+
</button>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export type SidebarProjectProps = {
|
|
400
|
+
/** A `SidebarProjectTile`. */
|
|
401
|
+
icon: ReactNode;
|
|
402
|
+
label: ReactNode;
|
|
403
|
+
current?: boolean;
|
|
404
|
+
defaultOpen?: boolean;
|
|
405
|
+
open?: boolean;
|
|
406
|
+
onOpenChange?: (open: boolean) => void;
|
|
407
|
+
/**
|
|
408
|
+
* The project's "More" control, such as a `MenuTrigger` whose render target is
|
|
409
|
+
* `<SidebarProjectMore />`. It replaces the chevron while the project is open.
|
|
410
|
+
*/
|
|
411
|
+
actions?: ReactNode;
|
|
412
|
+
/** Chats and documents in the project, shown in a `SidebarTree` when open. */
|
|
413
|
+
children?: ReactNode;
|
|
414
|
+
className?: string;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Figma sidebar project row (88:848, Menu=Project 314:12407): tile, name and a
|
|
419
|
+
* 14px chevron (down when open). An open project shows the 16px "More" button in
|
|
420
|
+
* place of the chevron, on a 6% ink row; a collapsed project keeps the chevron.
|
|
421
|
+
*/
|
|
422
|
+
export function SidebarProject({
|
|
423
|
+
icon,
|
|
424
|
+
label,
|
|
425
|
+
current,
|
|
426
|
+
defaultOpen = false,
|
|
427
|
+
open,
|
|
428
|
+
onOpenChange,
|
|
429
|
+
actions,
|
|
430
|
+
children,
|
|
431
|
+
className,
|
|
432
|
+
}: SidebarProjectProps) {
|
|
433
|
+
const hasChildren = Children.count(children) > 0;
|
|
434
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
435
|
+
const isOpen = open ?? uncontrolledOpen;
|
|
436
|
+
const handleOpenChange = (next: boolean) => {
|
|
437
|
+
setUncontrolledOpen(next);
|
|
438
|
+
onOpenChange?.(next);
|
|
439
|
+
};
|
|
440
|
+
return (
|
|
441
|
+
<Collapsible.Root open={isOpen} onOpenChange={handleOpenChange} className={cn("flex flex-col gap-0.5", className)}>
|
|
442
|
+
<div
|
|
443
|
+
className={cn(
|
|
444
|
+
"group/project relative flex h-8 items-center rounded-md has-data-popup-open:bg-overlay-ink-6",
|
|
445
|
+
"motion-colors",
|
|
446
|
+
current ? "bg-overlay-ink-6" : "hover:bg-overlay-ink-4",
|
|
447
|
+
)}
|
|
448
|
+
>
|
|
449
|
+
<Collapsible.Trigger
|
|
450
|
+
aria-current={current ? "page" : undefined}
|
|
451
|
+
className={cn(
|
|
452
|
+
"group/trigger flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-label-md text-text-primary",
|
|
453
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
|
|
454
|
+
)}
|
|
455
|
+
>
|
|
456
|
+
<span data-slot="icon" className="flex shrink-0">
|
|
457
|
+
{icon}
|
|
458
|
+
</span>
|
|
459
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
460
|
+
{hasChildren && (
|
|
461
|
+
<span
|
|
462
|
+
aria-hidden
|
|
463
|
+
className={cn("flex size-3.5 shrink-0 [&>svg]:size-3.5", actions !== undefined && isOpen && "invisible")}
|
|
464
|
+
>
|
|
465
|
+
<CaretRight className="transition-transform duration-200 ease-out-quint group-data-panel-open/trigger:rotate-90" />
|
|
466
|
+
</span>
|
|
467
|
+
)}
|
|
468
|
+
</Collapsible.Trigger>
|
|
469
|
+
{actions !== undefined && (
|
|
470
|
+
<span className={cn("absolute right-2.5 flex", !hasChildren || isOpen ? "visible" : "invisible")}>
|
|
471
|
+
{actions}
|
|
472
|
+
</span>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
{hasChildren && (
|
|
476
|
+
<Collapsible.Panel className="motion-collapse">
|
|
477
|
+
<SidebarTree>{children}</SidebarTree>
|
|
478
|
+
</Collapsible.Panel>
|
|
479
|
+
)}
|
|
480
|
+
</Collapsible.Root>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export type SidebarProjectMoreProps = ButtonProps & { label?: string };
|
|
485
|
+
|
|
486
|
+
/** The 16px "More" button of a project row. */
|
|
487
|
+
export function SidebarProjectMore({ label = "Project actions", className, ...props }: SidebarProjectMoreProps) {
|
|
488
|
+
return (
|
|
489
|
+
<button
|
|
490
|
+
type="button"
|
|
491
|
+
aria-label={label}
|
|
492
|
+
className={cn(
|
|
493
|
+
"relative flex size-4 cursor-pointer items-center justify-center rounded-xs text-text-primary motion-colors after:absolute after:top-1/2 after:left-1/2 after:min-h-11 after:min-w-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-[''] hover:bg-overlay-ink-6 focus-visible:outline-2 focus-visible:outline-text-brand",
|
|
494
|
+
className,
|
|
495
|
+
)}
|
|
496
|
+
{...props}
|
|
497
|
+
>
|
|
498
|
+
<DotsThree aria-hidden className="size-4" />
|
|
499
|
+
</button>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export type SidebarAddButtonProps = ButtonProps & { icon?: ReactNode };
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Figma `Add project`: a 36px button with a 1px inside dashed border (4/3 dash,
|
|
507
|
+
* 20% ink), label then a 16px plus-circle, 12px above. The border is an SVG rect
|
|
508
|
+
* so the dash matches; its 2px stroke is clipped to the inner 1px.
|
|
509
|
+
*/
|
|
510
|
+
const addIcon = <PlusCircle />;
|
|
511
|
+
|
|
512
|
+
export function SidebarAddButton({ icon = addIcon, className, children, ...props }: SidebarAddButtonProps) {
|
|
513
|
+
return (
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
className={cn(
|
|
517
|
+
"relative mt-2 flex h-9 w-full cursor-pointer items-center justify-center gap-1.5 rounded-[10px] text-label-md text-text-secondary motion-colors hover:bg-overlay-ink-4",
|
|
518
|
+
"focus-visible:outline-2 focus-visible:outline-text-brand",
|
|
519
|
+
className,
|
|
520
|
+
)}
|
|
521
|
+
{...props}
|
|
522
|
+
>
|
|
523
|
+
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full text-overlay-ink-20">
|
|
524
|
+
<rect
|
|
525
|
+
width="100%"
|
|
526
|
+
height="100%"
|
|
527
|
+
rx="10"
|
|
528
|
+
fill="none"
|
|
529
|
+
stroke="currentColor"
|
|
530
|
+
strokeWidth="2"
|
|
531
|
+
strokeDasharray="4 3"
|
|
532
|
+
/>
|
|
533
|
+
</svg>
|
|
534
|
+
{children}
|
|
535
|
+
<span data-slot="icon" className="flex [&>svg]:size-4">
|
|
536
|
+
{icon}
|
|
537
|
+
</span>
|
|
538
|
+
</button>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export type { DivProps as SidebarDivProps };
|