@cosxai/ui 0.25.0 → 1.0.0-alpha.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/CHANGELOG.md +597 -0
- package/NOTICE +11 -0
- package/README.md +52 -0
- package/package.json +57 -7
- package/src/brand.css +13 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Button.tsx +178 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/Dialog.tsx +111 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -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/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/Switch.tsx +67 -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/core.test.tsx +242 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/forms.test.tsx +280 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/overlays.test.tsx +245 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/fonts.css +20 -0
- package/src/index.ts +34 -24
- package/src/lib/cn.ts +23 -6
- package/src/modes.css +10 -0
- package/src/styles.css +19 -0
- package/src/theme.css +143 -0
- package/src/tokens/colors.css +112 -0
- package/src/tokens/compat.css +36 -0
- package/src/tokens/elevation.css +14 -0
- package/src/tokens/motion.css +42 -0
- package/src/tokens/spacing.css +55 -0
- package/src/tokens/typography-cjk.css +42 -0
- package/src/tokens/typography.css +81 -0
- package/src/actionbar/ActionBar.tsx +0 -837
- package/src/actionbar/ActionBarButton.tsx +0 -111
- package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
- package/src/actionbar/ActionBarModeHandle.tsx +0 -171
- package/src/actionbar/ActionBarProvider.tsx +0 -91
- package/src/actionbar/actionbar-context.ts +0 -45
- package/src/actionbar/index.ts +0 -28
- package/src/actionbar/types.ts +0 -147
- package/src/actionbar/useActionBarItems.ts +0 -60
- package/src/actionbar/useActionBarMode.ts +0 -144
- package/src/actionbar/useActionBarPanel.ts +0 -56
- package/src/actionbar/useActionBarStatusDot.ts +0 -49
- package/src/actionbar/useActionBarToast.ts +0 -33
- package/src/ambient/AmbientBackdrop.tsx +0 -74
- package/src/ambient/CommandInput.tsx +0 -107
- package/src/ambient/SuperbarStrip.tsx +0 -36
- package/src/ambient/index.ts +0 -6
- package/src/bento/BentoCell.tsx +0 -66
- package/src/bento/BentoGrid.tsx +0 -42
- package/src/bento/index.ts +0 -2
- package/src/command/CommandPalette.tsx +0 -329
- package/src/command/CommandProvider.tsx +0 -57
- package/src/command/command-context.ts +0 -12
- package/src/command/index.ts +0 -6
- package/src/command/rank.ts +0 -50
- package/src/command/types.ts +0 -37
- package/src/command/useCommandSource.ts +0 -37
- package/src/dialogs/DialogsProvider.tsx +0 -216
- package/src/dialogs/Modal.tsx +0 -260
- package/src/dialogs/Toast.tsx +0 -85
- package/src/dialogs/dialogs-context.ts +0 -6
- package/src/dialogs/index.ts +0 -10
- package/src/dialogs/types.ts +0 -37
- package/src/dialogs/useDialogs.ts +0 -8
- package/src/editorial/EditorialSpotlight.tsx +0 -63
- package/src/editorial/Folio.tsx +0 -52
- package/src/editorial/PlateMarker.tsx +0 -33
- package/src/editorial/RomanSection.tsx +0 -65
- package/src/editorial/RunningMarginalia.tsx +0 -65
- package/src/editorial/index.ts +0 -10
- package/src/hooks/index.ts +0 -5
- package/src/hooks/useKeyboardHotkey.ts +0 -93
- package/src/hooks/useReducedMotion.ts +0 -20
- package/src/hooks/useViewport.ts +0 -61
- package/src/layout/Breadcrumb.tsx +0 -74
- package/src/layout/LeftNavRail.tsx +0 -139
- package/src/layout/MobileTabBar.tsx +0 -101
- package/src/layout/NavItem.tsx +0 -128
- package/src/layout/NavSearchTrigger.tsx +0 -88
- package/src/layout/NavSection.tsx +0 -40
- package/src/layout/RightSidebarPanel.tsx +0 -111
- package/src/layout/Shell.tsx +0 -91
- package/src/layout/SidePanel.tsx +0 -316
- package/src/layout/StickyBanner.tsx +0 -83
- package/src/layout/Topbar.tsx +0 -68
- package/src/layout/index.ts +0 -24
- package/src/layout/useNavRailState.ts +0 -69
- package/src/lib/time-utils.ts +0 -44
- package/src/neobrutalism/Marquee.tsx +0 -81
- package/src/neobrutalism/Sticker.tsx +0 -71
- package/src/neobrutalism/index.ts +0 -4
- package/src/primitives/Avatar.tsx +0 -53
- package/src/primitives/Button.tsx +0 -67
- package/src/primitives/Card.tsx +0 -41
- package/src/primitives/Checkbox.tsx +0 -87
- package/src/primitives/Chip.tsx +0 -191
- package/src/primitives/Combobox.tsx +0 -507
- package/src/primitives/CopyField.tsx +0 -139
- package/src/primitives/CountBadge.tsx +0 -50
- package/src/primitives/Input.tsx +0 -204
- package/src/primitives/Kbd.tsx +0 -45
- package/src/primitives/MentionCombobox.tsx +0 -612
- package/src/primitives/MultiCombobox.tsx +0 -327
- package/src/primitives/PageHeader.tsx +0 -77
- package/src/primitives/Radio.tsx +0 -131
- package/src/primitives/SegmentedControl.tsx +0 -135
- package/src/primitives/Select.tsx +0 -779
- package/src/primitives/SignaturePad.tsx +0 -461
- package/src/primitives/Tag.tsx +0 -56
- package/src/primitives/Textarea.tsx +0 -62
- package/src/primitives/ToggleSwitch.tsx +0 -79
- package/src/primitives/Tooltip.tsx +0 -213
- package/src/primitives/index.ts +0 -53
- package/src/pwa/InstallPromptBanner.tsx +0 -132
- package/src/pwa/index.ts +0 -4
- package/src/pwa/manifest.template.json +0 -20
- package/src/pwa/registerSW.ts +0 -55
- package/src/riso/Halftone.tsx +0 -85
- package/src/riso/Misregister.tsx +0 -63
- package/src/riso/RisoStamp.tsx +0 -76
- package/src/riso/index.ts +0 -3
- package/src/sketch/HandUnderline.tsx +0 -53
- package/src/sketch/RoughArrow.tsx +0 -91
- package/src/sketch/RoughBox.tsx +0 -73
- package/src/sketch/StickyNote.tsx +0 -56
- package/src/sketch/index.ts +0 -4
- package/src/styles/base.css +0 -186
- package/src/styles/chrome-ambient.css +0 -235
- package/src/styles/chrome-bento.css +0 -192
- package/src/styles/chrome-editorial.css +0 -168
- package/src/styles/chrome-neobrutalism.css +0 -326
- package/src/styles/chrome-riso.css +0 -339
- package/src/styles/chrome-sketch.css +0 -362
- package/src/styles/chrome-swiss.css +0 -265
- package/src/styles/chrome-terminal.css +0 -272
- package/src/styles/fonts.css +0 -1034
- package/src/styles/index.css +0 -343
- package/src/styles/tokens.css +0 -941
- package/src/terminal/AsciiBox.tsx +0 -65
- package/src/terminal/BrailleSpinner.tsx +0 -46
- package/src/terminal/index.ts +0 -4
- package/src/theme/ThemeProvider.tsx +0 -99
- package/src/theme/index.ts +0 -6
- package/src/theme/inline-script.ts +0 -40
- package/src/theme/theme-context.ts +0 -7
- package/src/theme/types.ts +0 -39
- package/src/theme/useTheme.ts +0 -8
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import * as Popover from '@radix-ui/react-popover';
|
|
2
|
+
import { Check, ChevronDown, Search, X } from 'lucide-react';
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
import { useField } from './Field';
|
|
7
|
+
import { controlFrame } from './forms-control';
|
|
8
|
+
|
|
9
|
+
export type SelectOption = {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** Right-aligned secondary text: a count, a date — or, on a disabled row, the reason. */
|
|
13
|
+
meta?: string | undefined;
|
|
14
|
+
/** Rows sharing a group label are headed by it, in source order. */
|
|
15
|
+
group?: string | undefined;
|
|
16
|
+
disabled?: boolean | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SelectProps = {
|
|
20
|
+
options: Array<string | SelectOption>;
|
|
21
|
+
value?: string | undefined;
|
|
22
|
+
onChange?: ((value: string) => void) | undefined;
|
|
23
|
+
/** @default "Choose one" */
|
|
24
|
+
placeholder?: string | undefined;
|
|
25
|
+
/** A filter field at the top of the menu. Defaults to on above 8 options. */
|
|
26
|
+
searchable?: boolean | undefined;
|
|
27
|
+
/** @default "Nothing matches that." */
|
|
28
|
+
emptyText?: string | undefined;
|
|
29
|
+
/** Placeholder of the filter field. @default "Filter" */
|
|
30
|
+
filterPlaceholder?: string | undefined;
|
|
31
|
+
invalid?: boolean | undefined;
|
|
32
|
+
disabled?: boolean | undefined;
|
|
33
|
+
/** 32 · 38 · 44. @default "md" */
|
|
34
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
35
|
+
/** auto (default): opens upward when below is short and above has more room; top / bottom force it. */
|
|
36
|
+
placement?: 'auto' | 'top' | 'bottom' | undefined;
|
|
37
|
+
/** Start open (uncontrolled). */
|
|
38
|
+
defaultOpen?: boolean | undefined;
|
|
39
|
+
id?: string | undefined;
|
|
40
|
+
name?: string | undefined;
|
|
41
|
+
className?: string | undefined;
|
|
42
|
+
'aria-label'?: string | undefined;
|
|
43
|
+
'aria-labelledby'?: string | undefined;
|
|
44
|
+
'aria-describedby'?: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TYPEAHEAD_MS = 600;
|
|
48
|
+
|
|
49
|
+
function normalise(options: SelectProps['options']): SelectOption[] {
|
|
50
|
+
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Select — a styled listbox for a fixed set of 3 to 15 (two options: a
|
|
55
|
+
* switch or segment; many or remote: the async search select). The menu
|
|
56
|
+
* renders in a portal so dialogs don't clip it and opens upward when it
|
|
57
|
+
* must. Arrows move, Enter chooses, Esc closes, Home/End jump, a letter
|
|
58
|
+
* jumps to it. The chosen row sits on the brand colour; disabled rows stay
|
|
59
|
+
* visible with their reason. No shadow — paper lifted by its hairline.
|
|
60
|
+
*/
|
|
61
|
+
export function Select({
|
|
62
|
+
options,
|
|
63
|
+
value,
|
|
64
|
+
onChange,
|
|
65
|
+
placeholder = 'Choose one',
|
|
66
|
+
searchable,
|
|
67
|
+
emptyText = 'Nothing matches that.',
|
|
68
|
+
filterPlaceholder = 'Filter',
|
|
69
|
+
invalid,
|
|
70
|
+
disabled,
|
|
71
|
+
size,
|
|
72
|
+
placement = 'auto',
|
|
73
|
+
defaultOpen = false,
|
|
74
|
+
id,
|
|
75
|
+
name,
|
|
76
|
+
className,
|
|
77
|
+
...aria
|
|
78
|
+
}: SelectProps) {
|
|
79
|
+
const field = useField();
|
|
80
|
+
const auto = useId();
|
|
81
|
+
const listId = `sl${auto}-list`;
|
|
82
|
+
const triggerId = id ?? field?.id ?? `sl${auto}-trigger`;
|
|
83
|
+
const optionId = (i: number) => `sl${auto}-o${i}`;
|
|
84
|
+
const all = useMemo(() => normalise(options), [options]);
|
|
85
|
+
const canSearch = searchable ?? all.length > 8;
|
|
86
|
+
const selected = all.find((o) => o.value === value);
|
|
87
|
+
const bad = Boolean(invalid || field?.invalid);
|
|
88
|
+
const off = Boolean(disabled || field?.disabled);
|
|
89
|
+
|
|
90
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
91
|
+
const [query, setQuery] = useState('');
|
|
92
|
+
const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, all.findIndex((o) => o.value === value)) : -1));
|
|
93
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
94
|
+
const filterRef = useRef<HTMLInputElement | null>(null);
|
|
95
|
+
const typed = useRef({ text: '', at: 0 });
|
|
96
|
+
|
|
97
|
+
const shown = useMemo(() => {
|
|
98
|
+
const q = query.trim().toLowerCase();
|
|
99
|
+
return q ? all.filter((o) => `${o.label} ${o.meta ?? ''}`.toLowerCase().includes(q)) : all;
|
|
100
|
+
}, [all, query]);
|
|
101
|
+
|
|
102
|
+
const enabled = (i: number) => i >= 0 && i < shown.length && !shown[i]!.disabled;
|
|
103
|
+
const step = (from: number, dir: 1 | -1) => {
|
|
104
|
+
for (let i = from + dir; i >= 0 && i < shown.length; i += dir) if (enabled(i)) return i;
|
|
105
|
+
return from;
|
|
106
|
+
};
|
|
107
|
+
const first = () => step(-1, 1);
|
|
108
|
+
const last = () => step(shown.length, -1);
|
|
109
|
+
|
|
110
|
+
// Keep the active row in view.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!open || active < 0) return;
|
|
113
|
+
const el = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`);
|
|
114
|
+
el?.scrollIntoView?.({ block: 'nearest' });
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
116
|
+
}, [active, open]);
|
|
117
|
+
|
|
118
|
+
const openWith = (index?: number) => {
|
|
119
|
+
setQuery('');
|
|
120
|
+
const cur = all.findIndex((o) => o.value === value);
|
|
121
|
+
setActive(index ?? (cur >= 0 && !all[cur]!.disabled ? cur : step(-1, 1)));
|
|
122
|
+
setOpen(true);
|
|
123
|
+
};
|
|
124
|
+
const choose = (i: number) => {
|
|
125
|
+
const o = shown[i];
|
|
126
|
+
if (!o || o.disabled) return;
|
|
127
|
+
onChange?.(o.value);
|
|
128
|
+
setOpen(false);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** First-letter jump: repeated keys cycle, fast typing matches a prefix. */
|
|
132
|
+
const typeahead = (key: string, from: number) => {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const t = typed.current;
|
|
135
|
+
t.text = now - t.at < TYPEAHEAD_MS ? t.text + key.toLowerCase() : key.toLowerCase();
|
|
136
|
+
t.at = now;
|
|
137
|
+
const cycling = t.text.length > 1 && t.text.split('').every((c) => c === t.text[0]);
|
|
138
|
+
const prefix = cycling ? t.text[0]! : t.text;
|
|
139
|
+
const start = cycling || t.text.length === 1 ? from + 1 : from;
|
|
140
|
+
for (let n = 0; n < shown.length; n++) {
|
|
141
|
+
const i = (start + n) % shown.length;
|
|
142
|
+
if (enabled(i) && shown[i]!.label.toLowerCase().startsWith(prefix)) return i;
|
|
143
|
+
}
|
|
144
|
+
return -1;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
148
|
+
if (off || open) return;
|
|
149
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
openWith();
|
|
152
|
+
} else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
153
|
+
const cur = all.findIndex((o) => o.value === value);
|
|
154
|
+
const hit = typeahead(e.key, cur);
|
|
155
|
+
if (hit >= 0) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
openWith(hit);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const onMenuKey = (e: KeyboardEvent<HTMLElement>) => {
|
|
163
|
+
const inFilter = e.currentTarget === filterRef.current;
|
|
164
|
+
switch (e.key) {
|
|
165
|
+
case 'ArrowDown':
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
setActive((a) => (a < 0 ? first() : step(a, 1)));
|
|
168
|
+
return;
|
|
169
|
+
case 'ArrowUp':
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
setActive((a) => (a < 0 ? last() : step(a, -1)));
|
|
172
|
+
return;
|
|
173
|
+
case 'Home':
|
|
174
|
+
if (inFilter) return;
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
setActive(first());
|
|
177
|
+
return;
|
|
178
|
+
case 'End':
|
|
179
|
+
if (inFilter) return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
setActive(last());
|
|
182
|
+
return;
|
|
183
|
+
case 'Enter':
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
choose(active);
|
|
186
|
+
return;
|
|
187
|
+
case 'Tab':
|
|
188
|
+
setOpen(false);
|
|
189
|
+
return;
|
|
190
|
+
default:
|
|
191
|
+
if (!inFilter && e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
192
|
+
const hit = typeahead(e.key, active);
|
|
193
|
+
if (hit >= 0) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
setActive(hit);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Rows in source order, runs of a group wrapped in a labelled group.
|
|
202
|
+
const rows: ReactNode[] = [];
|
|
203
|
+
let i = 0;
|
|
204
|
+
while (i < shown.length) {
|
|
205
|
+
const group = shown[i]!.group;
|
|
206
|
+
const start = i;
|
|
207
|
+
while (i < shown.length && shown[i]!.group === group) i++;
|
|
208
|
+
const items = shown.slice(start, i).map((o, k) => {
|
|
209
|
+
const idx = start + k;
|
|
210
|
+
const on = o.value === value;
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
key={o.value}
|
|
214
|
+
id={optionId(idx)}
|
|
215
|
+
role="option"
|
|
216
|
+
aria-label={o.meta ? `${o.label}, ${o.meta}` : undefined}
|
|
217
|
+
aria-selected={on}
|
|
218
|
+
aria-disabled={o.disabled || undefined}
|
|
219
|
+
data-active={idx === active || undefined}
|
|
220
|
+
onMouseMove={() => enabled(idx) && idx !== active && setActive(idx)}
|
|
221
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
222
|
+
onClick={() => choose(idx)}
|
|
223
|
+
className={cn(
|
|
224
|
+
'flex items-center gap-2 rounded-md px-2.5 py-2 text-[13px] leading-[1.35] text-fg transition-colors duration-[120ms] ease-standard',
|
|
225
|
+
o.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
|
|
226
|
+
on ? 'bg-brand-field font-medium text-ink' : idx === active && 'bg-hover',
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<span className="min-w-0 flex-1 truncate">{o.label}</span>
|
|
230
|
+
{o.meta && <span className={cn('shrink-0 text-meta tabular-nums', on ? 'text-fg-on-yellow-secondary' : 'text-fg-secondary')}>{o.meta}</span>}
|
|
231
|
+
{on && <Check size={13} strokeWidth={2} aria-hidden className="shrink-0" />}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
if (group) {
|
|
236
|
+
const gid = `sl${auto}-g${start}`;
|
|
237
|
+
rows.push(
|
|
238
|
+
<div key={gid} role="group" aria-labelledby={gid}>
|
|
239
|
+
<div id={gid} className="px-2.5 pt-2 pb-1 text-meta font-medium text-fg-secondary">
|
|
240
|
+
{group}
|
|
241
|
+
</div>
|
|
242
|
+
{items}
|
|
243
|
+
</div>,
|
|
244
|
+
);
|
|
245
|
+
} else rows.push(...items);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Popover.Root open={open} onOpenChange={(o) => (o ? openWith() : setOpen(false))}>
|
|
250
|
+
<Popover.Trigger asChild disabled={off}>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
id={triggerId}
|
|
254
|
+
role="combobox"
|
|
255
|
+
aria-expanded={open}
|
|
256
|
+
aria-haspopup="listbox"
|
|
257
|
+
aria-controls={open ? listId : undefined}
|
|
258
|
+
aria-invalid={bad || undefined}
|
|
259
|
+
aria-label={aria['aria-label']}
|
|
260
|
+
aria-labelledby={aria['aria-labelledby']}
|
|
261
|
+
aria-describedby={cn(aria['aria-describedby'], field?.describedBy) || undefined}
|
|
262
|
+
disabled={off}
|
|
263
|
+
onKeyDown={onTriggerKey}
|
|
264
|
+
className={cn(
|
|
265
|
+
controlFrame({ size, invalid: bad, disabled: off }),
|
|
266
|
+
'cursor-pointer text-left outline-none focus-visible:border-fg focus-visible:shadow-(--focus-ring) disabled:cursor-not-allowed',
|
|
267
|
+
open && !bad && 'border-fg shadow-(--focus-ring)',
|
|
268
|
+
className,
|
|
269
|
+
)}
|
|
270
|
+
>
|
|
271
|
+
<span className={cn('min-w-0 flex-1 truncate', !selected && 'text-fg-secondary')}>{selected ? selected.label : placeholder}</span>
|
|
272
|
+
{selected?.meta && <span className="shrink-0 text-meta text-fg-secondary">{selected.meta}</span>}
|
|
273
|
+
<ChevronDown
|
|
274
|
+
size={14}
|
|
275
|
+
aria-hidden
|
|
276
|
+
className={cn('shrink-0 text-fg-secondary transition-transform duration-[320ms] ease-out', open && 'rotate-180')}
|
|
277
|
+
/>
|
|
278
|
+
</button>
|
|
279
|
+
</Popover.Trigger>
|
|
280
|
+
{name && <input type="hidden" name={name} value={value ?? ''} />}
|
|
281
|
+
<Popover.Portal>
|
|
282
|
+
<Popover.Content
|
|
283
|
+
side={placement === 'top' ? 'top' : 'bottom'}
|
|
284
|
+
align="start"
|
|
285
|
+
sideOffset={6}
|
|
286
|
+
{...(aria['aria-labelledby'] ?? field?.labelId
|
|
287
|
+
? { 'aria-labelledby': aria['aria-labelledby'] ?? field?.labelId }
|
|
288
|
+
: { 'aria-label': aria['aria-label'] ?? placeholder })}
|
|
289
|
+
collisionPadding={12}
|
|
290
|
+
avoidCollisions={placement === 'auto'}
|
|
291
|
+
onOpenAutoFocus={(e) => {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
(canSearch ? filterRef.current : listRef.current)?.focus();
|
|
294
|
+
}}
|
|
295
|
+
className="rise z-60 flex flex-col overflow-hidden rounded-lg border border-rule bg-page p-1 font-sans text-fg [--motion-reveal:200ms]"
|
|
296
|
+
style={{
|
|
297
|
+
minWidth: 'var(--radix-popover-trigger-width)',
|
|
298
|
+
maxHeight: 'min(280px, calc(var(--radix-popover-content-available-height) - 12px))',
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{canSearch && (
|
|
302
|
+
<div className="mb-1 flex shrink-0 items-center gap-[7px] border-b border-rule-soft px-2 pt-[5px] pb-2">
|
|
303
|
+
<Search size={13} aria-hidden className="shrink-0 text-fg-secondary" />
|
|
304
|
+
<input
|
|
305
|
+
ref={filterRef}
|
|
306
|
+
value={query}
|
|
307
|
+
onChange={(e) => {
|
|
308
|
+
setQuery(e.target.value);
|
|
309
|
+
setActive(-1);
|
|
310
|
+
}}
|
|
311
|
+
onKeyDown={onMenuKey}
|
|
312
|
+
placeholder={filterPlaceholder}
|
|
313
|
+
aria-label={filterPlaceholder}
|
|
314
|
+
aria-controls={listId}
|
|
315
|
+
aria-activedescendant={active >= 0 ? optionId(active) : undefined}
|
|
316
|
+
aria-autocomplete="list"
|
|
317
|
+
role="combobox"
|
|
318
|
+
aria-expanded
|
|
319
|
+
className="min-w-0 flex-1 border-none bg-transparent p-0 font-sans text-[13px] text-fg outline-none placeholder:text-fg-secondary"
|
|
320
|
+
/>
|
|
321
|
+
{query && (
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
aria-label="Clear"
|
|
325
|
+
onClick={() => {
|
|
326
|
+
setQuery('');
|
|
327
|
+
filterRef.current?.focus();
|
|
328
|
+
}}
|
|
329
|
+
className="inline-grid cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
|
|
330
|
+
>
|
|
331
|
+
<X size={12} aria-hidden />
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
<div
|
|
337
|
+
ref={listRef}
|
|
338
|
+
id={listId}
|
|
339
|
+
role="listbox"
|
|
340
|
+
tabIndex={canSearch ? undefined : -1}
|
|
341
|
+
aria-activedescendant={!canSearch && active >= 0 ? optionId(active) : undefined}
|
|
342
|
+
onKeyDown={canSearch ? undefined : onMenuKey}
|
|
343
|
+
className="min-h-0 flex-1 overflow-y-auto outline-none"
|
|
344
|
+
>
|
|
345
|
+
{shown.length === 0 ? <div className="px-2.5 pt-2.5 pb-3 text-[12.5px] text-fg-secondary">{emptyText}</div> : rows}
|
|
346
|
+
</div>
|
|
347
|
+
</Popover.Content>
|
|
348
|
+
</Popover.Portal>
|
|
349
|
+
</Popover.Root>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cn } from '../lib/cn';
|
|
2
|
+
|
|
3
|
+
export type SpinnerProps = { size?: number | undefined; className?: string | undefined; label?: string | undefined };
|
|
4
|
+
|
|
5
|
+
/** Spinner — a thin ring in currentColor. Still (a static arc) when the
|
|
6
|
+
* user asks for reduced motion. Decorative unless labelled. */
|
|
7
|
+
export function Spinner({ size = 14, className, label }: SpinnerProps) {
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
role={label ? 'status' : undefined}
|
|
11
|
+
aria-label={label}
|
|
12
|
+
aria-hidden={label ? undefined : true}
|
|
13
|
+
className={cn(
|
|
14
|
+
'inline-block shrink-0 rounded-pill border-[1.5px] border-current border-r-transparent motion-safe:animate-spin',
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
style={{ width: size, height: size }}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
|
2
|
+
import { useId, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export type SwitchProps = Omit<ComponentProps<typeof SwitchPrimitive.Root>, 'checked' | 'onCheckedChange' | 'onChange' | 'children'> & {
|
|
7
|
+
checked?: boolean | undefined;
|
|
8
|
+
/** Called when flipped — the setting applies at once. */
|
|
9
|
+
onChange?: ((checked: boolean) => void) | undefined;
|
|
10
|
+
/** Name the setting ("Require two-step verification"), never the action. */
|
|
11
|
+
label?: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
/** Class for the whole row (className goes to the track). */
|
|
14
|
+
rowClassName?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Switch — one setting that applies at once (a form choice is a
|
|
19
|
+
* Checkbox). Pill track: chrome when off, ink when on (linen in ink
|
|
20
|
+
* mode); the knob has no shadow. role="switch" with aria-checked.
|
|
21
|
+
*/
|
|
22
|
+
export function Switch({ checked, onChange, label, description, disabled, id, className, rowClassName, ...rest }: SwitchProps) {
|
|
23
|
+
const auto = useId();
|
|
24
|
+
const switchId = id ?? `sw${auto}`;
|
|
25
|
+
const descId = description ? `${switchId}-desc` : undefined;
|
|
26
|
+
const track = (
|
|
27
|
+
<SwitchPrimitive.Root
|
|
28
|
+
id={switchId}
|
|
29
|
+
{...(checked !== undefined && { checked })}
|
|
30
|
+
onCheckedChange={(v) => onChange?.(v)}
|
|
31
|
+
disabled={disabled}
|
|
32
|
+
aria-describedby={descId}
|
|
33
|
+
className={cn(
|
|
34
|
+
'relative inline-flex h-5 w-[34px] shrink-0 cursor-pointer items-center rounded-pill border border-rule bg-chrome p-0',
|
|
35
|
+
'transition-[background-color,border-color] duration-[120ms] ease-standard outline-none focus-visible:shadow-(--focus-ring)',
|
|
36
|
+
'data-[state=checked]:border-fg data-[state=checked]:bg-fg disabled:cursor-not-allowed',
|
|
37
|
+
className,
|
|
38
|
+
)}
|
|
39
|
+
{...rest}
|
|
40
|
+
>
|
|
41
|
+
<SwitchPrimitive.Thumb
|
|
42
|
+
className={cn(
|
|
43
|
+
'block size-3.5 translate-x-[2px] rounded-pill bg-paper transition-transform duration-[120ms] ease-standard',
|
|
44
|
+
'data-[state=checked]:translate-x-[16px] data-[state=checked]:bg-page',
|
|
45
|
+
)}
|
|
46
|
+
/>
|
|
47
|
+
</SwitchPrimitive.Root>
|
|
48
|
+
);
|
|
49
|
+
if (!label && !description) return track;
|
|
50
|
+
return (
|
|
51
|
+
<div className={cn('inline-flex items-center gap-2.5 font-sans', description && 'items-start', disabled && 'opacity-40', rowClassName)}>
|
|
52
|
+
{track}
|
|
53
|
+
<span className="flex flex-col gap-0.5">
|
|
54
|
+
{label && (
|
|
55
|
+
<label htmlFor={switchId} className={cn('text-small leading-[1.45] text-fg', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
|
56
|
+
{label}
|
|
57
|
+
</label>
|
|
58
|
+
)}
|
|
59
|
+
{description && (
|
|
60
|
+
<span id={descId} className="text-meta leading-normal text-fg-secondary">
|
|
61
|
+
{description}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { ArrowDown, ArrowUp } from 'lucide-react';
|
|
2
|
+
import type { ComponentProps, KeyboardEvent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export type TableSortDirection = 'asc' | 'desc';
|
|
7
|
+
export type TableSort = { key: string; direction: TableSortDirection };
|
|
8
|
+
|
|
9
|
+
export type TableColumn<R> = {
|
|
10
|
+
key: string;
|
|
11
|
+
label: ReactNode;
|
|
12
|
+
/** Numbers and dates right-aligned (tabular). @default "left" */
|
|
13
|
+
align?: 'left' | 'right' | undefined;
|
|
14
|
+
/** Quiet metadata cell: 12px grey, tabular. */
|
|
15
|
+
meta?: boolean | undefined;
|
|
16
|
+
width?: number | string | undefined;
|
|
17
|
+
/** Header click sorts by this column. */
|
|
18
|
+
sortable?: boolean | undefined;
|
|
19
|
+
render?: ((row: R) => ReactNode) | undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type TableRowBase = {
|
|
23
|
+
id: string | number;
|
|
24
|
+
/** attention (yellow wash) or error (red wash); progress and complete stay plain — the status column says them. */
|
|
25
|
+
status?: 'attention' | 'error' | 'progress' | 'complete' | 'neutral' | undefined;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type TableProps<R extends TableRowBase> = Omit<ComponentProps<'table'>, 'children'> & {
|
|
30
|
+
columns: TableColumn<R>[];
|
|
31
|
+
rows: R[];
|
|
32
|
+
/** Opening a row. The row is also reachable by keyboard (Enter). Prefer a real link in the first column (rowHref). */
|
|
33
|
+
onRowClick?: ((row: R) => void) | undefined;
|
|
34
|
+
/** Makes the first column a real link to the row. */
|
|
35
|
+
rowHref?: ((row: R) => string) | undefined;
|
|
36
|
+
sort?: TableSort | undefined;
|
|
37
|
+
onSortChange?: ((sort: TableSort) => void) | undefined;
|
|
38
|
+
dense?: boolean | undefined;
|
|
39
|
+
/** Keep the head and show five skeleton rows. */
|
|
40
|
+
loading?: boolean | undefined;
|
|
41
|
+
/** One line and one action for an empty table. */
|
|
42
|
+
empty?: ReactNode;
|
|
43
|
+
/** Accessible name / visible caption. */
|
|
44
|
+
caption?: ReactNode;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Table — a register: linen head, hairline rows, a wash on rows that need a
|
|
51
|
+
* person (attention yellow, overdue red). For lists people compare and
|
|
52
|
+
* sort; for browsing use list rows or cards. Six columns at most. On phones
|
|
53
|
+
* each row becomes a card: the first column as the title, the rest one
|
|
54
|
+
* line of metadata.
|
|
55
|
+
*/
|
|
56
|
+
export function Table<R extends TableRowBase>({
|
|
57
|
+
columns,
|
|
58
|
+
rows,
|
|
59
|
+
onRowClick,
|
|
60
|
+
rowHref,
|
|
61
|
+
sort,
|
|
62
|
+
onSortChange,
|
|
63
|
+
dense = false,
|
|
64
|
+
loading = false,
|
|
65
|
+
empty,
|
|
66
|
+
caption,
|
|
67
|
+
className,
|
|
68
|
+
...rest
|
|
69
|
+
}: TableProps<R>) {
|
|
70
|
+
const pad = dense ? 'px-3 py-[7px]' : 'px-3.5 py-2.5';
|
|
71
|
+
const cell = (r: R, c: TableColumn<R>) => (c.render ? c.render(r) : (r[c.key] as ReactNode));
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<table className={cn('w-full border-collapse font-sans text-[13px] text-fg', 'max-sm:block', className)} {...rest}>
|
|
75
|
+
{caption && <caption className="pb-2 text-left text-meta font-medium text-fg-secondary max-sm:block">{caption}</caption>}
|
|
76
|
+
<thead className="max-sm:sr-only">
|
|
77
|
+
<tr>
|
|
78
|
+
{columns.map((c) => {
|
|
79
|
+
const active = sort?.key === c.key;
|
|
80
|
+
const next: TableSort = { key: c.key, direction: active && sort?.direction === 'asc' ? 'desc' : 'asc' };
|
|
81
|
+
return (
|
|
82
|
+
<th
|
|
83
|
+
key={c.key}
|
|
84
|
+
scope="col"
|
|
85
|
+
aria-sort={c.sortable ? (active ? ARIA_SORT[sort!.direction] : 'none') : undefined}
|
|
86
|
+
style={c.width !== undefined ? { width: c.width } : undefined}
|
|
87
|
+
className={cn(pad, 'border-b border-rule bg-sunk text-meta font-medium whitespace-nowrap text-fg-secondary', c.align === 'right' ? 'text-right' : 'text-left')}
|
|
88
|
+
>
|
|
89
|
+
{c.sortable && onSortChange ? (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => onSortChange(next)}
|
|
93
|
+
className={cn(
|
|
94
|
+
'inline-flex cursor-pointer items-center gap-1 rounded-xs border-0 bg-transparent p-0 font-[inherit] text-[length:inherit] font-medium outline-none hover:text-fg focus-visible:shadow-(--focus-ring)',
|
|
95
|
+
active ? 'text-fg' : 'text-fg-secondary',
|
|
96
|
+
c.align === 'right' && 'flex-row-reverse',
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
{c.label}
|
|
100
|
+
{active && (sort!.direction === 'asc' ? <ArrowUp size={12} aria-hidden /> : <ArrowDown size={12} aria-hidden />)}
|
|
101
|
+
</button>
|
|
102
|
+
) : (
|
|
103
|
+
c.label
|
|
104
|
+
)}
|
|
105
|
+
</th>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody className="max-sm:flex max-sm:w-full max-sm:flex-col max-sm:gap-2">
|
|
111
|
+
{loading
|
|
112
|
+
? Array.from({ length: 5 }, (_, i) => (
|
|
113
|
+
<tr key={i} aria-hidden className="max-sm:flex max-sm:w-full max-sm:gap-3">
|
|
114
|
+
{columns.map((c) => (
|
|
115
|
+
<td key={c.key} className={cn(pad, 'border-b border-rule-soft')}>
|
|
116
|
+
<span className="block h-3 w-3/4 rounded-xs bg-well motion-safe:animate-pulse" />
|
|
117
|
+
</td>
|
|
118
|
+
))}
|
|
119
|
+
</tr>
|
|
120
|
+
))
|
|
121
|
+
: rows.length === 0 && empty
|
|
122
|
+
? (
|
|
123
|
+
<tr>
|
|
124
|
+
<td colSpan={columns.length} className="px-3.5 py-6 text-center text-small text-fg-secondary">
|
|
125
|
+
{empty}
|
|
126
|
+
</td>
|
|
127
|
+
</tr>
|
|
128
|
+
)
|
|
129
|
+
: rows.map((r) => {
|
|
130
|
+
const wash =
|
|
131
|
+
r.status === 'attention'
|
|
132
|
+
? 'bg-attention-wash ink:bg-yellow-accent/15'
|
|
133
|
+
: r.status === 'error'
|
|
134
|
+
? 'bg-error-wash ink:bg-error/20'
|
|
135
|
+
: 'hover:bg-hover';
|
|
136
|
+
const clickable = Boolean(onRowClick);
|
|
137
|
+
return (
|
|
138
|
+
<tr
|
|
139
|
+
key={r.id}
|
|
140
|
+
data-status={r.status}
|
|
141
|
+
onClick={clickable ? () => onRowClick!(r) : undefined}
|
|
142
|
+
onKeyDown={
|
|
143
|
+
clickable && !rowHref
|
|
144
|
+
? (e: KeyboardEvent<HTMLTableRowElement>) => {
|
|
145
|
+
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
onRowClick!(r);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
: undefined
|
|
151
|
+
}
|
|
152
|
+
tabIndex={clickable && !rowHref ? 0 : undefined}
|
|
153
|
+
className={cn(
|
|
154
|
+
'transition-colors duration-[120ms] ease-standard outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--text-primary)]',
|
|
155
|
+
wash,
|
|
156
|
+
clickable && 'cursor-pointer',
|
|
157
|
+
'max-sm:box-border max-sm:flex max-sm:w-full max-sm:flex-wrap max-sm:gap-x-2 max-sm:rounded-lg max-sm:border max-sm:border-rule max-sm:px-4 max-sm:py-3',
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
{columns.map((c, i) => {
|
|
161
|
+
const content = cell(r, c);
|
|
162
|
+
return (
|
|
163
|
+
<td
|
|
164
|
+
key={c.key}
|
|
165
|
+
className={cn(
|
|
166
|
+
pad,
|
|
167
|
+
'border-b border-rule-soft align-middle',
|
|
168
|
+
c.align === 'right' && 'text-right tabular-nums',
|
|
169
|
+
c.meta && 'text-meta whitespace-nowrap text-fg-secondary tabular-nums',
|
|
170
|
+
// Phones: title line, then one line of metadata.
|
|
171
|
+
'max-sm:border-0 max-sm:p-0',
|
|
172
|
+
i === 0 ? 'max-sm:w-full max-sm:text-ui max-sm:font-medium' : 'max-sm:text-meta max-sm:text-fg-secondary',
|
|
173
|
+
i > 1 && "max-sm:before:content-['·_']",
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
{i === 0 && rowHref ? (
|
|
177
|
+
<a
|
|
178
|
+
href={rowHref(r)}
|
|
179
|
+
onClick={(e) => e.stopPropagation()}
|
|
180
|
+
className="rounded-xs text-fg no-underline outline-none hover:underline focus-visible:shadow-(--focus-ring)"
|
|
181
|
+
>
|
|
182
|
+
{content}
|
|
183
|
+
</a>
|
|
184
|
+
) : (
|
|
185
|
+
content
|
|
186
|
+
)}
|
|
187
|
+
</td>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</tr>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
);
|
|
196
|
+
}
|