@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/package.json +32 -3
- package/src/brand.css +13 -0
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +193 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +135 -0
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/Table.tsx +196 -0
- package/src/components/Tabs.tsx +124 -0
- package/src/components/Tag.tsx +35 -0
- package/src/components/Textarea.tsx +46 -0
- package/src/components/ThemeProvider.tsx +80 -0
- package/src/components/Toast.tsx +159 -0
- package/src/components/Tooltip.tsx +57 -0
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +67 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme.css +11 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as Popover from '@radix-ui/react-popover';
|
|
2
|
+
import { Search, X } from 'lucide-react';
|
|
3
|
+
import { useId, useRef, useState, type KeyboardEvent } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
import { useField } from './Field';
|
|
7
|
+
import { controlFrame, controlInner } from './forms-control';
|
|
8
|
+
import { Avatar, DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
|
|
9
|
+
import { useDebouncedSearch } from './inputs-search';
|
|
10
|
+
import { Spinner } from './Spinner';
|
|
11
|
+
|
|
12
|
+
export type AsyncSelectProps = {
|
|
13
|
+
/** Look up matches; the signal aborts when newer input arrives. */
|
|
14
|
+
search: (query: string, signal: AbortSignal) => Promise<ListItem[]>;
|
|
15
|
+
value?: ListItem | null | undefined;
|
|
16
|
+
onChange?: ((item: ListItem | null) => void) | undefined;
|
|
17
|
+
/** Offer “Create …” for text that matches nothing exactly. */
|
|
18
|
+
onCreate?: ((query: string) => void | ListItem | Promise<ListItem | void>) | undefined;
|
|
19
|
+
/** @default "Search" */
|
|
20
|
+
placeholder?: string | undefined;
|
|
21
|
+
/** ms. @default 250 */
|
|
22
|
+
debounce?: number | undefined;
|
|
23
|
+
labels?: Partial<ListboxLabels> & { clear?: string | undefined };
|
|
24
|
+
invalid?: boolean | undefined;
|
|
25
|
+
disabled?: boolean | undefined;
|
|
26
|
+
/** Start with this search typed and its results open. */
|
|
27
|
+
defaultQuery?: string | undefined;
|
|
28
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
29
|
+
id?: string | undefined;
|
|
30
|
+
'aria-label'?: string | undefined;
|
|
31
|
+
className?: string | undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* AsyncSelect — choose one from a large or remote set (a customer, a
|
|
36
|
+
* project). Typing searches after a short pause and drops stale answers;
|
|
37
|
+
* a spinner shows until the answer comes, then results with their meta, a
|
|
38
|
+
* Create row, “nothing matches”, or an error with Retry. The choice
|
|
39
|
+
* becomes a card that clears with ×.
|
|
40
|
+
*/
|
|
41
|
+
export function AsyncSelect({
|
|
42
|
+
search,
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
onCreate,
|
|
46
|
+
placeholder = 'Search',
|
|
47
|
+
debounce = 250,
|
|
48
|
+
labels: labelsIn,
|
|
49
|
+
invalid,
|
|
50
|
+
disabled,
|
|
51
|
+
defaultQuery = '',
|
|
52
|
+
size,
|
|
53
|
+
id,
|
|
54
|
+
className,
|
|
55
|
+
...aria
|
|
56
|
+
}: AsyncSelectProps) {
|
|
57
|
+
const field = useField();
|
|
58
|
+
const auto = useId();
|
|
59
|
+
const listId = `as${auto}-list`;
|
|
60
|
+
const optionId = (i: number) => `as${auto}-o${i}`;
|
|
61
|
+
const labels = { ...DEFAULT_LABELS, ...labelsIn };
|
|
62
|
+
const [own, setOwn] = useState<ListItem | null>(null);
|
|
63
|
+
const chosen = value === undefined ? own : value;
|
|
64
|
+
const [query, setQuery] = useState(defaultQuery);
|
|
65
|
+
const [open, setOpen] = useState(defaultQuery !== '');
|
|
66
|
+
const [active, setActive] = useState(0);
|
|
67
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
68
|
+
const { status, result, retry } = useDebouncedSearch(query, search, { delay: debounce });
|
|
69
|
+
const items = status === 'done' ? (result ?? []) : [];
|
|
70
|
+
const q = query.trim();
|
|
71
|
+
const canCreate = Boolean(onCreate) && q.length > 0 && !items.some((i) => i.label.toLowerCase() === q.toLowerCase());
|
|
72
|
+
const count = items.length + (canCreate ? 1 : 0);
|
|
73
|
+
const bad = Boolean(invalid || field?.invalid);
|
|
74
|
+
const off = Boolean(disabled || field?.disabled);
|
|
75
|
+
|
|
76
|
+
const pick = (item: ListItem | null) => {
|
|
77
|
+
if (value === undefined) setOwn(item);
|
|
78
|
+
onChange?.(item);
|
|
79
|
+
setQuery('');
|
|
80
|
+
setOpen(false);
|
|
81
|
+
};
|
|
82
|
+
const choose = async (i: number) => {
|
|
83
|
+
if (i < items.length) return pick(items[i]!);
|
|
84
|
+
if (i === items.length && canCreate) {
|
|
85
|
+
const made = await onCreate!(q);
|
|
86
|
+
if (made && typeof made === 'object') pick(made);
|
|
87
|
+
else {
|
|
88
|
+
setQuery('');
|
|
89
|
+
setOpen(false);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
94
|
+
if (e.key === 'ArrowDown') {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
setOpen(true);
|
|
97
|
+
setActive((a) => Math.min(count - 1, a + 1));
|
|
98
|
+
} else if (e.key === 'ArrowUp') {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
setActive((a) => Math.max(0, a - 1));
|
|
101
|
+
} else if (e.key === 'Enter' && open && count > 0) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
void choose(active);
|
|
104
|
+
} else if (e.key === 'Escape') {
|
|
105
|
+
setOpen(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (chosen) {
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
className={cn(
|
|
113
|
+
'flex min-h-[38px] items-center gap-2.5 rounded-md border bg-sunk px-2 py-1.5 font-sans text-ui text-fg',
|
|
114
|
+
bad ? 'border-error' : 'border-rule-soft',
|
|
115
|
+
off && 'bg-well text-fg-secondary',
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
<Avatar item={chosen} className="size-8 rounded-sm bg-ink text-linen ink:bg-yellow-accent ink:text-ink" />
|
|
120
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
121
|
+
<span id={id ?? field?.id} className="truncate font-medium">
|
|
122
|
+
{chosen.label}
|
|
123
|
+
</span>
|
|
124
|
+
{chosen.meta && <span className="truncate text-meta text-fg-secondary">{chosen.meta}</span>}
|
|
125
|
+
</span>
|
|
126
|
+
{!off && (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
aria-label={`${labelsIn?.clear ?? 'Clear'} ${chosen.label}`}
|
|
130
|
+
onClick={() => {
|
|
131
|
+
pick(null);
|
|
132
|
+
requestAnimationFrame(() => input.current?.focus());
|
|
133
|
+
}}
|
|
134
|
+
className="inline-grid shrink-0 cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
|
|
135
|
+
>
|
|
136
|
+
<X size={13} aria-hidden />
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const showList = open && q.length > 0;
|
|
144
|
+
return (
|
|
145
|
+
<Popover.Root open={showList} onOpenChange={setOpen}>
|
|
146
|
+
<Popover.Anchor asChild>
|
|
147
|
+
<div className={cn(controlFrame({ size, invalid: bad, disabled: off }), className)}>
|
|
148
|
+
<Search size={14} aria-hidden className="shrink-0 text-fg-secondary" />
|
|
149
|
+
<input
|
|
150
|
+
ref={input}
|
|
151
|
+
id={id ?? field?.id}
|
|
152
|
+
role="combobox"
|
|
153
|
+
aria-expanded={showList}
|
|
154
|
+
aria-controls={showList ? listId : undefined}
|
|
155
|
+
aria-autocomplete="list"
|
|
156
|
+
aria-activedescendant={showList && status === 'done' && count > 0 ? optionId(active) : undefined}
|
|
157
|
+
aria-invalid={bad || undefined}
|
|
158
|
+
aria-describedby={field?.describedBy}
|
|
159
|
+
aria-label={aria['aria-label']}
|
|
160
|
+
disabled={off}
|
|
161
|
+
value={query}
|
|
162
|
+
placeholder={placeholder}
|
|
163
|
+
autoComplete="off"
|
|
164
|
+
onChange={(e) => {
|
|
165
|
+
setQuery(e.target.value);
|
|
166
|
+
setActive(0);
|
|
167
|
+
setOpen(true);
|
|
168
|
+
}}
|
|
169
|
+
onFocus={() => q && setOpen(true)}
|
|
170
|
+
onKeyDown={onKey}
|
|
171
|
+
className={controlInner}
|
|
172
|
+
/>
|
|
173
|
+
{status === 'searching' && <Spinner size={12} className="text-fg-secondary" />}
|
|
174
|
+
</div>
|
|
175
|
+
</Popover.Anchor>
|
|
176
|
+
<Popover.Portal>
|
|
177
|
+
<Popover.Content
|
|
178
|
+
align="start"
|
|
179
|
+
sideOffset={6}
|
|
180
|
+
collisionPadding={12}
|
|
181
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
182
|
+
onInteractOutside={(e) => {
|
|
183
|
+
if (e.target instanceof Node && input.current?.parentElement?.contains(e.target)) e.preventDefault();
|
|
184
|
+
}}
|
|
185
|
+
className="rise z-60 rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
|
|
186
|
+
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
187
|
+
>
|
|
188
|
+
<ListboxBody
|
|
189
|
+
id={listId}
|
|
190
|
+
optionId={optionId}
|
|
191
|
+
items={items}
|
|
192
|
+
active={active}
|
|
193
|
+
status={status}
|
|
194
|
+
query={q}
|
|
195
|
+
canCreate={canCreate}
|
|
196
|
+
labels={labels}
|
|
197
|
+
onActive={setActive}
|
|
198
|
+
onChoose={(i) => void choose(i)}
|
|
199
|
+
onRetry={retry}
|
|
200
|
+
/>
|
|
201
|
+
</Popover.Content>
|
|
202
|
+
</Popover.Portal>
|
|
203
|
+
</Popover.Root>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type BadgeStatus = 'neutral' | 'attention' | 'error' | 'progress' | 'complete';
|
|
6
|
+
export type BadgeAppearance = 'fill' | 'outline' | 'dot';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_APPEARANCE: Record<BadgeStatus, BadgeAppearance> = {
|
|
9
|
+
attention: 'fill',
|
|
10
|
+
error: 'fill',
|
|
11
|
+
progress: 'outline',
|
|
12
|
+
complete: 'dot',
|
|
13
|
+
neutral: 'dot',
|
|
14
|
+
};
|
|
15
|
+
const STATUSES = new Set<string>(Object.keys(DEFAULT_APPEARANCE));
|
|
16
|
+
|
|
17
|
+
export type BadgeProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
18
|
+
/** The state. Unknown values fall back to neutral. @default "neutral" */
|
|
19
|
+
status?: BadgeStatus | (string & {}) | undefined;
|
|
20
|
+
/** fill · outline · dot. Defaults by status; change it only to match density in a column. */
|
|
21
|
+
appearance?: BadgeAppearance | undefined;
|
|
22
|
+
/** Dot density for rows: only the dot shows; the wording stays for screen readers and appears on hover. */
|
|
23
|
+
compact?: boolean | undefined;
|
|
24
|
+
/** The wording — always present: name the state (Awaiting you), not the action. */
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Badge — product status in yellow, ink and one red. Shape carries urgency:
|
|
30
|
+
* a fill needs a person (attention: brand fill, ink text; error: the red),
|
|
31
|
+
* an outline is under way (progress), a dot is a known state (complete:
|
|
32
|
+
* ink dot, neutral: grey dot). Colour is never the only signal.
|
|
33
|
+
* Product UI only — never on brand, marketing or decks.
|
|
34
|
+
*/
|
|
35
|
+
export function Badge({ status = 'neutral', appearance, compact = false, className, children, title, ...rest }: BadgeProps) {
|
|
36
|
+
const s = (STATUSES.has(status) ? status : 'neutral') as BadgeStatus;
|
|
37
|
+
const mode = compact ? 'dot' : (appearance ?? DEFAULT_APPEARANCE[s]);
|
|
38
|
+
const base = 'inline-flex items-center gap-[7px] whitespace-nowrap rounded-sm text-meta leading-none font-semibold';
|
|
39
|
+
|
|
40
|
+
if (mode === 'fill') {
|
|
41
|
+
const fill = s === 'error' ? 'bg-error text-white' : s === 'attention' ? 'bg-brand-mark text-ink' : 'bg-fg text-page';
|
|
42
|
+
return (
|
|
43
|
+
<span data-status={s} className={cn(base, 'px-2 py-[5px]', fill, className)} title={title} {...rest}>
|
|
44
|
+
{children}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (mode === 'outline') {
|
|
49
|
+
return (
|
|
50
|
+
<span data-status={s} className={cn(base, 'border border-fg px-[7px] py-1 text-fg', className)} title={title} {...rest}>
|
|
51
|
+
{children}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const dot =
|
|
56
|
+
s === 'error' ? 'bg-error' : s === 'attention' ? 'bg-brand-mark' : s === 'complete' || s === 'progress' ? 'bg-fg' : 'bg-fg-secondary';
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
data-status={s}
|
|
60
|
+
className={cn(base, 'text-fg-secondary', className)}
|
|
61
|
+
title={compact ? (title ?? (typeof children === 'string' ? children : undefined)) : title}
|
|
62
|
+
{...rest}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden className={cn('size-[9px] shrink-0 rounded-pill', dot)} />
|
|
65
|
+
<span className={compact ? 'sr-only' : undefined}>{children}</span>
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export type BreadcrumbItem = {
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** A link; or use onSelect for in-app navigation. */
|
|
9
|
+
href?: string | undefined;
|
|
10
|
+
onSelect?: (() => void) | undefined;
|
|
11
|
+
/** A trailing tag on the current page: its format (PDF). */
|
|
12
|
+
tag?: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BreadcrumbProps = Omit<ComponentProps<'nav'>, 'children'> & {
|
|
16
|
+
items: BreadcrumbItem[];
|
|
17
|
+
/** What starts the trail — the workspace brand (on a customer domain it replaces the platform name). */
|
|
18
|
+
root?: ReactNode;
|
|
19
|
+
/** Beyond this many segments the middle folds into a menu. @default 4 */
|
|
20
|
+
max?: number | undefined;
|
|
21
|
+
/** @default "Breadcrumb" */
|
|
22
|
+
label?: string | undefined;
|
|
23
|
+
/** Accessible name of the fold button. @default "Show hidden folders" */
|
|
24
|
+
foldLabel?: string | undefined;
|
|
25
|
+
/** A line under the trail: the current item's status. */
|
|
26
|
+
status?: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const linkClass = 'cursor-pointer text-fg-secondary outline-none hover:text-fg hover:underline underline-offset-2 focus-visible:shadow-(--focus-ring) rounded-xs';
|
|
30
|
+
|
|
31
|
+
function Crumb({ item }: { item: BreadcrumbItem }) {
|
|
32
|
+
if (item.href)
|
|
33
|
+
return (
|
|
34
|
+
<a href={item.href} onClick={item.onSelect} className={linkClass}>
|
|
35
|
+
{item.label}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
if (item.onSelect)
|
|
39
|
+
return (
|
|
40
|
+
<button type="button" onClick={item.onSelect} className={linkClass}>
|
|
41
|
+
{item.label}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
return <span className="text-fg-secondary">{item.label}</span>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const Sep = () => (
|
|
48
|
+
<span aria-hidden className="text-fg-secondary">
|
|
49
|
+
/
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Breadcrumb — where this page sits. The current page is the last segment
|
|
55
|
+
* and not a link; beyond four segments the middle folds into a menu. On a
|
|
56
|
+
* customer domain the trail starts at the workspace brand (root).
|
|
57
|
+
*/
|
|
58
|
+
export function Breadcrumb({ items, root, max = 4, label = 'Breadcrumb', foldLabel = 'Show hidden folders', status, className, ...rest }: BreadcrumbProps) {
|
|
59
|
+
const last = items[items.length - 1];
|
|
60
|
+
const before = items.slice(0, -1);
|
|
61
|
+
const total = items.length + (root ? 1 : 0);
|
|
62
|
+
const fold = total > max && before.length > 1;
|
|
63
|
+
// Folded: [root or the first ancestor] / … / parent / current.
|
|
64
|
+
const head = fold ? (root ? [] : before.slice(0, 1)) : before;
|
|
65
|
+
const folded = fold ? before.slice(root ? 0 : 1, -1) : [];
|
|
66
|
+
const tail = fold ? before.slice(-1) : [];
|
|
67
|
+
const segment = 'flex items-center gap-2';
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<nav aria-label={label} className={cn('flex flex-col gap-1.5', className)} {...rest}>
|
|
71
|
+
<ol className="m-0 flex list-none flex-wrap items-center gap-x-2 gap-y-1 p-0 text-ui">
|
|
72
|
+
{root && (
|
|
73
|
+
<li className={segment}>
|
|
74
|
+
{root}
|
|
75
|
+
{items.length > 0 && <Sep />}
|
|
76
|
+
</li>
|
|
77
|
+
)}
|
|
78
|
+
{head.map((it, i) => (
|
|
79
|
+
<li key={`h${i}`} className={segment}>
|
|
80
|
+
<Crumb item={it} />
|
|
81
|
+
<Sep />
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
{folded.length > 0 && (
|
|
85
|
+
<li className={segment}>
|
|
86
|
+
<DropdownMenu.Root>
|
|
87
|
+
<DropdownMenu.Trigger
|
|
88
|
+
aria-label={foldLabel}
|
|
89
|
+
className="inline-grid h-6 min-w-6 cursor-pointer place-items-center rounded-sm bg-sunk px-1.5 text-meta font-semibold text-fg outline-none hover:bg-well focus-visible:shadow-(--focus-ring)"
|
|
90
|
+
>
|
|
91
|
+
…
|
|
92
|
+
</DropdownMenu.Trigger>
|
|
93
|
+
<DropdownMenu.Portal>
|
|
94
|
+
<DropdownMenu.Content align="start" sideOffset={6} className="z-50 min-w-[180px] rounded-lg border border-rule bg-page p-1.5 text-ui text-fg">
|
|
95
|
+
{folded.map((it, i) => (
|
|
96
|
+
<DropdownMenu.Item
|
|
97
|
+
key={i}
|
|
98
|
+
onSelect={() => {
|
|
99
|
+
if (it.href) window.location.assign(it.href);
|
|
100
|
+
else it.onSelect?.();
|
|
101
|
+
}}
|
|
102
|
+
className="flex h-8 cursor-pointer items-center rounded-md px-2.5 outline-none data-[highlighted]:bg-hover"
|
|
103
|
+
>
|
|
104
|
+
{it.label}
|
|
105
|
+
</DropdownMenu.Item>
|
|
106
|
+
))}
|
|
107
|
+
</DropdownMenu.Content>
|
|
108
|
+
</DropdownMenu.Portal>
|
|
109
|
+
</DropdownMenu.Root>
|
|
110
|
+
<Sep />
|
|
111
|
+
</li>
|
|
112
|
+
)}
|
|
113
|
+
{tail.map((it, i) => (
|
|
114
|
+
<li key={`t${i}`} className={segment}>
|
|
115
|
+
<Crumb item={it} />
|
|
116
|
+
<Sep />
|
|
117
|
+
</li>
|
|
118
|
+
))}
|
|
119
|
+
{last && (
|
|
120
|
+
<li aria-current="page" className="flex items-center gap-2 font-medium text-fg">
|
|
121
|
+
{last.label}
|
|
122
|
+
{last.tag && <span className="rounded-sm bg-sunk px-1.5 py-0.5 text-[11px] font-semibold text-fg">{last.tag}</span>}
|
|
123
|
+
</li>
|
|
124
|
+
)}
|
|
125
|
+
</ol>
|
|
126
|
+
{status && <div className="text-meta text-fg-secondary">{status}</div>}
|
|
127
|
+
</nav>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn';
|
|
7
|
+
import { Spinner } from './Spinner';
|
|
8
|
+
|
|
9
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'yellow' | 'ink' | 'danger';
|
|
10
|
+
export type ButtonGround = 'paper' | 'yellow' | 'ink';
|
|
11
|
+
/** idle → busy (spinner, width locked, clicks ignored) → done (confirmed in place) → idle. */
|
|
12
|
+
export type ButtonState = 'idle' | 'busy' | 'done';
|
|
13
|
+
|
|
14
|
+
const button = cva(
|
|
15
|
+
[
|
|
16
|
+
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap select-none',
|
|
17
|
+
'rounded-md border border-transparent font-sans font-semibold leading-none',
|
|
18
|
+
'transition-[background-color,border-color,color] duration-[120ms] ease-standard',
|
|
19
|
+
'cursor-pointer outline-none focus-visible:shadow-(--focus-ring)',
|
|
20
|
+
// Disabled sinks: the well with grey text (3.0 readme and design.cosx.co;
|
|
21
|
+
// the 3.0 Button source used opacity). "!" so hover never lifts it back.
|
|
22
|
+
'disabled:cursor-not-allowed aria-disabled:cursor-not-allowed',
|
|
23
|
+
],
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
size: {
|
|
27
|
+
sm: 'h-8 px-3.5 text-[13px]',
|
|
28
|
+
md: 'h-[38px] px-[18px] text-ui',
|
|
29
|
+
lg: 'h-11 px-6 text-[16px]',
|
|
30
|
+
},
|
|
31
|
+
variant: { primary: '', secondary: 'bg-transparent', ghost: 'bg-transparent', yellow: 'bg-yellow text-ink hover:bg-yellow-hover', ink: 'bg-ink text-linen hover:bg-ink-raised ink:border-inv-rule ink:bg-ink-raised ink:hover:border-linen', danger: 'bg-error text-white hover:bg-error-text' },
|
|
32
|
+
ground: { auto: '', paper: '', yellow: '', ink: '' },
|
|
33
|
+
},
|
|
34
|
+
compoundVariants: [
|
|
35
|
+
// Primary: ink on paper and on the yellow; the yellow on ink (and in ink mode).
|
|
36
|
+
{ variant: 'primary', ground: 'auto', class: 'bg-ink text-linen hover:bg-ink-raised ink:bg-yellow-accent ink:text-ink ink:hover:bg-yellow-accent-hover' },
|
|
37
|
+
{ variant: 'primary', ground: ['paper', 'yellow'], class: 'bg-ink text-linen hover:bg-ink-raised' },
|
|
38
|
+
{ variant: 'primary', ground: 'ink', class: 'bg-yellow-accent text-ink hover:bg-yellow-accent-hover' },
|
|
39
|
+
// Secondary: a hairline that turns ink on hover.
|
|
40
|
+
{ variant: 'secondary', ground: 'auto', class: 'text-fg border-[rgba(17,17,17,.18)] hover:border-fg ink:border-rule' },
|
|
41
|
+
{ variant: 'secondary', ground: ['paper', 'yellow'], class: 'text-ink border-[rgba(17,17,17,.18)] hover:border-ink' },
|
|
42
|
+
{ variant: 'secondary', ground: 'ink', class: 'text-linen border-inv-rule hover:border-linen' },
|
|
43
|
+
// Ghost (subtle): text only; hover deepens one step.
|
|
44
|
+
{ variant: 'ghost', ground: 'auto', class: 'text-fg hover:bg-hover' },
|
|
45
|
+
{ variant: 'ghost', ground: ['paper', 'yellow'], class: 'text-ink hover:bg-[rgba(17,17,17,.05)]' },
|
|
46
|
+
{ variant: 'ghost', ground: 'ink', class: 'text-linen hover:bg-ink-raised' },
|
|
47
|
+
// Focus ring follows the ground.
|
|
48
|
+
{ ground: 'yellow', class: 'focus-visible:shadow-[0_0_0_3px_var(--yellow),0_0_0_5px_var(--ink)]' },
|
|
49
|
+
{ ground: 'ink', class: 'focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]' },
|
|
50
|
+
// Disabled: filled types sink into the well; outlined and text-only turn grey.
|
|
51
|
+
{
|
|
52
|
+
variant: ['primary', 'yellow', 'ink', 'danger'],
|
|
53
|
+
class:
|
|
54
|
+
'disabled:border-transparent! disabled:bg-well! disabled:text-fg-secondary! aria-disabled:border-transparent! aria-disabled:bg-well! aria-disabled:text-fg-secondary!',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
variant: ['secondary', 'ghost'],
|
|
58
|
+
class:
|
|
59
|
+
'disabled:bg-transparent! disabled:text-fg-secondary! aria-disabled:bg-transparent! aria-disabled:text-fg-secondary! disabled:hover:border-[rgba(17,17,17,.18)] aria-disabled:hover:border-[rgba(17,17,17,.18)]',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
defaultVariants: { size: 'md', variant: 'primary', ground: 'auto' },
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export type ButtonProps = Omit<ComponentProps<'button'>, 'disabled'> & {
|
|
67
|
+
/** primary (one per view) · secondary · ghost (subtle: cancel, secondary links) · yellow (inline attention) · ink · danger (destructive, inside a confirming dialog). @default "primary" */
|
|
68
|
+
variant?: ButtonVariant | undefined;
|
|
69
|
+
/** Height 32 · 38 · 44 (the product control heights). @default "md" */
|
|
70
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
71
|
+
/** The surface underneath; sets the primary colour and the focus ring. Unset: paper, flipping to ink in ink mode. */
|
|
72
|
+
ground?: ButtonGround | undefined;
|
|
73
|
+
iconLeft?: ReactNode;
|
|
74
|
+
iconRight?: ReactNode;
|
|
75
|
+
/** Shortcut hint at the end of the button (⇧S, Esc). */
|
|
76
|
+
shortcut?: string | undefined;
|
|
77
|
+
/** A count at the end (Comments 12). Hidden at zero, 99+ above 99. */
|
|
78
|
+
count?: number | undefined;
|
|
79
|
+
/** Async state: busy locks the width and shows a spinner; done confirms in place. See useButtonAction. */
|
|
80
|
+
state?: ButtonState | undefined;
|
|
81
|
+
/** Label shown while state="done". @default the children */
|
|
82
|
+
doneLabel?: ReactNode;
|
|
83
|
+
disabled?: boolean | undefined;
|
|
84
|
+
/** Why it is unavailable. Keeps the button focusable (aria-disabled) so the reason can be read and shown. */
|
|
85
|
+
disabledReason?: string | undefined;
|
|
86
|
+
/** Render the child (a link) with the button's look. Async state is not applied. */
|
|
87
|
+
asChild?: boolean | undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function formatCount(n: number): string | null {
|
|
91
|
+
if (!n || n <= 0) return null;
|
|
92
|
+
return n > 99 ? '99+' : String(n);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Button — one button for every action. Contrast is ink's job: the primary
|
|
97
|
+
* is ink on paper and on the yellow, the yellow on ink. One primary per
|
|
98
|
+
* view. Async results stay in the button: busy (width locked, repeat clicks
|
|
99
|
+
* ignored), done (confirmed in place); a failure is explained beside it.
|
|
100
|
+
* Hover deepens one step — no lift, no scale, no shadow.
|
|
101
|
+
*/
|
|
102
|
+
export function Button({
|
|
103
|
+
variant,
|
|
104
|
+
size,
|
|
105
|
+
ground,
|
|
106
|
+
iconLeft,
|
|
107
|
+
iconRight,
|
|
108
|
+
shortcut,
|
|
109
|
+
count,
|
|
110
|
+
state = 'idle',
|
|
111
|
+
doneLabel,
|
|
112
|
+
disabled,
|
|
113
|
+
disabledReason,
|
|
114
|
+
asChild,
|
|
115
|
+
className,
|
|
116
|
+
children,
|
|
117
|
+
onClick,
|
|
118
|
+
style,
|
|
119
|
+
ref,
|
|
120
|
+
...rest
|
|
121
|
+
}: ButtonProps) {
|
|
122
|
+
const inner = useRef<HTMLButtonElement | null>(null);
|
|
123
|
+
const [lockedWidth, setLockedWidth] = useState<number | null>(null);
|
|
124
|
+
|
|
125
|
+
// Lock the idle width so busy / done never resize the button.
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
if (state === 'idle') setLockedWidth(null);
|
|
128
|
+
else if (lockedWidth === null && inner.current) setLockedWidth(inner.current.getBoundingClientRect().width);
|
|
129
|
+
}, [state, lockedWidth]);
|
|
130
|
+
|
|
131
|
+
const classes = cn(button({ variant, size, ground: ground ?? 'auto' }), className);
|
|
132
|
+
if (asChild) {
|
|
133
|
+
return (
|
|
134
|
+
<Slot className={classes} {...(rest as object)}>
|
|
135
|
+
{children}
|
|
136
|
+
</Slot>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const busy = state === 'busy';
|
|
141
|
+
const blocked = Boolean(disabledReason) && disabled;
|
|
142
|
+
const shownCount = count === undefined ? null : formatCount(count);
|
|
143
|
+
const setRefs = (el: HTMLButtonElement | null) => {
|
|
144
|
+
inner.current = el;
|
|
145
|
+
if (typeof ref === 'function') ref(el);
|
|
146
|
+
else if (ref) ref.current = el;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
{...rest}
|
|
153
|
+
ref={setRefs}
|
|
154
|
+
className={classes}
|
|
155
|
+
style={lockedWidth ? { ...style, width: lockedWidth } : style}
|
|
156
|
+
disabled={disabled && !disabledReason ? true : undefined}
|
|
157
|
+
// Busy is not disabled (spec: "busy and disabled are different states"):
|
|
158
|
+
// it keeps its look; aria-busy says so and repeat clicks are ignored below.
|
|
159
|
+
aria-disabled={blocked ? true : undefined}
|
|
160
|
+
aria-busy={busy || undefined}
|
|
161
|
+
title={blocked ? disabledReason : rest.title}
|
|
162
|
+
onClick={(e) => {
|
|
163
|
+
if (blocked || busy || state === 'done') {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
onClick?.(e);
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{busy ? (
|
|
171
|
+
<Spinner />
|
|
172
|
+
) : state === 'done' ? (
|
|
173
|
+
<>
|
|
174
|
+
<Check size={14} strokeWidth={2} aria-hidden />
|
|
175
|
+
{doneLabel ?? children}
|
|
176
|
+
</>
|
|
177
|
+
) : (
|
|
178
|
+
<>
|
|
179
|
+
{iconLeft}
|
|
180
|
+
{children}
|
|
181
|
+
{iconRight}
|
|
182
|
+
{shownCount && <span className="font-medium tabular-nums opacity-70">{shownCount}</span>}
|
|
183
|
+
{shortcut && (
|
|
184
|
+
<kbd className="ml-0.5 font-sans text-[12px] font-medium opacity-60" aria-hidden>
|
|
185
|
+
{shortcut}
|
|
186
|
+
</kbd>
|
|
187
|
+
)}
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
{busy && <span className="sr-only">{children}</span>}
|
|
191
|
+
</button>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva } from 'class-variance-authority';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
|
|
7
|
+
const card = cva('overflow-hidden transition-[background-color,border-color] duration-[120ms] ease-standard', {
|
|
8
|
+
variants: {
|
|
9
|
+
ground: {
|
|
10
|
+
page: 'bg-page text-fg',
|
|
11
|
+
sunk: 'bg-sunk text-fg',
|
|
12
|
+
field: 'bg-brand-field text-ink',
|
|
13
|
+
ink: 'bg-ink text-linen ink:bg-ink-raised',
|
|
14
|
+
},
|
|
15
|
+
radius: { panel: 'rounded-lg', section: 'rounded-xl' },
|
|
16
|
+
outline: { true: 'border border-rule', false: '' },
|
|
17
|
+
interactive: { true: 'cursor-pointer outline-none hover:border-fg focus-visible:shadow-(--focus-ring)', false: '' },
|
|
18
|
+
accent: { true: 'border-t-4 border-t-yellow-accent', false: '' },
|
|
19
|
+
},
|
|
20
|
+
compoundVariants: [{ interactive: true, ground: 'page', class: 'hover:bg-hover' }],
|
|
21
|
+
defaultVariants: { ground: 'page', radius: 'panel', interactive: false, accent: false },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type CardProps = ComponentProps<'div'> & {
|
|
25
|
+
/** page (paper) · sunk (linen) · field (the brand yellow) · ink. @default "page" */
|
|
26
|
+
ground?: 'page' | 'sunk' | 'field' | 'ink' | undefined;
|
|
27
|
+
/** panel 16px · section 24px (whole page-level sections). @default "panel" */
|
|
28
|
+
radius?: 'panel' | 'section' | undefined;
|
|
29
|
+
/** Hairline edge. Defaults to on for page, off for the other grounds. */
|
|
30
|
+
outline?: boolean | undefined;
|
|
31
|
+
/** Clickable card: hover wash + ink edge. Use asChild to make it the link or button. */
|
|
32
|
+
interactive?: boolean | undefined;
|
|
33
|
+
/** A 4px accent rule along the top. */
|
|
34
|
+
accent?: boolean | undefined;
|
|
35
|
+
asChild?: boolean | undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Card — a rounded region of the page. No shadow: depth is cut into the page. */
|
|
39
|
+
export function Card({ ground = 'page', radius, outline, interactive, accent, asChild, className, ...rest }: CardProps) {
|
|
40
|
+
const Comp = asChild ? Slot : 'div';
|
|
41
|
+
return <Comp className={cn(card({ ground, radius, outline: outline ?? ground === 'page', interactive, accent }), className)} {...rest} />;
|
|
42
|
+
}
|