@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,75 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
5
|
+
|
|
6
|
+
export type SkeletonProps = Omit<ComponentProps<"div">, "className"> & {
|
|
7
|
+
/** `block` has the Figma 6px radius, `pill` is fully round (avatars, buttons). */
|
|
8
|
+
shape?: "block" | "pill";
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** A 7% ink placeholder; size it with classes. */
|
|
13
|
+
export function Skeleton({ shape = "block", className, ...props }: SkeletonProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
aria-hidden
|
|
17
|
+
className={cn(
|
|
18
|
+
"animate-shimmer bg-overlay-ink-7 bg-[linear-gradient(90deg,transparent_30%,rgba(255,255,255,0.4)_50%,transparent_70%)] bg-size-[200%_100%] bg-no-repeat dark:bg-[linear-gradient(90deg,transparent_30%,rgba(255,255,255,0.06)_50%,transparent_70%)]",
|
|
19
|
+
shape === "pill" ? "rounded-full" : "rounded-xs",
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SkeletonCardProps = {
|
|
28
|
+
type?: "row" | "card" | "text";
|
|
29
|
+
/** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass card. */
|
|
30
|
+
surface?: "solid" | "glass";
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Figma `Skeleton` (in Loading 109:733): Row, Card and Text placeholders on a solid or thick-glass card. */
|
|
35
|
+
export function SkeletonCard({ type = "row", surface = "solid", className }: SkeletonCardProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
role="status"
|
|
39
|
+
aria-label="Loading"
|
|
40
|
+
className={cn(
|
|
41
|
+
"relative flex gap-3 overflow-clip rounded-xl p-4",
|
|
42
|
+
surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
|
|
43
|
+
type === "row" ? "w-75 max-w-full items-center" : "w-70 max-w-full flex-col items-start",
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{surface === "glass" && <LiquidGlass />}
|
|
48
|
+
{type === "row" && (
|
|
49
|
+
<>
|
|
50
|
+
<Skeleton shape="pill" className="relative size-9 shrink-0" />
|
|
51
|
+
<div className="relative flex min-w-0 flex-1 flex-col gap-2">
|
|
52
|
+
<Skeleton className="h-2.5 w-35 max-sm:max-w-full" />
|
|
53
|
+
<Skeleton className="h-2 w-50 max-sm:max-w-full" />
|
|
54
|
+
</div>
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
{type === "card" && (
|
|
58
|
+
<>
|
|
59
|
+
<Skeleton className="relative h-3 w-30 max-sm:max-w-full" />
|
|
60
|
+
<Skeleton className="relative h-2 w-62 max-sm:max-w-full" />
|
|
61
|
+
<Skeleton className="relative h-2 w-55 max-sm:max-w-full" />
|
|
62
|
+
<Skeleton className="relative h-2 w-40 max-sm:max-w-full" />
|
|
63
|
+
<Skeleton shape="pill" className="relative h-7 w-20 max-sm:max-w-full" />
|
|
64
|
+
</>
|
|
65
|
+
)}
|
|
66
|
+
{type === "text" && (
|
|
67
|
+
<>
|
|
68
|
+
<Skeleton className="relative h-2 w-62 max-sm:max-w-full" />
|
|
69
|
+
<Skeleton className="relative h-2 w-59 max-sm:max-w-full" />
|
|
70
|
+
<Skeleton className="relative h-2 w-45 max-sm:max-w-full" />
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Slider as BaseSlider } from "@base-ui/react/slider";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
export type SliderProps = Omit<ComponentProps<typeof BaseSlider.Root>, "className" | "children"> & {
|
|
7
|
+
className?: string;
|
|
8
|
+
"aria-label"?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Paper settings slider (Voice / Speed): a 20px control, 6px track at 8% ink with an ink fill,
|
|
13
|
+
* and an 18px white thumb with a 1px hairline and a 0 1 3 12% shadow. Width comes from the caller.
|
|
14
|
+
*/
|
|
15
|
+
export function Slider({ className, "aria-label": ariaLabel, ...props }: SliderProps) {
|
|
16
|
+
return (
|
|
17
|
+
<BaseSlider.Root className={cn("relative w-55 max-w-full", className)} {...props}>
|
|
18
|
+
<BaseSlider.Control className="flex h-5 w-full cursor-pointer touch-none items-center select-none data-disabled:cursor-not-allowed data-disabled:opacity-40">
|
|
19
|
+
<BaseSlider.Track className="relative h-1.5 w-full rounded-full bg-overlay-ink-8">
|
|
20
|
+
<BaseSlider.Indicator className="rounded-full bg-interactive-primary" />
|
|
21
|
+
<BaseSlider.Thumb
|
|
22
|
+
aria-label={ariaLabel}
|
|
23
|
+
className="size-[18px] cursor-grab rounded-full border border-border-default bg-bg-default shadow-[0_1px_3px_#0000001f] transition-[scale,box-shadow] duration-120 ease-out-quint hover:scale-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand active:scale-110"
|
|
24
|
+
/>
|
|
25
|
+
</BaseSlider.Track>
|
|
26
|
+
</BaseSlider.Control>
|
|
27
|
+
</BaseSlider.Root>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Check, WarningCircle } from "@phosphor-icons/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
export type SourceStatus = "ready" | "indexing" | "failed";
|
|
7
|
+
|
|
8
|
+
export type SourceRowProps = {
|
|
9
|
+
status: SourceStatus;
|
|
10
|
+
/** 16px source icon on the 32px tile. */
|
|
11
|
+
icon: ReactNode;
|
|
12
|
+
name: ReactNode;
|
|
13
|
+
/** Pages and chunks, progress, or the error. */
|
|
14
|
+
detail: ReactNode;
|
|
15
|
+
/** 0-100 while indexing. */
|
|
16
|
+
progress?: number;
|
|
17
|
+
onRetry?: () => void;
|
|
18
|
+
/** `card` is a standalone subtle row; `list` is transparent with a 1px divider, for stacking inside a list. */
|
|
19
|
+
variant?: "card" | "list";
|
|
20
|
+
/** Pipeline stage states; defaults to Parse, Chunk, Embed, Index derived from `status`. */
|
|
21
|
+
stages?: SourceStages;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SourceStage = "done" | "active" | "todo" | "failed";
|
|
26
|
+
export type SourceStages = [SourceStage, SourceStage, SourceStage, SourceStage];
|
|
27
|
+
const stageNames = ["parse", "chunk", "embed", "index"] as const;
|
|
28
|
+
|
|
29
|
+
const defaultStages: Record<SourceStatus, SourceStages> = {
|
|
30
|
+
ready: ["done", "done", "done", "done"],
|
|
31
|
+
indexing: ["done", "done", "active", "todo"],
|
|
32
|
+
failed: ["done", "done", "failed", "todo"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const stageFill = { done: "bg-overlay-ink-35", todo: "bg-overlay-ink-8", failed: "bg-accent-red-500-90" } as const;
|
|
36
|
+
const stage = (state: keyof typeof stageFill) =>
|
|
37
|
+
cn("h-[3px] w-6 overflow-clip rounded-full transition-colors duration-150 sm:w-11", stageFill[state]);
|
|
38
|
+
|
|
39
|
+
/** Figma `Source row` (Knowledge base): a flat row with a four-stage pipeline (parse, chunk, embed, index). */
|
|
40
|
+
export function SourceRow({
|
|
41
|
+
status,
|
|
42
|
+
icon,
|
|
43
|
+
name,
|
|
44
|
+
detail,
|
|
45
|
+
progress = 0,
|
|
46
|
+
onRetry,
|
|
47
|
+
variant = "card",
|
|
48
|
+
stages = defaultStages[status],
|
|
49
|
+
className,
|
|
50
|
+
}: SourceRowProps) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={cn(
|
|
54
|
+
"flex flex-wrap items-center gap-x-3 gap-y-2 px-2 py-3",
|
|
55
|
+
variant === "list"
|
|
56
|
+
? "relative after:absolute after:inset-x-2 after:bottom-0 after:h-px after:bg-overlay-ink-8 last:after:hidden"
|
|
57
|
+
: "rounded-[14px] border border-border-default bg-bg-surface px-3",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-overlay-ink-5 text-text-primary [&>svg]:size-4">
|
|
62
|
+
{icon}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
65
|
+
<span className="truncate text-label-md text-text-primary">{name}</span>
|
|
66
|
+
<span
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex items-center gap-1 text-caption",
|
|
69
|
+
status === "failed" ? "text-text-danger" : "text-text-secondary",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{status === "failed" && <WarningCircle aria-hidden className="size-3 shrink-0" />}
|
|
73
|
+
<span className="truncate">{detail}</span>
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
<span aria-hidden className="flex shrink-0 items-center gap-1">
|
|
77
|
+
{stages.map((state, i) => {
|
|
78
|
+
const stageName = stageNames[i];
|
|
79
|
+
return state === "active" ? (
|
|
80
|
+
<span key={stageName} className={stage("todo")}>
|
|
81
|
+
<span
|
|
82
|
+
className="block h-full rounded-full bg-status-live transition-[width] duration-400 ease-out-quint"
|
|
83
|
+
style={{ width: `${progress}%` }}
|
|
84
|
+
/>
|
|
85
|
+
</span>
|
|
86
|
+
) : (
|
|
87
|
+
<span key={stageName} className={stage(state)} />
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="flex h-7.5 w-18 shrink-0 items-center justify-end">
|
|
92
|
+
{status === "ready" && <Check aria-label="Ready" className="size-4 text-text-secondary" />}
|
|
93
|
+
{status === "indexing" && (
|
|
94
|
+
<span className="w-9 text-right text-label-md text-text-secondary tabular-nums">
|
|
95
|
+
<span key={progress} className="inline-block animate-fade-in">
|
|
96
|
+
{progress}%
|
|
97
|
+
</span>
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
{status === "failed" && (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={onRetry}
|
|
104
|
+
className="flex h-7.5 motion-press cursor-pointer items-center rounded-full bg-overlay-ink-6 px-3 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
|
|
105
|
+
>
|
|
106
|
+
Retry
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cn } from "../lib/cn";
|
|
2
|
+
|
|
3
|
+
export type SpinnerSize = "xs" | "sm" | "md" | "lg";
|
|
4
|
+
|
|
5
|
+
export type SpinnerProps = {
|
|
6
|
+
/** Named size, or a pixel size for the one-off spinners in agent rows (12, 15, 20). */
|
|
7
|
+
size?: SpinnerSize | number;
|
|
8
|
+
label?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sizeClass: Record<SpinnerSize, string> = { xs: "size-3.5", sm: "size-4", md: "size-6", lg: "size-8" };
|
|
13
|
+
|
|
14
|
+
/** Figma `Spinner` (Loading 109:733): a 10% ink ring with a quarter ink arc, 11% stroke. */
|
|
15
|
+
export function Spinner({ size = "sm", label = "Loading", className }: SpinnerProps) {
|
|
16
|
+
return (
|
|
17
|
+
<svg
|
|
18
|
+
role="status"
|
|
19
|
+
aria-label={label}
|
|
20
|
+
viewBox="0 0 100 100"
|
|
21
|
+
width={typeof size === "number" ? size : undefined}
|
|
22
|
+
height={typeof size === "number" ? size : undefined}
|
|
23
|
+
className={cn("shrink-0 animate-spin", typeof size === "number" ? undefined : sizeClass[size], className)}
|
|
24
|
+
>
|
|
25
|
+
<circle cx="50" cy="50" r="44.5" fill="none" strokeWidth="11" className="stroke-overlay-ink-10" />
|
|
26
|
+
<path d="M50 5.5A44.5 44.5 0 0 1 94.5 50" fill="none" strokeWidth="11" className="stroke-interactive-primary" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Button as BaseButton } from "@base-ui/react/button";
|
|
2
|
+
import { CaretDown } from "@phosphor-icons/react";
|
|
3
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { Menu, MenuContent, MenuTrigger } from "./menu";
|
|
7
|
+
|
|
8
|
+
export type SplitButtonProps = Omit<ComponentProps<typeof BaseButton>, "className" | "children"> & {
|
|
9
|
+
/** Primary action label. */
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** 16px leading icon. */
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
/** Menu items (`MenuItem`, `MenuSeparator`) opened by the caret. */
|
|
14
|
+
menu?: ReactNode;
|
|
15
|
+
menuLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const halfClass =
|
|
20
|
+
"relative inline-flex h-full cursor-pointer items-center select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand data-disabled:cursor-not-allowed";
|
|
21
|
+
|
|
22
|
+
/** Paper `Split button` (1VGK-0): one primary pill on `Button`'s 32px rung, two hit areas split by a 1x18 bar. */
|
|
23
|
+
export function SplitButton({
|
|
24
|
+
children,
|
|
25
|
+
icon,
|
|
26
|
+
menu,
|
|
27
|
+
menuLabel = "More actions",
|
|
28
|
+
disabled,
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
}: SplitButtonProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={cn(
|
|
35
|
+
"inline-flex h-8 shrink-0 items-center rounded-full bg-interactive-primary text-label-md text-interactive-on-primary",
|
|
36
|
+
// The press and the disabled dim belong to the pill, not to one half: the fill is the container's,
|
|
37
|
+
// so shrinking a half alone moves the label inside a fixed pill.
|
|
38
|
+
"transition-[scale,background-color,opacity] duration-150 ease-out-quint",
|
|
39
|
+
"hover:bg-interactive-primary-hover has-[[data-disabled]]:opacity-40 has-[[data-disabled]]:hover:bg-interactive-primary",
|
|
40
|
+
"has-[button:active:not(:disabled,[data-disabled],[aria-disabled=true])]:scale-[0.97]",
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
<BaseButton
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
className={cn(halfClass, "gap-1.5 [&_svg]:size-4", menu ? "rounded-l-full pr-2 pl-3" : "rounded-full px-3")}
|
|
47
|
+
{...props}
|
|
48
|
+
>
|
|
49
|
+
{icon && <span className="flex shrink-0">{icon}</span>}
|
|
50
|
+
<span className="relative text-trim">{children}</span>
|
|
51
|
+
</BaseButton>
|
|
52
|
+
{menu ? (
|
|
53
|
+
<>
|
|
54
|
+
<span aria-hidden className="h-4.5 w-px shrink-0 bg-interactive-on-primary opacity-25" />
|
|
55
|
+
<Menu>
|
|
56
|
+
<MenuTrigger
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
aria-label={menuLabel}
|
|
59
|
+
className={cn(halfClass, "group rounded-r-full pr-4 pl-2")}
|
|
60
|
+
>
|
|
61
|
+
<CaretDown
|
|
62
|
+
aria-hidden
|
|
63
|
+
className="size-3.5 transition-transform duration-200 ease-out-quint group-data-popup-open:rotate-180"
|
|
64
|
+
/>
|
|
65
|
+
</MenuTrigger>
|
|
66
|
+
<MenuContent align="end">{menu}</MenuContent>
|
|
67
|
+
</Menu>
|
|
68
|
+
</>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Switch as BaseSwitch } from "@base-ui/react/switch";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import type { ControlSize } from "../lib/size";
|
|
6
|
+
|
|
7
|
+
/** `compact` is the 40x24 Paper bot-memory switch and keeps its own rung. */
|
|
8
|
+
export type SwitchSize = ControlSize | "compact";
|
|
9
|
+
|
|
10
|
+
export type SwitchProps = Omit<ComponentProps<typeof BaseSwitch.Root>, "className"> & {
|
|
11
|
+
size?: SwitchSize;
|
|
12
|
+
label?: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const trackClass: Record<SwitchSize, string> = {
|
|
17
|
+
sm: "h-5 w-8 p-0.5",
|
|
18
|
+
md: "h-6.5 w-11 p-0.5",
|
|
19
|
+
lg: "h-7.5 w-13 p-0.5",
|
|
20
|
+
compact: "h-6 w-10 p-[3px]",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const thumbClass: Record<SwitchSize, string> = {
|
|
24
|
+
sm: "size-4 data-checked:translate-x-3",
|
|
25
|
+
md: "size-5.5 data-checked:translate-x-4.5",
|
|
26
|
+
lg: "size-6.5 data-checked:translate-x-5.5",
|
|
27
|
+
compact: "size-[18px] data-checked:translate-x-4",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const labelClass: Record<SwitchSize, string> = {
|
|
31
|
+
sm: "gap-3 text-body-sm",
|
|
32
|
+
md: "gap-4 text-body-md",
|
|
33
|
+
lg: "gap-4 text-body-lg",
|
|
34
|
+
compact: "gap-3 text-body-sm",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Switch: MD 44x26 and SM 32x20 tracks with a white thumb 2px in; LG is 52x30; COMPACT 40x24 with an
|
|
39
|
+
* 18px thumb 3px in (Paper bot memory). The thumb stays white on the grey track in both themes and
|
|
40
|
+
* takes the inverse of the ink fill when checked, so the checked state reads the same in dark.
|
|
41
|
+
*/
|
|
42
|
+
export function Switch({ size = "md", label, className, ...props }: SwitchProps) {
|
|
43
|
+
const control = (
|
|
44
|
+
<BaseSwitch.Root
|
|
45
|
+
className={cn(
|
|
46
|
+
"relative inline-flex shrink-0 cursor-pointer items-center rounded-full bg-border-strong motion-colors",
|
|
47
|
+
"hover:not-data-disabled:bg-interactive-secondary-hover data-checked:bg-interactive-primary hover:not-data-disabled:data-checked:bg-interactive-primary-hover",
|
|
48
|
+
"data-disabled:cursor-not-allowed data-disabled:opacity-40",
|
|
49
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
|
|
50
|
+
trackClass[size],
|
|
51
|
+
label ? undefined : className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
<BaseSwitch.Thumb
|
|
56
|
+
className={cn(
|
|
57
|
+
// The thumb is a flex child of a fixed-width track whose content box can be
|
|
58
|
+
// narrower than the thumb plus its travel; `shrink-0` holds it at its own
|
|
59
|
+
// size so the track never reads as a bare bar.
|
|
60
|
+
"shrink-0 rounded-full bg-text-on-ink transition-[translate,background-color] duration-220 ease-spring data-checked:bg-interactive-on-primary",
|
|
61
|
+
thumbClass[size],
|
|
62
|
+
)}
|
|
63
|
+
/>
|
|
64
|
+
</BaseSwitch.Root>
|
|
65
|
+
);
|
|
66
|
+
if (!label) return control;
|
|
67
|
+
return (
|
|
68
|
+
<label
|
|
69
|
+
className={cn(
|
|
70
|
+
"inline-flex max-w-full min-w-0 cursor-pointer items-center text-text-primary",
|
|
71
|
+
labelClass[size],
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
<span className="min-w-0 flex-1">{label}</span>
|
|
76
|
+
{control}
|
|
77
|
+
</label>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type ReactNode, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import type { ControlSize } from "../lib/size";
|
|
5
|
+
import { useActiveIndicator } from "../hooks/use-active-indicator";
|
|
6
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
7
|
+
|
|
8
|
+
export type TabBarItem = { value: string; label: string; icon: ReactNode };
|
|
9
|
+
export type TabBarSize = ControlSize;
|
|
10
|
+
|
|
11
|
+
export type TabBarProps = {
|
|
12
|
+
items: TabBarItem[];
|
|
13
|
+
value: string;
|
|
14
|
+
onValueChange?: (value: string) => void;
|
|
15
|
+
/** `md` is the 64x48 Figma tab; `sm` is 56x40 and `lg` 72x56. */
|
|
16
|
+
size?: TabBarSize;
|
|
17
|
+
surface?: "glass" | "flat";
|
|
18
|
+
"aria-label"?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const trackClass: Record<TabBarSize, string> = {
|
|
23
|
+
sm: "p-1",
|
|
24
|
+
md: "p-1.5",
|
|
25
|
+
lg: "p-2",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const tabClass: Record<TabBarSize, string> = {
|
|
29
|
+
sm: "h-10 w-14 [&_svg]:size-5",
|
|
30
|
+
md: "h-12 w-16 [&_svg]:size-5.5",
|
|
31
|
+
lg: "h-14 w-18 [&_svg]:size-6",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Figma `Tab bar` (63:441): the mobile bottom navigation, a regular-glass pill
|
|
36
|
+
* with icon-only 64x48 tabs and 22px icons; the current tab is a 10% ink pill
|
|
37
|
+
* that slides to the tab you pick.
|
|
38
|
+
*/
|
|
39
|
+
export function TabBar({
|
|
40
|
+
items,
|
|
41
|
+
value,
|
|
42
|
+
onValueChange,
|
|
43
|
+
size = "md",
|
|
44
|
+
surface = "glass",
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: TabBarProps) {
|
|
48
|
+
const track = useRef<HTMLElement>(null);
|
|
49
|
+
const { rect, animate } = useActiveIndicator(track, '[aria-current="page"]');
|
|
50
|
+
return (
|
|
51
|
+
<nav
|
|
52
|
+
ref={track}
|
|
53
|
+
aria-label={props["aria-label"] ?? "Main"}
|
|
54
|
+
className={cn(
|
|
55
|
+
// Tabs keep their fixed 56/64/72px tap size; when the row is wider than the
|
|
56
|
+
// container the track scrolls instead of squeezing the icons.
|
|
57
|
+
"relative flex w-fit max-w-full min-w-0 items-center gap-1 overflow-x-auto overflow-y-clip rounded-full bg-glass-regular",
|
|
58
|
+
trackClass[size],
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{surface === "glass" ? <LiquidGlass /> : null}
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden
|
|
65
|
+
className={cn("pointer-events-none absolute rounded-full bg-overlay-ink-10", animate && "motion-indicator")}
|
|
66
|
+
style={rect ? { ...rect, opacity: 1 } : { opacity: 0 }}
|
|
67
|
+
/>
|
|
68
|
+
{items.map((item) => {
|
|
69
|
+
const current = item.value === value;
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
key={item.value}
|
|
73
|
+
type="button"
|
|
74
|
+
aria-label={item.label}
|
|
75
|
+
aria-current={current ? "page" : undefined}
|
|
76
|
+
onClick={() => onValueChange?.(item.value)}
|
|
77
|
+
className={cn(
|
|
78
|
+
"relative flex shrink-0 motion-press cursor-pointer items-center justify-center rounded-full",
|
|
79
|
+
tabClass[size],
|
|
80
|
+
current ? "text-text-primary" : "text-text-secondary hover:bg-overlay-ink-5",
|
|
81
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{item.icon}
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</nav>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
|
|
2
|
+
import { type ComponentProps, createContext, type ReactNode, useContext } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import type { ControlSize } from "../lib/size";
|
|
6
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
7
|
+
|
|
8
|
+
export type TabsSize = ControlSize;
|
|
9
|
+
|
|
10
|
+
/** `Tabs` owns the size; every `TabsTab` inside reads it so one list stays on one rung. */
|
|
11
|
+
const TabsSizeContext = createContext<ControlSize>("md");
|
|
12
|
+
|
|
13
|
+
export type TabsListVariant = "pill" | "underline";
|
|
14
|
+
|
|
15
|
+
/** `TabsList` owns the variant; its `TabsTab`s read it to drop the pill shape on the underline list. */
|
|
16
|
+
const TabsVariantContext = createContext<TabsListVariant>("pill");
|
|
17
|
+
|
|
18
|
+
type WithClass<T> = Omit<T, "className"> & { className?: string };
|
|
19
|
+
|
|
20
|
+
export function Tabs({
|
|
21
|
+
size = "md",
|
|
22
|
+
className,
|
|
23
|
+
...props
|
|
24
|
+
}: WithClass<ComponentProps<typeof BaseTabs.Root>> & { size?: TabsSize }) {
|
|
25
|
+
return (
|
|
26
|
+
<TabsSizeContext.Provider value={size}>
|
|
27
|
+
<BaseTabs.Root className={cn("flex max-w-full min-w-0 flex-col gap-4", className)} {...props} />
|
|
28
|
+
</TabsSizeContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Track padding per rung, the same as `SegmentedControl` so the two pill trays line up. */
|
|
33
|
+
const listClass: Record<TabsSize, string> = {
|
|
34
|
+
sm: "p-[3px]",
|
|
35
|
+
md: "p-[3px]",
|
|
36
|
+
lg: "p-1",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Figma `Tabs example` (62:353): a pill tray, solid or regular glass, with the same rungs as
|
|
41
|
+
* `SegmentedControl` (3/3/4px padding, 2px between tabs). The selection is one indicator that
|
|
42
|
+
* slides to the active tab (Base UI `Tabs.Indicator` positions it): 10% ink on solid, the thick
|
|
43
|
+
* glass pill with a soft shadow on glass, as on the segmented control.
|
|
44
|
+
*
|
|
45
|
+
* `variant="underline"` is the document variant: no tray, a full-width 1px default border under
|
|
46
|
+
* the tabs, and a 2px primary bar that slides under the active tab. `surface` does not apply to it.
|
|
47
|
+
*/
|
|
48
|
+
export function TabsList({
|
|
49
|
+
variant = "pill",
|
|
50
|
+
surface = "solid",
|
|
51
|
+
className,
|
|
52
|
+
children,
|
|
53
|
+
...props
|
|
54
|
+
}: WithClass<ComponentProps<typeof BaseTabs.List>> & {
|
|
55
|
+
/** `pill` (default) is the tray of pill tabs; `underline` is a text row with a sliding underline. */
|
|
56
|
+
variant?: TabsListVariant;
|
|
57
|
+
/** `solid` (default) is an opaque surface tray with a default border; `glass` is the regular glass tray. */
|
|
58
|
+
surface?: "solid" | "glass";
|
|
59
|
+
}) {
|
|
60
|
+
const size = useContext(TabsSizeContext);
|
|
61
|
+
if (variant === "underline") {
|
|
62
|
+
return (
|
|
63
|
+
<TabsVariantContext.Provider value="underline">
|
|
64
|
+
<BaseTabs.List
|
|
65
|
+
className={cn("relative flex w-full items-center gap-6 border-b border-border-default", className)}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
<BaseTabs.Indicator className="absolute -bottom-px left-(--active-tab-left) h-0.5 w-(--active-tab-width) bg-text-primary motion-indicator" />
|
|
69
|
+
{children}
|
|
70
|
+
</BaseTabs.List>
|
|
71
|
+
</TabsVariantContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<BaseTabs.List
|
|
76
|
+
className={cn(
|
|
77
|
+
// The tray hugs its tabs and caps at the container; tabs keep their label
|
|
78
|
+
// padding and type, so an over-wide set scrolls rather than squeezing.
|
|
79
|
+
"relative flex w-full max-w-fit min-w-0 items-center gap-0.5 overflow-x-auto overflow-y-clip rounded-full",
|
|
80
|
+
listClass[size],
|
|
81
|
+
surface === "glass" ? "bg-glass-regular" : "border border-border-default bg-bg-surface",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
{surface === "glass" && <LiquidGlass />}
|
|
87
|
+
<BaseTabs.Indicator
|
|
88
|
+
className={cn(
|
|
89
|
+
"absolute top-(--active-tab-top) left-(--active-tab-left) h-(--active-tab-height) w-(--active-tab-width) overflow-clip rounded-full motion-indicator",
|
|
90
|
+
surface === "glass"
|
|
91
|
+
? "bg-glass-thick shadow-[0_1px_3px_rgba(0,0,0,0.08)] dark:bg-overlay-ink-10"
|
|
92
|
+
: "bg-overlay-ink-10",
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{surface === "glass" && <LiquidGlass />}
|
|
96
|
+
</BaseTabs.Indicator>
|
|
97
|
+
{children}
|
|
98
|
+
</BaseTabs.List>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One tab per rung: the `SegmentedControl` segment heights and padding (26/30/36px, 10/16/16px). */
|
|
103
|
+
const tabClass: Record<TabsSize, string> = {
|
|
104
|
+
sm: "h-6.5 gap-1 px-2.5 text-label-sm [&_svg]:size-3.5",
|
|
105
|
+
md: "h-7.5 gap-1.5 px-4 text-label-md [&_svg]:size-4",
|
|
106
|
+
lg: "h-9 gap-2 px-4 text-label-lg [&_svg]:size-5",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const countClass: Record<TabsSize, string> = {
|
|
110
|
+
sm: "h-4.5 px-1.5",
|
|
111
|
+
md: "h-5 px-1.5",
|
|
112
|
+
lg: "h-6 px-2",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Figma `Tabs` (62:335): a pill tab; the list's indicator draws the selection under the active tab. */
|
|
116
|
+
export function TabsTab({
|
|
117
|
+
className,
|
|
118
|
+
icon,
|
|
119
|
+
count,
|
|
120
|
+
children,
|
|
121
|
+
...props
|
|
122
|
+
}: WithClass<ComponentProps<typeof BaseTabs.Tab>> & {
|
|
123
|
+
/** Leading icon, 14px (sm), 16px (md) or 20px (lg) (Settings `Tabs with count`). */
|
|
124
|
+
icon?: ReactNode;
|
|
125
|
+
/** Count pill after the label; turns solid on the active tab. */
|
|
126
|
+
count?: number;
|
|
127
|
+
}) {
|
|
128
|
+
const size = useContext(TabsSizeContext);
|
|
129
|
+
const underline = useContext(TabsVariantContext) === "underline";
|
|
130
|
+
return (
|
|
131
|
+
<BaseTabs.Tab
|
|
132
|
+
className={cn(
|
|
133
|
+
"group/tab relative inline-flex cursor-pointer items-center justify-center whitespace-nowrap text-text-secondary",
|
|
134
|
+
underline ? "rounded-xs" : "rounded-full",
|
|
135
|
+
tabClass[size],
|
|
136
|
+
// The underline row keeps each rung's height and type but drops the pill's side padding.
|
|
137
|
+
underline && "px-0",
|
|
138
|
+
"motion-colors hover:text-text-primary data-active:text-text-primary",
|
|
139
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand data-disabled:cursor-not-allowed data-disabled:opacity-40",
|
|
140
|
+
className,
|
|
141
|
+
)}
|
|
142
|
+
{...props}
|
|
143
|
+
>
|
|
144
|
+
{icon}
|
|
145
|
+
<span className="text-trim">{children}</span>
|
|
146
|
+
{count !== undefined && (
|
|
147
|
+
<span
|
|
148
|
+
className={cn(
|
|
149
|
+
"inline-flex items-center rounded-full bg-overlay-ink-6 text-label-sm text-text-secondary motion-colors group-data-active/tab:bg-interactive-primary group-data-active/tab:text-interactive-on-primary",
|
|
150
|
+
countClass[size],
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
<span className="text-trim">{count}</span>
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</BaseTabs.Tab>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function TabsPanel({ className, ...props }: WithClass<ComponentProps<typeof BaseTabs.Panel>>) {
|
|
161
|
+
return <BaseTabs.Panel className={cn("animate-swap-move outline-none", className)} {...props} />;
|
|
162
|
+
}
|