@horon-ai/react 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -2
- package/package.json +50 -5
- package/src/assets/sidebar-photo.jpg +0 -0
- package/src/components/agent-activity.tsx +202 -0
- package/src/components/agent-lane.tsx +80 -0
- package/src/components/agent-lanes-timeline.tsx +312 -0
- package/src/components/agent-panel.tsx +121 -0
- package/src/components/agent-steering-panel.tsx +274 -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 +365 -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 +191 -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 +402 -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 +35 -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 +156 -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 +224 -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/index.ts +86 -0
- package/src/lib/cn.ts +25 -0
- package/src/lib/cursor-ghost.ts +244 -0
- package/src/lib/size.ts +10 -0
- package/src/lib/use-active-indicator.ts +60 -0
- package/src/styles/globals.css +564 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Drawer } from "@base-ui/react/drawer";
|
|
2
|
+
import { CaretRight } from "@phosphor-icons/react";
|
|
3
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
|
|
7
|
+
export const BottomSheet = Drawer.Root;
|
|
8
|
+
export const BottomSheetTrigger = Drawer.Trigger;
|
|
9
|
+
export const BottomSheetClose = Drawer.Close;
|
|
10
|
+
|
|
11
|
+
export type BottomSheetContentProps = Omit<ComponentProps<typeof Drawer.Popup>, "className" | "title"> & {
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
/**
|
|
14
|
+
* `panel` is the Figma panel sheet (374px max, label-lg title). `detail` is the Paper detail sheet:
|
|
15
|
+
* full width, 18px rhythm, a caller-built header (tile, title, description) set flush left.
|
|
16
|
+
*/
|
|
17
|
+
variant?: "panel" | "detail";
|
|
18
|
+
/** `glass` is the veil sheet (`veil-overlay`); `solid` is the Paper workspace sheet: opaque default fill, 20px top corners, 8/24/16 padding. */
|
|
19
|
+
surface?: "glass" | "solid";
|
|
20
|
+
/** Draws the dimmed, blurred scrim behind the sheet. Paper's floating panel sheet has none. */
|
|
21
|
+
backdrop?: boolean;
|
|
22
|
+
/** Insets the sheet 16px from the sides and bottom edge (Paper's 358px panel sheet) instead of docking it full width. */
|
|
23
|
+
floating?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Figma `Bottom sheet` (Panels 88:808): the veil with 32px top corners, a 36x5 grabber and a
|
|
29
|
+
* label-lg title. There is no close button: the sheet closes on the scrim, on Escape, on a
|
|
30
|
+
* downward drag of the grabber, or through the caller's own bottom action row.
|
|
31
|
+
*/
|
|
32
|
+
export function BottomSheetContent({
|
|
33
|
+
title,
|
|
34
|
+
variant = "panel",
|
|
35
|
+
surface = "solid",
|
|
36
|
+
backdrop = true,
|
|
37
|
+
floating = false,
|
|
38
|
+
className,
|
|
39
|
+
children,
|
|
40
|
+
...props
|
|
41
|
+
}: BottomSheetContentProps) {
|
|
42
|
+
const detail = variant === "detail";
|
|
43
|
+
const solid = surface === "solid";
|
|
44
|
+
return (
|
|
45
|
+
<Drawer.Portal>
|
|
46
|
+
{backdrop ? (
|
|
47
|
+
<Drawer.Backdrop className="fixed inset-0 z-50 bg-bg-scrim/32 motion-fade backdrop-blur-[6px]" />
|
|
48
|
+
) : null}
|
|
49
|
+
<Drawer.Viewport
|
|
50
|
+
className={cn("fixed z-50 flex justify-center", floating ? "inset-x-4 bottom-4" : "inset-x-0 bottom-0")}
|
|
51
|
+
>
|
|
52
|
+
<Drawer.Popup
|
|
53
|
+
className={cn(
|
|
54
|
+
"relative flex max-h-[calc(100dvh-2rem)] w-full flex-col items-stretch overflow-clip px-4 pt-2 outline-none",
|
|
55
|
+
solid ? "rounded-t-xl bg-bg-surface pb-6" : "veil-overlay rounded-t-[32px] pb-5",
|
|
56
|
+
"transition-[translate] duration-320 ease-out-quint data-ending-style:translate-y-full data-ending-style:duration-220 data-ending-style:ease-in data-starting-style:translate-y-full",
|
|
57
|
+
detail ? "gap-4.5" : floating ? "gap-3" : "max-w-93.5 gap-1",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
<span
|
|
63
|
+
aria-hidden
|
|
64
|
+
className="relative mx-auto h-[5px] w-9 cursor-grab touch-none rounded-[3px] bg-overlay-ink-20 active:cursor-grabbing"
|
|
65
|
+
/>
|
|
66
|
+
<Drawer.Content
|
|
67
|
+
className={cn(
|
|
68
|
+
"relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain",
|
|
69
|
+
detail ? "gap-4.5" : floating ? "gap-3" : "gap-1",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<div className={cn("flex gap-2", detail ? "items-start" : "items-center pt-2.5 pb-2 pl-2")}>
|
|
73
|
+
<Drawer.Title className={cn("min-w-0 flex-1 text-text-primary", detail ? "flex" : "text-label-lg")}>
|
|
74
|
+
{title}
|
|
75
|
+
</Drawer.Title>
|
|
76
|
+
</div>
|
|
77
|
+
{children}
|
|
78
|
+
</Drawer.Content>
|
|
79
|
+
</Drawer.Popup>
|
|
80
|
+
</Drawer.Viewport>
|
|
81
|
+
</Drawer.Portal>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type BottomSheetRowProps = Omit<ComponentProps<"button">, "className"> & {
|
|
86
|
+
icon?: ReactNode;
|
|
87
|
+
/** Current value on the right, such as "On" or "Weekdays". */
|
|
88
|
+
value?: ReactNode;
|
|
89
|
+
className?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A 48px settings row: 20px icon, label, value and a 16px caret. */
|
|
93
|
+
export function BottomSheetRow({ icon, value, className, children, ...props }: BottomSheetRowProps) {
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
className={cn(
|
|
98
|
+
"flex h-12 w-full motion-press cursor-pointer items-center gap-3 rounded-lg px-2 text-left hover:bg-overlay-ink-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
|
|
99
|
+
className,
|
|
100
|
+
)}
|
|
101
|
+
{...props}
|
|
102
|
+
>
|
|
103
|
+
{icon && <span className="flex shrink-0 text-text-secondary [&>svg]:size-5">{icon}</span>}
|
|
104
|
+
<span className="min-w-0 flex-1 truncate text-label-md text-text-primary">{children}</span>
|
|
105
|
+
{value && <span className="shrink-0 text-body-sm text-text-secondary">{value}</span>}
|
|
106
|
+
<CaretRight aria-hidden className="size-4 shrink-0 text-text-secondary" />
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The action row: secondary first, the primary action at the right edge, 8px apart. */
|
|
112
|
+
export function BottomSheetActions({
|
|
113
|
+
className,
|
|
114
|
+
...props
|
|
115
|
+
}: Omit<ComponentProps<"div">, "className"> & { className?: string }) {
|
|
116
|
+
return (
|
|
117
|
+
<div className={cn("relative flex w-full flex-wrap items-center justify-end gap-2 pt-3", className)} {...props} />
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type BottomSheetActionProps = Omit<ComponentProps<"button">, "className"> & {
|
|
122
|
+
variant?: "primary" | "secondary";
|
|
123
|
+
className?: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** The sheet's 44px pill actions: `secondary` is 6% ink, `primary` is ink. */
|
|
127
|
+
export function BottomSheetAction({ variant = "secondary", className, ...props }: BottomSheetActionProps) {
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className={cn(
|
|
132
|
+
"flex h-11 min-w-25 motion-press cursor-pointer items-center justify-center rounded-full px-4 text-label-md whitespace-nowrap",
|
|
133
|
+
variant === "primary" ? "bg-interactive-primary text-text-inverse" : "bg-overlay-ink-6 text-text-primary",
|
|
134
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
|
|
135
|
+
className,
|
|
136
|
+
)}
|
|
137
|
+
{...props}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { CaretRight, DotsThree, MagnifyingGlass } from "@phosphor-icons/react";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { BreadcrumbItem } from "./breadcrumb";
|
|
6
|
+
import { IconButton } from "./icon-button";
|
|
7
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
8
|
+
|
|
9
|
+
export type BreadcrumbHeaderCrumb = {
|
|
10
|
+
label: string;
|
|
11
|
+
href?: string;
|
|
12
|
+
/** Leading 20px ink tile, such as the workspace initial. */
|
|
13
|
+
tile?: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BreadcrumbHeaderProps = Omit<ComponentProps<"header">, "className"> & {
|
|
17
|
+
/** Crumbs in order; the last one is the current page. */
|
|
18
|
+
crumbs: BreadcrumbHeaderCrumb[];
|
|
19
|
+
onSearch?: () => void;
|
|
20
|
+
onMore?: () => void;
|
|
21
|
+
searchLabel?: string;
|
|
22
|
+
moreLabel?: string;
|
|
23
|
+
/** `solid` (default) on the page, `glass` over imagery and glass panels. */
|
|
24
|
+
surface?: "solid" | "glass";
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Paper `Header with breadcrumb`: a 52px regular-glass pill with a crumb row, search and more buttons. */
|
|
29
|
+
export function BreadcrumbHeader({
|
|
30
|
+
crumbs,
|
|
31
|
+
onSearch,
|
|
32
|
+
onMore,
|
|
33
|
+
searchLabel = "Search",
|
|
34
|
+
moreLabel = "More",
|
|
35
|
+
surface = "solid",
|
|
36
|
+
className,
|
|
37
|
+
...props
|
|
38
|
+
}: BreadcrumbHeaderProps) {
|
|
39
|
+
return (
|
|
40
|
+
<header
|
|
41
|
+
className={cn(
|
|
42
|
+
"relative flex h-13 items-center gap-2 overflow-clip rounded-full p-2",
|
|
43
|
+
surface === "glass" ? "bg-glass-regular" : "border border-border-default bg-bg-surface",
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{surface === "glass" && <LiquidGlass />}
|
|
49
|
+
<nav aria-label="Breadcrumb" className="relative min-w-0 flex-1 overflow-x-auto">
|
|
50
|
+
<ol className="flex items-center gap-1.5">
|
|
51
|
+
{crumbs.map((crumb, index) => {
|
|
52
|
+
const current = index === crumbs.length - 1;
|
|
53
|
+
return (
|
|
54
|
+
<li key={crumb.label} className={cn("flex items-center gap-1.5", current && "min-w-0")}>
|
|
55
|
+
{index > 0 && <CaretRight aria-hidden className="size-3.5 shrink-0 text-text-secondary" />}
|
|
56
|
+
<BreadcrumbItem
|
|
57
|
+
current={current}
|
|
58
|
+
tile={crumb.tile}
|
|
59
|
+
href={crumb.href}
|
|
60
|
+
className={current ? "min-w-0" : undefined}
|
|
61
|
+
>
|
|
62
|
+
<span className="min-w-0 truncate">{crumb.label}</span>
|
|
63
|
+
</BreadcrumbItem>
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</ol>
|
|
68
|
+
</nav>
|
|
69
|
+
{onSearch && (
|
|
70
|
+
<IconButton
|
|
71
|
+
icon={<MagnifyingGlass />}
|
|
72
|
+
label={searchLabel}
|
|
73
|
+
onClick={onSearch}
|
|
74
|
+
className="relative size-9 [&_svg]:size-4.5"
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
{onMore && (
|
|
78
|
+
<IconButton
|
|
79
|
+
icon={<DotsThree weight="bold" />}
|
|
80
|
+
label={moreLabel}
|
|
81
|
+
onClick={onMore}
|
|
82
|
+
className="relative size-9 [&_svg]:size-4.5"
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
85
|
+
</header>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { CaretRight } from "@phosphor-icons/react";
|
|
2
|
+
import { Children, Fragment, type ComponentProps, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
6
|
+
|
|
7
|
+
export type BreadcrumbSurface = "glass" | "solid";
|
|
8
|
+
|
|
9
|
+
export type BreadcrumbProps = Omit<ComponentProps<"nav">, "className"> & {
|
|
10
|
+
/** `glass` wraps the crumbs in a regular-glass tray, `solid` in an opaque tray. Omitted keeps them bare. */
|
|
11
|
+
surface?: BreadcrumbSurface;
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Figma `Breadcrumb` (110:948): crumbs 6px apart with 14px carets; the last crumb is the current page. */
|
|
16
|
+
export function Breadcrumb({
|
|
17
|
+
surface,
|
|
18
|
+
className,
|
|
19
|
+
children,
|
|
20
|
+
"aria-label": ariaLabel = "Breadcrumb",
|
|
21
|
+
...props
|
|
22
|
+
}: BreadcrumbProps) {
|
|
23
|
+
return (
|
|
24
|
+
<nav
|
|
25
|
+
aria-label={ariaLabel}
|
|
26
|
+
className={cn(
|
|
27
|
+
// A flex item cannot shrink below its trail's min-content width, so the tray
|
|
28
|
+
// would keep its 369px min-content and push the page wide. As a grid box the
|
|
29
|
+
// tray takes the available width below `sm` and hugs the crumbs from `sm` up.
|
|
30
|
+
"grid w-full min-w-0 sm:w-fit sm:max-w-fit",
|
|
31
|
+
surface === "glass" && "relative overflow-clip rounded-xl bg-glass-regular px-4 py-3.5",
|
|
32
|
+
surface === "solid" && "relative rounded-xl border border-border-default bg-bg-surface px-4 py-3.5",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
{surface === "glass" && <LiquidGlass />}
|
|
38
|
+
{/* Crumbs are navigation targets: an over-wide trail scrolls inside the tray
|
|
39
|
+
* instead of truncating a label the reader may need to tap. */}
|
|
40
|
+
<ol className="relative flex min-w-0 items-center gap-1.5 overflow-x-auto">
|
|
41
|
+
{Children.map(children, (item, index) => (
|
|
42
|
+
<Fragment key={index}>
|
|
43
|
+
{index > 0 && (
|
|
44
|
+
<li aria-hidden className="flex shrink-0">
|
|
45
|
+
<CaretRight className="size-3.5 shrink-0 text-text-secondary" />
|
|
46
|
+
</li>
|
|
47
|
+
)}
|
|
48
|
+
<li className="flex">{item}</li>
|
|
49
|
+
</Fragment>
|
|
50
|
+
))}
|
|
51
|
+
</ol>
|
|
52
|
+
</nav>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type BreadcrumbItemProps = Omit<ComponentProps<"a">, "className"> & {
|
|
57
|
+
/** Marks the current page: primary text and `aria-current`. */
|
|
58
|
+
current?: boolean;
|
|
59
|
+
/** Leading 20px ink tile, such as the workspace initial. */
|
|
60
|
+
tile?: ReactNode;
|
|
61
|
+
className?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function BreadcrumbItem({ current, tile, href, className, children, ...props }: BreadcrumbItemProps) {
|
|
65
|
+
const Element = href && !current ? "a" : "span";
|
|
66
|
+
return (
|
|
67
|
+
<Element
|
|
68
|
+
href={Element === "a" ? href : undefined}
|
|
69
|
+
aria-current={current ? "page" : undefined}
|
|
70
|
+
className={cn(
|
|
71
|
+
"flex items-center gap-1.5 rounded-sm py-1 pr-1.5 pl-1.5 text-label-md whitespace-nowrap no-underline motion-colors",
|
|
72
|
+
current ? "text-text-primary" : "text-text-secondary",
|
|
73
|
+
Element === "a" && "hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand",
|
|
74
|
+
className,
|
|
75
|
+
)}
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
{tile && (
|
|
79
|
+
<span className="flex size-5 shrink-0 items-center justify-center rounded-xs bg-interactive-primary text-label-sm text-text-inverse">
|
|
80
|
+
{tile}
|
|
81
|
+
</span>
|
|
82
|
+
)}
|
|
83
|
+
{children}
|
|
84
|
+
</Element>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Button as BaseButton } from "@base-ui/react/button";
|
|
2
|
+
import { Children, isValidElement, type ComponentProps, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
6
|
+
|
|
7
|
+
export type ButtonVariant = "primary" | "glass" | "solid" | "danger" | "link";
|
|
8
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
9
|
+
|
|
10
|
+
type ButtonCommonProps = {
|
|
11
|
+
variant?: ButtonVariant;
|
|
12
|
+
size?: ButtonSize;
|
|
13
|
+
/** Leading icon; sized 16px (sm, md) or 20px (lg) and colored like the label. */
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Anchor attributes a button element does not have; they only reach the DOM when `href` is set. */
|
|
19
|
+
type ButtonAnchorProps = Pick<ComponentProps<"a">, "href" | "target" | "rel" | "download">;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One flat prop type rather than a union of button props and anchor props: a caller that forwards the
|
|
23
|
+
* component's props (`Omit<ComponentProps<typeof Button>, "variant" | "size">`) needs a single object
|
|
24
|
+
* type. `keyof` of a union is only the keys both branches share, so `type` vanished there and the
|
|
25
|
+
* caller's `type="button"` failed to type-check.
|
|
26
|
+
*
|
|
27
|
+
* `href` still switches the render to a real anchor, and the anchor drops everything a button-only prop
|
|
28
|
+
* means there: `type`, `disabled`, `name`, `value`, the `form*` family and Base UI's own props. They stay
|
|
29
|
+
* assignable while `href` is set — forbidding them needs the two-branch union that caused the bug — but
|
|
30
|
+
* they never reach the DOM, where `<a disabled>` would render as an inert attribute.
|
|
31
|
+
*/
|
|
32
|
+
export type ButtonProps = ButtonCommonProps & ButtonAnchorProps & Omit<ComponentProps<typeof BaseButton>, "className">;
|
|
33
|
+
|
|
34
|
+
const sizeClass: Record<ButtonSize, string> = {
|
|
35
|
+
sm: "h-8 gap-1.5 px-3 text-label-md [&_svg]:size-4",
|
|
36
|
+
md: "h-10 gap-2 px-4 text-label-lg [&_svg]:size-4",
|
|
37
|
+
lg: "h-12 gap-2 px-5 text-label-lg [&_svg]:size-5",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const variantClass: Record<ButtonVariant, string> = {
|
|
41
|
+
primary:
|
|
42
|
+
"bg-interactive-primary text-interactive-on-primary hover:not-data-disabled:bg-interactive-primary-hover active:not-data-disabled:bg-interactive-primary-pressed",
|
|
43
|
+
glass:
|
|
44
|
+
"bg-glass-clear text-text-primary hover:not-data-disabled:bg-glass-clear-hover active:not-data-disabled:bg-glass-clear-pressed",
|
|
45
|
+
solid:
|
|
46
|
+
"border border-border-default bg-bg-surface text-text-primary hover:not-data-disabled:bg-bg-subtle active:not-data-disabled:bg-interactive-secondary-hover",
|
|
47
|
+
danger:
|
|
48
|
+
"bg-interactive-danger text-interactive-on-danger hover:not-data-disabled:bg-interactive-danger-hover active:not-data-disabled:bg-interactive-danger",
|
|
49
|
+
link: "h-auto gap-1.5 rounded-sm px-1 text-label-sm text-text-secondary motion-colors hover:not-data-disabled:text-text-primary aria-[current=page]:text-text-primary aria-[current=true]:text-text-primary [&_svg]:size-3.5",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Figma `Button` (55:339): Primary, Glass, Solid and Danger in three sizes, plus a text-only `link` variant (size-independent). */
|
|
53
|
+
export function Button({
|
|
54
|
+
variant = "primary",
|
|
55
|
+
size = "md",
|
|
56
|
+
icon,
|
|
57
|
+
className,
|
|
58
|
+
children,
|
|
59
|
+
href,
|
|
60
|
+
target,
|
|
61
|
+
rel,
|
|
62
|
+
download,
|
|
63
|
+
...props
|
|
64
|
+
}: ButtonProps) {
|
|
65
|
+
const classes = cn(
|
|
66
|
+
"relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap select-none",
|
|
67
|
+
variant === "link" ? "" : "motion-press rounded-full",
|
|
68
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand data-disabled:cursor-not-allowed data-disabled:opacity-40",
|
|
69
|
+
variant !== "link" && sizeClass[size],
|
|
70
|
+
// Paper's small buttons are 32px tall. Paint the 44px reach without moving the control;
|
|
71
|
+
// the reach is an invisible box, so it never needs clipping.
|
|
72
|
+
variant !== "link" &&
|
|
73
|
+
size !== "lg" &&
|
|
74
|
+
"relative 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-['']",
|
|
75
|
+
variantClass[variant],
|
|
76
|
+
className,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Preflight makes every `svg` a block box, so an icon passed as a child (rather than through `icon`)
|
|
80
|
+
// started a second line and the label clipped. Any element child gets a row at the button's own gap.
|
|
81
|
+
// A plain string or number label keeps the exact single-span markup it had, `text-trim` included.
|
|
82
|
+
// `forEach` + `isValidElement` rather than `toArray().some()`: no array per render.
|
|
83
|
+
let hasElementChild = false;
|
|
84
|
+
Children.forEach(children, (child) => {
|
|
85
|
+
if (isValidElement(child)) hasElementChild = true;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const content = (
|
|
89
|
+
<>
|
|
90
|
+
{variant === "glass" && <LiquidGlass />}
|
|
91
|
+
{icon && <span className="relative flex shrink-0">{icon}</span>}
|
|
92
|
+
<span
|
|
93
|
+
className={cn(
|
|
94
|
+
"relative",
|
|
95
|
+
variant !== "link" && "text-trim",
|
|
96
|
+
hasElementChild && "inline-flex items-center gap-[inherit]",
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</span>
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (href !== undefined) {
|
|
105
|
+
// An anchor takes none of the button-only attributes, and React would write them to the DOM where
|
|
106
|
+
// they do nothing (`<a disabled>` is inert). They stop here. `type` is a button type in this prop
|
|
107
|
+
// type, not an anchor MIME type, so it stops too, as do Base UI's own props.
|
|
108
|
+
const {
|
|
109
|
+
type: _type,
|
|
110
|
+
style,
|
|
111
|
+
render: _render,
|
|
112
|
+
nativeButton: _native,
|
|
113
|
+
focusableWhenDisabled: _focus,
|
|
114
|
+
disabled: _disabled,
|
|
115
|
+
form: _form,
|
|
116
|
+
formAction: _formAction,
|
|
117
|
+
formEncType: _formEncType,
|
|
118
|
+
formMethod: _formMethod,
|
|
119
|
+
formNoValidate: _formNoValidate,
|
|
120
|
+
formTarget: _formTarget,
|
|
121
|
+
name: _name,
|
|
122
|
+
value: _value,
|
|
123
|
+
...anchorProps
|
|
124
|
+
} = props;
|
|
125
|
+
return (
|
|
126
|
+
<a
|
|
127
|
+
href={href}
|
|
128
|
+
target={target}
|
|
129
|
+
rel={rel}
|
|
130
|
+
download={download}
|
|
131
|
+
style={typeof style === "function" ? undefined : style}
|
|
132
|
+
className={classes}
|
|
133
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- what is left is anchor attributes; Base UI types them against `<button>`
|
|
134
|
+
{...(anchorProps as Omit<ComponentProps<"a">, "className" | "href">)}
|
|
135
|
+
>
|
|
136
|
+
{content}
|
|
137
|
+
</a>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<BaseButton className={classes} {...props}>
|
|
143
|
+
{content}
|
|
144
|
+
</BaseButton>
|
|
145
|
+
);
|
|
146
|
+
}
|