@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,410 @@
|
|
|
1
|
+
import { CaretLeft, CaretRight } from "@phosphor-icons/react";
|
|
2
|
+
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import type { ControlSize } from "../lib/size";
|
|
6
|
+
|
|
7
|
+
export type DateRange = { start: Date | null; end: Date | null };
|
|
8
|
+
export type CalendarSize = ControlSize;
|
|
9
|
+
|
|
10
|
+
type CommonProps = {
|
|
11
|
+
/** First visible month; defaults to the selected date or today. */
|
|
12
|
+
defaultMonth?: Date;
|
|
13
|
+
month?: Date;
|
|
14
|
+
onMonthChange?: (month: Date) => void;
|
|
15
|
+
isDateDisabled?: (date: Date) => boolean;
|
|
16
|
+
/** BCP 47 locale for the month title, weekdays and day labels. */
|
|
17
|
+
locale?: string;
|
|
18
|
+
/** 0 = Sunday (the Figma calendar), 1 = Monday. */
|
|
19
|
+
weekStartsOn?: 0 | 1;
|
|
20
|
+
className?: string;
|
|
21
|
+
today?: Date;
|
|
22
|
+
/**
|
|
23
|
+
* `default` is the day picker calendar (60:245): centered title-sm month.
|
|
24
|
+
* `popover` is the `Calendar popover` (88:495): 24px radius, left label-lg
|
|
25
|
+
* month and 32px round arrow buttons on the right.
|
|
26
|
+
*/
|
|
27
|
+
variant?: "default" | "popover";
|
|
28
|
+
/**
|
|
29
|
+
* `solid` (default) is an opaque surface with a default border. `glass` puts it on the veil (`veil-overlay`),
|
|
30
|
+
* the material of the `DateField` popup; `flat` keeps the 80% white fill of the old thick glass
|
|
31
|
+
* without it (Paper Schedule mini calendar).
|
|
32
|
+
*/
|
|
33
|
+
surface?: "glass" | "flat" | "solid";
|
|
34
|
+
/** Days drawn as subtle ink circles in the day cell size, e.g. the week the schedule shows; selection still draws on top. */
|
|
35
|
+
highlight?: Date[];
|
|
36
|
+
/**
|
|
37
|
+
* Day cell size: `sm` 32px, `md` 40px (default, the Figma size), `lg` 48px.
|
|
38
|
+
* Scales the header, padding, weekday row and day text with the cells.
|
|
39
|
+
*/
|
|
40
|
+
size?: CalendarSize;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const containerSizeClass: Record<CalendarSize, { default: string; popover: string }> = {
|
|
44
|
+
// The default calendar fills its parent up to the Figma width (250/314/374px: seven cells plus padding),
|
|
45
|
+
// so it lands on that size on desktop and folds to the column on a narrow screen. `w-full` rather than a
|
|
46
|
+
// fixed `w-*` keeps its min-content at zero, which is what lets a flex parent shrink it. The popover is
|
|
47
|
+
// fixed by design, so it caps against the viewport instead: its positioner is auto-width.
|
|
48
|
+
//
|
|
49
|
+
// Below `sm` the frame gives its padding and cell gaps back to the grid: at a 320px viewport the demo
|
|
50
|
+
// stage leaves 248px, and the desktop 16px padding plus 12px gaps left each day about 21px. The narrower
|
|
51
|
+
// frame and gap keep every day at or above `min-w-7` (28px), which is the smallest a seven-column month
|
|
52
|
+
// can be without dropping under the 24px pointer minimum.
|
|
53
|
+
sm: {
|
|
54
|
+
default: "w-full max-w-62.5 gap-2 p-3 max-sm:gap-1 max-sm:p-2",
|
|
55
|
+
popover: "w-62 max-w-[calc(100vw-2rem)] gap-1.5 p-3",
|
|
56
|
+
},
|
|
57
|
+
md: {
|
|
58
|
+
default: "w-full max-w-78.5 gap-3 p-4 max-sm:gap-1 max-sm:p-2",
|
|
59
|
+
popover: "w-77 max-w-[calc(100vw-2rem)] gap-2 p-3.5",
|
|
60
|
+
},
|
|
61
|
+
lg: {
|
|
62
|
+
default: "w-full max-w-93.5 gap-3.5 p-4.5 max-sm:gap-1 max-sm:p-2",
|
|
63
|
+
popover: "w-92 max-w-[calc(100vw-2rem)] gap-2.5 p-4",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const titleSizeClass: Record<CalendarSize, string> = {
|
|
68
|
+
sm: "text-label-lg",
|
|
69
|
+
md: "text-title-sm",
|
|
70
|
+
lg: "text-title-md",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const popoverTitleSizeClass: Record<CalendarSize, string> = {
|
|
74
|
+
sm: "text-label-md",
|
|
75
|
+
md: "text-label-lg",
|
|
76
|
+
lg: "text-label-lg",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const popoverHeaderSizeClass: Record<CalendarSize, string> = {
|
|
80
|
+
sm: "gap-0.5 pb-0.5 pl-1",
|
|
81
|
+
md: "gap-1 pb-1 pl-1.5",
|
|
82
|
+
lg: "gap-1.5 pb-1.5 pl-2",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const arrowSizeClass: Record<CalendarSize, string> = {
|
|
86
|
+
sm: "size-5.5 sm:size-6 [&_svg]:size-3.5 sm:[&_svg]:size-4",
|
|
87
|
+
md: "size-6 sm:size-7 [&_svg]:size-4 sm:[&_svg]:size-4.5",
|
|
88
|
+
lg: "size-7 sm:size-8 [&_svg]:size-4.5 sm:[&_svg]:size-5",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const popoverArrowSizeClass: Record<CalendarSize, string> = {
|
|
92
|
+
sm: "size-6 sm:size-7 [&_svg]:size-3.5 sm:[&_svg]:size-4",
|
|
93
|
+
md: "size-7 sm:size-8 [&_svg]:size-4 sm:[&_svg]:size-4.5",
|
|
94
|
+
lg: "size-9 sm:size-10 [&_svg]:size-4.5 sm:[&_svg]:size-5",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const weekdayHeaderSizeClass: Record<CalendarSize, string> = {
|
|
98
|
+
sm: "h-5 max-w-8",
|
|
99
|
+
md: "h-6 max-w-10",
|
|
100
|
+
lg: "h-7 max-w-12",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Seven cells share one seventh of the calendar each and cap at the Figma size for their rung (32/40/48px),
|
|
105
|
+
* so the grid reads exactly as designed on desktop and narrows instead of overflowing when the calendar is
|
|
106
|
+
* squeezed. `min-w-0`/`flex-1` keep the calendar's min-content at its padding, which is what lets a parent
|
|
107
|
+
* flex item shrink it at all; `aspect-square` keeps each target square below the cap.
|
|
108
|
+
*/
|
|
109
|
+
const daySizeClass: Record<CalendarSize, string> = {
|
|
110
|
+
sm: "max-w-8",
|
|
111
|
+
md: "max-w-10",
|
|
112
|
+
lg: "max-w-12",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A day cell takes an equal share of the row and never drops below 28px, so a phone grid stays tappable
|
|
117
|
+
* while a desktop grid keeps the Figma size from `daySizeClass` as its ceiling.
|
|
118
|
+
*/
|
|
119
|
+
const cellFluidClass = "min-w-7 flex-1";
|
|
120
|
+
const dayShapeClass = "aspect-square";
|
|
121
|
+
|
|
122
|
+
const dayTextClass: Record<CalendarSize, string> = {
|
|
123
|
+
sm: "text-body-sm",
|
|
124
|
+
md: "text-body-md",
|
|
125
|
+
lg: "text-body-lg",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const dayEmphasisClass: Record<CalendarSize, string> = {
|
|
129
|
+
sm: "text-label-md",
|
|
130
|
+
md: "text-label-lg",
|
|
131
|
+
lg: "text-label-lg",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The today dot sits 4px above the cell's bottom edge, centred at every rung. */
|
|
135
|
+
const todayDotClass = "bottom-1 left-1/2 -translate-x-1/2";
|
|
136
|
+
|
|
137
|
+
const popoverDayTextClass: Record<CalendarSize, string> = {
|
|
138
|
+
sm: "[&_[role=gridcell]]:text-label-sm",
|
|
139
|
+
md: "[&_[role=gridcell]]:text-label-md",
|
|
140
|
+
lg: "[&_[role=gridcell]]:text-label-lg",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type CalendarProps = CommonProps &
|
|
144
|
+
(
|
|
145
|
+
| { mode?: "single"; value?: Date | null; onValueChange?: (value: Date) => void }
|
|
146
|
+
| { mode: "range"; value?: DateRange; onValueChange?: (value: DateRange) => void }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
150
|
+
const sameDay = (a: Date | null | undefined, b: Date | null | undefined) =>
|
|
151
|
+
!!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
152
|
+
const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
|
|
153
|
+
const firstOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
|
|
154
|
+
const addMonths = (date: Date, months: number) => new Date(date.getFullYear(), date.getMonth() + months, 1);
|
|
155
|
+
|
|
156
|
+
function monthGrid(month: Date, weekStartsOn: 0 | 1): Date[] {
|
|
157
|
+
const first = firstOfMonth(month);
|
|
158
|
+
const lead = (first.getDay() - weekStartsOn + 7) % 7;
|
|
159
|
+
const start = addDays(first, -lead);
|
|
160
|
+
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
|
161
|
+
const weeks = Math.ceil((lead + daysInMonth) / 7);
|
|
162
|
+
return Array.from({ length: weeks * 7 }, (_, index) => addDays(start, index));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Figma `Day` (60:125) inside the `Calendar` of the day picker example (60:245). */
|
|
166
|
+
export function Calendar(props: CalendarProps) {
|
|
167
|
+
const {
|
|
168
|
+
defaultMonth,
|
|
169
|
+
month: monthProp,
|
|
170
|
+
onMonthChange,
|
|
171
|
+
isDateDisabled,
|
|
172
|
+
locale,
|
|
173
|
+
weekStartsOn = 0,
|
|
174
|
+
className,
|
|
175
|
+
variant = "default",
|
|
176
|
+
surface = "solid",
|
|
177
|
+
highlight,
|
|
178
|
+
size = "md",
|
|
179
|
+
} = props;
|
|
180
|
+
const [now] = useState(() => new Date());
|
|
181
|
+
const today = startOfDay(props.today ?? now);
|
|
182
|
+
const single = props.mode === "range" ? null : (props.value ?? null);
|
|
183
|
+
const range = props.mode === "range" ? (props.value ?? { start: null, end: null }) : null;
|
|
184
|
+
const anchor = range ? range.start : single;
|
|
185
|
+
const [monthState, setMonthState] = useState(() => firstOfMonth(defaultMonth ?? anchor ?? today));
|
|
186
|
+
const month = monthProp ? firstOfMonth(monthProp) : monthState;
|
|
187
|
+
const [focused, setFocused] = useState<Date>(() => anchor ?? today);
|
|
188
|
+
const grid = useRef<HTMLDivElement>(null);
|
|
189
|
+
const [shownMonth, setShownMonth] = useState(month);
|
|
190
|
+
const [slide, setSlide] = useState<"next" | "prev" | null>(null);
|
|
191
|
+
if (month.getTime() !== shownMonth.getTime()) {
|
|
192
|
+
setSlide(month > shownMonth ? "next" : "prev");
|
|
193
|
+
setShownMonth(month);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const setMonth = (next: Date) => {
|
|
197
|
+
if (!monthProp) setMonthState(next);
|
|
198
|
+
onMonthChange?.(next);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const days = useMemo(() => monthGrid(month, weekStartsOn), [month, weekStartsOn]);
|
|
202
|
+
const title = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
|
|
203
|
+
const weekday = new Intl.DateTimeFormat(locale, { weekday: "narrow" });
|
|
204
|
+
const full = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
|
|
205
|
+
|
|
206
|
+
const isSelected = (date: Date) =>
|
|
207
|
+
range ? sameDay(date, range.start) || sameDay(date, range.end) : sameDay(date, single);
|
|
208
|
+
const inRange = (date: Date) =>
|
|
209
|
+
!!range?.start && !!range.end && date > startOfDay(range.start) && date < startOfDay(range.end);
|
|
210
|
+
|
|
211
|
+
const select = (date: Date) => {
|
|
212
|
+
if (props.mode === "range") {
|
|
213
|
+
const current = props.value ?? { start: null, end: null };
|
|
214
|
+
const next =
|
|
215
|
+
!current.start || current.end || date < current.start
|
|
216
|
+
? { start: date, end: null }
|
|
217
|
+
: { start: current.start, end: date };
|
|
218
|
+
props.onValueChange?.(next);
|
|
219
|
+
} else {
|
|
220
|
+
props.onValueChange?.(date);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const moveFocus = (next: Date) => {
|
|
225
|
+
setFocused(next);
|
|
226
|
+
if (next.getMonth() !== month.getMonth() || next.getFullYear() !== month.getFullYear())
|
|
227
|
+
setMonth(firstOfMonth(next));
|
|
228
|
+
requestAnimationFrame(() => {
|
|
229
|
+
grid.current?.querySelector<HTMLButtonElement>(`[data-date="${next.toDateString()}"]`)?.focus();
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
234
|
+
const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
|
|
235
|
+
const offset = step[event.key];
|
|
236
|
+
if (typeof offset === "number") {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
moveFocus(addDays(focused, offset));
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const focusInMonth = focused.getMonth() === month.getMonth() ? focused : month;
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
className={cn(
|
|
247
|
+
"relative flex flex-col",
|
|
248
|
+
// The default grid scrolls sideways rather than clipping when a viewport is narrower than the
|
|
249
|
+
// 28px cell floor allows; the popover is viewport-capped, so it keeps clipping its corners.
|
|
250
|
+
variant === "popover" ? "overflow-clip" : "overflow-x-auto overflow-y-clip",
|
|
251
|
+
surface === "solid"
|
|
252
|
+
? "border border-border-default bg-bg-surface"
|
|
253
|
+
: surface === "glass"
|
|
254
|
+
? "veil-overlay"
|
|
255
|
+
: "bg-glass-thick",
|
|
256
|
+
variant === "popover"
|
|
257
|
+
? cn("rounded-[24px]", containerSizeClass[size].popover)
|
|
258
|
+
: cn("rounded-xl", containerSizeClass[size].default),
|
|
259
|
+
// Paper renders every popover day on the label scale; the default calendar mixes body and label.
|
|
260
|
+
variant === "popover" && popoverDayTextClass[size],
|
|
261
|
+
className,
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
{variant === "popover" ? (
|
|
265
|
+
<div className={cn("relative flex w-full items-center", popoverHeaderSizeClass[size])}>
|
|
266
|
+
<p aria-live="polite" className={cn("min-w-0 flex-1 text-text-primary", popoverTitleSizeClass[size])}>
|
|
267
|
+
<span key={title} className="animate-fade-in">
|
|
268
|
+
{title}
|
|
269
|
+
</span>
|
|
270
|
+
</p>
|
|
271
|
+
{(
|
|
272
|
+
[
|
|
273
|
+
["Previous month", -1, <CaretLeft key="prev" />],
|
|
274
|
+
["Next month", 1, <CaretRight key="next" />],
|
|
275
|
+
] as const
|
|
276
|
+
).map(([label, step, icon]) => (
|
|
277
|
+
<button
|
|
278
|
+
key={label}
|
|
279
|
+
type="button"
|
|
280
|
+
aria-label={label}
|
|
281
|
+
className={cn(
|
|
282
|
+
"flex motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary hover:bg-overlay-ink-6",
|
|
283
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
|
|
284
|
+
popoverArrowSizeClass[size],
|
|
285
|
+
)}
|
|
286
|
+
onClick={() => setMonth(addMonths(month, step))}
|
|
287
|
+
>
|
|
288
|
+
{icon}
|
|
289
|
+
</button>
|
|
290
|
+
))}
|
|
291
|
+
</div>
|
|
292
|
+
) : (
|
|
293
|
+
<div className="relative flex w-full items-center">
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
aria-label="Previous month"
|
|
297
|
+
className={cn(
|
|
298
|
+
"flex motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary hover:bg-overlay-ink-6 hover:text-text-primary",
|
|
299
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
|
|
300
|
+
arrowSizeClass[size],
|
|
301
|
+
)}
|
|
302
|
+
onClick={() => setMonth(addMonths(month, -1))}
|
|
303
|
+
>
|
|
304
|
+
<CaretLeft />
|
|
305
|
+
</button>
|
|
306
|
+
<p aria-live="polite" className={cn("flex-1 text-center text-text-primary", titleSizeClass[size])}>
|
|
307
|
+
<span key={title} className="animate-fade-in">
|
|
308
|
+
{title}
|
|
309
|
+
</span>
|
|
310
|
+
</p>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
aria-label="Next month"
|
|
314
|
+
className={cn(
|
|
315
|
+
"flex motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary hover:bg-overlay-ink-6 hover:text-text-primary",
|
|
316
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
|
|
317
|
+
arrowSizeClass[size],
|
|
318
|
+
)}
|
|
319
|
+
onClick={() => setMonth(addMonths(month, 1))}
|
|
320
|
+
>
|
|
321
|
+
<CaretRight />
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
<div
|
|
326
|
+
key={month.getTime()}
|
|
327
|
+
ref={grid}
|
|
328
|
+
role="grid"
|
|
329
|
+
aria-label={title}
|
|
330
|
+
className={cn(
|
|
331
|
+
"relative flex flex-col",
|
|
332
|
+
slide === "next" && "animate-slide-shift-next",
|
|
333
|
+
slide === "prev" && "animate-slide-shift-prev",
|
|
334
|
+
)}
|
|
335
|
+
onKeyDown={onKeyDown}
|
|
336
|
+
>
|
|
337
|
+
<div role="row" className="flex">
|
|
338
|
+
{days.slice(0, 7).map((date) => (
|
|
339
|
+
<span
|
|
340
|
+
key={date.getDay()}
|
|
341
|
+
role="columnheader"
|
|
342
|
+
className={cn(
|
|
343
|
+
"flex justify-center text-caption text-text-secondary",
|
|
344
|
+
cellFluidClass,
|
|
345
|
+
weekdayHeaderSizeClass[size],
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
348
|
+
{weekday.format(date)}
|
|
349
|
+
</span>
|
|
350
|
+
))}
|
|
351
|
+
</div>
|
|
352
|
+
{Array.from({ length: days.length / 7 }, (_, week) => (
|
|
353
|
+
<div key={week} role="row" className="flex">
|
|
354
|
+
{days.slice(week * 7, week * 7 + 7).map((date) => {
|
|
355
|
+
const outside = date.getMonth() !== month.getMonth();
|
|
356
|
+
const disabled = isDateDisabled?.(date) ?? false;
|
|
357
|
+
const selected = isSelected(date);
|
|
358
|
+
const between = inRange(date);
|
|
359
|
+
const isToday = sameDay(date, today);
|
|
360
|
+
const marked = !selected && !between && (highlight?.some((day) => sameDay(day, date)) ?? false);
|
|
361
|
+
return (
|
|
362
|
+
<button
|
|
363
|
+
key={date.toDateString()}
|
|
364
|
+
type="button"
|
|
365
|
+
role="gridcell"
|
|
366
|
+
data-date={date.toDateString()}
|
|
367
|
+
aria-selected={selected || between}
|
|
368
|
+
aria-label={full.format(date)}
|
|
369
|
+
aria-current={isToday ? "date" : undefined}
|
|
370
|
+
disabled={disabled}
|
|
371
|
+
tabIndex={sameDay(date, focusInMonth) ? 0 : -1}
|
|
372
|
+
onClick={() => select(date)}
|
|
373
|
+
onFocus={() => setFocused(date)}
|
|
374
|
+
className={cn(
|
|
375
|
+
"relative flex cursor-pointer flex-col items-center justify-center motion-colors",
|
|
376
|
+
cellFluidClass,
|
|
377
|
+
dayShapeClass,
|
|
378
|
+
daySizeClass[size],
|
|
379
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
|
|
380
|
+
between
|
|
381
|
+
? cn("bg-overlay-ink-8 text-text-primary", dayTextClass[size])
|
|
382
|
+
: selected
|
|
383
|
+
? cn("rounded-full bg-interactive-primary text-text-inverse", dayEmphasisClass[size])
|
|
384
|
+
: marked
|
|
385
|
+
? "rounded-full bg-overlay-ink-6"
|
|
386
|
+
: "rounded-full hover:not-disabled:bg-overlay-ink-5",
|
|
387
|
+
!between &&
|
|
388
|
+
!selected &&
|
|
389
|
+
(disabled
|
|
390
|
+
? cn("cursor-not-allowed text-text-disabled", dayTextClass[size])
|
|
391
|
+
: outside
|
|
392
|
+
? cn("text-text-placeholder", dayTextClass[size])
|
|
393
|
+
: isToday
|
|
394
|
+
? cn("text-text-brand", dayEmphasisClass[size])
|
|
395
|
+
: cn("text-text-primary", dayTextClass[size])),
|
|
396
|
+
)}
|
|
397
|
+
>
|
|
398
|
+
{date.getDate()}
|
|
399
|
+
{isToday && !selected && !between && (
|
|
400
|
+
<span aria-hidden className={cn("absolute size-1 rounded-full bg-status-live", todayDotClass)} />
|
|
401
|
+
)}
|
|
402
|
+
</button>
|
|
403
|
+
);
|
|
404
|
+
})}
|
|
405
|
+
</div>
|
|
406
|
+
))}
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
2
|
+
import { CaretRight } from "@phosphor-icons/react";
|
|
3
|
+
import type { ComponentProps, ReactElement, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
7
|
+
|
|
8
|
+
export type CardProps = Omit<ComponentProps<"div">, "className" | "title"> & {
|
|
9
|
+
title?: ReactNode;
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
/** Shows the trailing caret, for cards that open something. */
|
|
12
|
+
interactive?: boolean;
|
|
13
|
+
/** Brand rim for the selected card. */
|
|
14
|
+
selected?: boolean;
|
|
15
|
+
/** Render as another element, such as `<button />` or `<a />`. */
|
|
16
|
+
render?: ReactElement;
|
|
17
|
+
/**
|
|
18
|
+
* `solid` (default) is an opaque surface with a default border. `glass` is the thick glass.
|
|
19
|
+
* `frost` is the Paper home board card: the same glass with the denser frost fill and a 0 8 24 6% drop.
|
|
20
|
+
*/
|
|
21
|
+
surface?: "glass" | "frost" | "solid";
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Figma `Card` (63:310): a 320px thick-glass card, 20px padding and radius, Default / Interactive / Selected. */
|
|
26
|
+
export function Card({
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
interactive,
|
|
30
|
+
selected,
|
|
31
|
+
render,
|
|
32
|
+
surface = "solid",
|
|
33
|
+
className,
|
|
34
|
+
children,
|
|
35
|
+
...props
|
|
36
|
+
}: CardProps) {
|
|
37
|
+
return useRender({
|
|
38
|
+
render: render ?? <div />,
|
|
39
|
+
props: {
|
|
40
|
+
...props,
|
|
41
|
+
"data-selected": selected ? "" : undefined,
|
|
42
|
+
className: cn(
|
|
43
|
+
// `isolate` makes the card its own stacking context so the glass layer can sit at
|
|
44
|
+
// `-z-10`: behind the title, description and children, above the card's own fill.
|
|
45
|
+
// Without it the absolutely positioned layer paints over static body content and
|
|
46
|
+
// every screen has to patch the card's className to lift it. `min-w-0` keeps the
|
|
47
|
+
// grid/flex min-content of the card from widening a narrow parent.
|
|
48
|
+
"relative isolate flex min-w-0 flex-col items-start gap-2 overflow-clip rounded-xl p-5 text-left motion-colors [&>.liquid-layers]:-z-10",
|
|
49
|
+
surface === "solid" ? "border border-border-default bg-bg-surface" : "bg-glass-thick",
|
|
50
|
+
surface === "frost" && "bg-glass-fill-frost shadow-[0_8px_24px_#0a14240f]",
|
|
51
|
+
selected && (surface === "solid" ? "border-border-brand" : "[--glass-edge:var(--color-border-brand)]"),
|
|
52
|
+
interactive &&
|
|
53
|
+
cn(
|
|
54
|
+
"cursor-pointer transition-[color,background-color,border-color,box-shadow,scale] duration-150 ease-out",
|
|
55
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand active:scale-[0.99]",
|
|
56
|
+
!selected &&
|
|
57
|
+
(surface === "solid" ? "hover:border-border-strong" : "hover:[--glass-edge:var(--color-border-strong)]"),
|
|
58
|
+
),
|
|
59
|
+
className,
|
|
60
|
+
),
|
|
61
|
+
children: (
|
|
62
|
+
<>
|
|
63
|
+
{surface !== "solid" && <LiquidGlass />}
|
|
64
|
+
{title && (
|
|
65
|
+
<div className="relative flex w-full items-center gap-2">
|
|
66
|
+
<span className="min-w-0 flex-1 text-title-sm text-text-primary">{title}</span>
|
|
67
|
+
{interactive && <CaretRight aria-hidden className="size-4 shrink-0 text-text-secondary" />}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
{interactive && !title && (
|
|
71
|
+
<CaretRight aria-hidden className="absolute top-5 right-5 size-4 shrink-0 text-text-secondary" />
|
|
72
|
+
)}
|
|
73
|
+
{description && <p className="relative text-body-sm text-text-secondary">{description}</p>}
|
|
74
|
+
{children && <div className="relative w-full">{children}</div>}
|
|
75
|
+
</>
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import type { ControlSize } from "../lib/size";
|
|
5
|
+
|
|
6
|
+
export type ChatBubbleSize = ControlSize;
|
|
7
|
+
|
|
8
|
+
export type ChatBubbleProps = Omit<ComponentProps<"div">, "className"> & {
|
|
9
|
+
/** `me` is the ink bubble, `bot` the flat subtle bubble. Neither uses glass. */
|
|
10
|
+
from?: "me" | "bot";
|
|
11
|
+
/** Bubble size: `sm` 12/8px padding with body-sm, `md` 16/12px with body-md (default), `lg` 20/14px with body-lg. */
|
|
12
|
+
size?: ChatBubbleSize;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const sizeClass: Record<ChatBubbleSize, string> = {
|
|
17
|
+
sm: "px-3 py-2 text-body-sm",
|
|
18
|
+
md: "px-4 py-3 text-body-md",
|
|
19
|
+
lg: "px-5 py-3.5 text-body-lg",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Figma `Chat bubble` (64:359): 16/12px padding, 20px radius, body-md text. */
|
|
23
|
+
export function ChatBubble({ from = "me", size = "md", className, ...props }: ChatBubbleProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-from={from}
|
|
27
|
+
className={cn(
|
|
28
|
+
"w-fit max-w-[min(100%,560px)] motion-rise rounded-xl wrap-break-word",
|
|
29
|
+
sizeClass[size],
|
|
30
|
+
from === "me" ? "ml-auto bg-interactive-primary text-interactive-on-primary" : "bg-bg-subtle text-text-primary",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { CaretDown } from "@phosphor-icons/react";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
6
|
+
|
|
7
|
+
export type ChatControlSurface = "solid" | "glass";
|
|
8
|
+
|
|
9
|
+
type ButtonProps = Omit<ComponentProps<"button">, "className"> & {
|
|
10
|
+
className?: string;
|
|
11
|
+
/** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
|
|
12
|
+
surface?: ChatControlSurface;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const controlBase =
|
|
16
|
+
"motion-press relative flex motion-rise cursor-pointer items-center overflow-clip rounded-full text-text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand";
|
|
17
|
+
|
|
18
|
+
const surfaceClass: Record<ChatControlSurface, string> = {
|
|
19
|
+
glass: "bg-glass-clear hover:bg-glass-clear-hover active:bg-glass-clear-pressed",
|
|
20
|
+
solid: "border border-border-default bg-bg-surface hover:bg-bg-subtle active:bg-interactive-secondary-hover",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Figma `Jump to latest` (Chat controls 88:659): a 44px clear-glass circle with a 20px caret. */
|
|
24
|
+
export function JumpToLatest({
|
|
25
|
+
surface = "solid",
|
|
26
|
+
className,
|
|
27
|
+
"aria-label": ariaLabel = "Jump to latest",
|
|
28
|
+
...props
|
|
29
|
+
}: ButtonProps) {
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
aria-label={ariaLabel}
|
|
34
|
+
className={cn(controlBase, surfaceClass[surface], "size-11 justify-center", className)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
{surface === "glass" && <LiquidGlass />}
|
|
38
|
+
<CaretDown aria-hidden className="relative size-5" />
|
|
39
|
+
</button>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Figma `New messages` (88:659): a 40px clear-glass pill with a 16px caret and a label. */
|
|
44
|
+
export function NewMessagesPill({ surface = "solid", className, children, ...props }: ButtonProps) {
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className={cn(controlBase, surfaceClass[surface], "h-10 gap-1.5 pr-4 pl-3.5 text-label-md", className)}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{surface === "glass" && <LiquidGlass />}
|
|
52
|
+
<CaretDown aria-hidden className="relative size-4" />
|
|
53
|
+
<span className="relative whitespace-nowrap">{children}</span>
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Bar heights of the Figma waveform, in px. */
|
|
59
|
+
const WAVEFORM = [8, 14, 22, 12, 26, 18, 10, 20, 28, 16, 9, 18, 24, 12, 7, 15, 21, 11];
|
|
60
|
+
|
|
61
|
+
export type VoiceInputProps = {
|
|
62
|
+
/** Elapsed time, such as "0:07". */
|
|
63
|
+
elapsed: ReactNode;
|
|
64
|
+
/** 0-1 levels for the 18 bars; defaults to the Figma waveform. */
|
|
65
|
+
levels?: number[];
|
|
66
|
+
/** Animates the waveform while true; set false once recording stops. */
|
|
67
|
+
recording?: boolean;
|
|
68
|
+
onStop?: () => void;
|
|
69
|
+
stopLabel?: string;
|
|
70
|
+
/** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
|
|
71
|
+
surface?: ChatControlSurface;
|
|
72
|
+
className?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Figma `Voice input` (88:659): a 52px pill with a red stop button, waveform and timer. */
|
|
76
|
+
export function VoiceInput({
|
|
77
|
+
elapsed,
|
|
78
|
+
levels,
|
|
79
|
+
recording = true,
|
|
80
|
+
onStop,
|
|
81
|
+
stopLabel = "Stop recording",
|
|
82
|
+
surface = "solid",
|
|
83
|
+
className,
|
|
84
|
+
}: VoiceInputProps) {
|
|
85
|
+
const heights = levels ? levels.map((level) => Math.max(4, Math.round(level * 28))) : WAVEFORM;
|
|
86
|
+
const bars = heights.map((height, position) => ({ height, id: `bar-${position}` }));
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={cn(
|
|
90
|
+
"relative flex h-13 w-60 max-w-full min-w-0 items-center gap-3 overflow-clip rounded-full px-2",
|
|
91
|
+
surface === "glass" ? "bg-glass-regular" : "border border-border-default bg-bg-surface",
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{surface === "glass" && <LiquidGlass />}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-label={stopLabel}
|
|
99
|
+
onClick={onStop}
|
|
100
|
+
className="relative flex size-9 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-interactive-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
|
|
101
|
+
>
|
|
102
|
+
<span className="size-3 rounded-[3px] bg-interactive-on-danger" />
|
|
103
|
+
</button>
|
|
104
|
+
<div aria-hidden className="relative flex min-w-0 flex-1 items-center gap-[3px] overflow-hidden">
|
|
105
|
+
{bars.map((bar, position) => (
|
|
106
|
+
<span
|
|
107
|
+
key={bar.id}
|
|
108
|
+
className={cn("w-[3px] shrink-0 rounded-[2px] bg-interactive-primary-75", recording && "animate-wave")}
|
|
109
|
+
style={{ height: bar.height, animationDelay: recording ? `${position * 120}ms` : undefined }}
|
|
110
|
+
/>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
<span className="relative w-10 shrink-0 text-right text-label-md whitespace-nowrap text-text-secondary tabular-nums">
|
|
114
|
+
{elapsed}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|