@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.10

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/package.json +36 -6
  3. package/src/brand.css +13 -0
  4. package/src/components/ActionBar.tsx +607 -0
  5. package/src/components/ActivityTimeline.tsx +77 -0
  6. package/src/components/AsyncSelect.tsx +205 -0
  7. package/src/components/AuthLayout.tsx +127 -0
  8. package/src/components/Badge.tsx +68 -0
  9. package/src/components/BrandLoader.tsx +122 -0
  10. package/src/components/Breadcrumb.tsx +129 -0
  11. package/src/components/Button.tsx +186 -0
  12. package/src/components/Card.tsx +42 -0
  13. package/src/components/Checkbox.tsx +67 -0
  14. package/src/components/ChoiceCards.tsx +68 -0
  15. package/src/components/CodeInput.tsx +150 -0
  16. package/src/components/CommandPalette.tsx +228 -0
  17. package/src/components/ConfirmDialog.tsx +294 -0
  18. package/src/components/CopyField.tsx +104 -0
  19. package/src/components/DateInput.tsx +366 -0
  20. package/src/components/Dialog.tsx +135 -0
  21. package/src/components/Dropzone.tsx +187 -0
  22. package/src/components/Field.tsx +66 -0
  23. package/src/components/Figure.tsx +30 -0
  24. package/src/components/FileCard.tsx +233 -0
  25. package/src/components/FileRow.tsx +159 -0
  26. package/src/components/FolderTree.tsx +377 -0
  27. package/src/components/Icon.tsx +32 -0
  28. package/src/components/IconButton.tsx +55 -0
  29. package/src/components/Input.tsx +74 -0
  30. package/src/components/ListToolbar.tsx +177 -0
  31. package/src/components/Logo.tsx +62 -0
  32. package/src/components/Marker.tsx +26 -0
  33. package/src/components/MentionInput.tsx +233 -0
  34. package/src/components/Menu.tsx +109 -0
  35. package/src/components/MetaLabel.tsx +38 -0
  36. package/src/components/PageHeader.tsx +62 -0
  37. package/src/components/PageState.tsx +84 -0
  38. package/src/components/Progress.tsx +97 -0
  39. package/src/components/PulseDot.tsx +31 -0
  40. package/src/components/Radio.tsx +72 -0
  41. package/src/components/RecipientsInput.tsx +252 -0
  42. package/src/components/SearchField.tsx +137 -0
  43. package/src/components/SegmentedControl.tsx +95 -0
  44. package/src/components/Select.tsx +351 -0
  45. package/src/components/SidePanel.tsx +130 -0
  46. package/src/components/Skeleton.tsx +73 -0
  47. package/src/components/Spinner.tsx +20 -0
  48. package/src/components/StageProgress.tsx +51 -0
  49. package/src/components/Steps.tsx +58 -0
  50. package/src/components/Switch.tsx +67 -0
  51. package/src/components/SyncStatus.tsx +56 -0
  52. package/src/components/Table.tsx +196 -0
  53. package/src/components/Tabs.tsx +124 -0
  54. package/src/components/Tag.tsx +35 -0
  55. package/src/components/Textarea.tsx +46 -0
  56. package/src/components/ThemeProvider.tsx +80 -0
  57. package/src/components/ThemeSwitch.tsx +99 -0
  58. package/src/components/Toast.tsx +196 -0
  59. package/src/components/Tooltip.tsx +57 -0
  60. package/src/components/UploadCheck.tsx +118 -0
  61. package/src/components/UploadList.tsx +176 -0
  62. package/src/components/VirtualList.tsx +90 -0
  63. package/src/components/WindowDrop.tsx +131 -0
  64. package/src/components/WorkspaceRow.tsx +80 -0
  65. package/src/components/forms-control.ts +36 -0
  66. package/src/components/inputs-aria.ts +5 -0
  67. package/src/components/inputs-date.ts +92 -0
  68. package/src/components/inputs-listbox.tsx +154 -0
  69. package/src/components/inputs-search.ts +60 -0
  70. package/src/components/logo-art.ts +4 -0
  71. package/src/components/status-files.ts +124 -0
  72. package/src/components/status-motion.ts +30 -0
  73. package/src/components/useButtonAction.ts +57 -0
  74. package/src/components/useSelection.ts +142 -0
  75. package/src/index.ts +73 -2
  76. package/src/modes.css +10 -0
  77. package/src/styles.css +2 -0
  78. package/src/theme-base.css +123 -0
  79. package/src/theme-reset.css +14 -0
  80. package/src/theme.css +2 -114
@@ -0,0 +1,177 @@
1
+ import { LayoutGrid, List as ListIcon, Search, X } from 'lucide-react';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Input } from './Input';
6
+ import { Select, type SelectOption } from './Select';
7
+
8
+ export type ListView = 'cards' | 'list';
9
+ export type SearchScope = 'deep' | 'folder';
10
+
11
+ export type ListFilter = { id: string; label: string; active?: boolean | undefined };
12
+
13
+ export type ListToolbarLabels = {
14
+ /** The toolbar's accessible name. */
15
+ toolbar: string;
16
+ search: string;
17
+ sort: string;
18
+ view: string;
19
+ cards: string;
20
+ list: string;
21
+ removeFilter: (label: string) => string;
22
+ /** "Searching this folder and its subfolders" */
23
+ scopeDeep: string;
24
+ /** "Searching this folder only" */
25
+ scopeFolder: string;
26
+ /** The switch to folder only: "This folder only" */
27
+ switchToFolder: string;
28
+ /** The switch back: "Include subfolders" */
29
+ switchToDeep: string;
30
+ };
31
+
32
+ const EN: ListToolbarLabels = {
33
+ toolbar: 'List tools',
34
+ search: 'Search this folder',
35
+ sort: 'Sort',
36
+ view: 'View',
37
+ cards: 'Cards',
38
+ list: 'List',
39
+ removeFilter: (l) => `Remove filter ${l}`,
40
+ scopeDeep: 'Searching this folder and its subfolders',
41
+ scopeFolder: 'Searching this folder only',
42
+ switchToFolder: 'This folder only',
43
+ switchToDeep: 'Include subfolders',
44
+ };
45
+
46
+ export type ListToolbarProps = Omit<ComponentProps<'div'>, 'onChange'> & {
47
+ query?: string | undefined;
48
+ onQueryChange?: ((query: string) => void) | undefined;
49
+ /** Where the search looks; the line under the toolbar says so and switches it. */
50
+ scope?: SearchScope | undefined;
51
+ onScopeChange?: ((scope: SearchScope) => void) | undefined;
52
+ sortOptions?: Array<string | SelectOption> | undefined;
53
+ sort?: string | undefined;
54
+ onSortChange?: ((value: string) => void) | undefined;
55
+ /** Active filters show as chips on the brand field with a remove button. */
56
+ filters?: ListFilter[] | undefined;
57
+ onFilterRemove?: ((id: string) => void) | undefined;
58
+ view?: ListView | undefined;
59
+ onViewChange?: ((view: ListView) => void) | undefined;
60
+ /** "2 folders · 14 documents · more below" */
61
+ counts?: ReactNode;
62
+ labels?: Partial<ListToolbarLabels> | undefined;
63
+ };
64
+
65
+ /**
66
+ * ListToolbar — search in the folder (with its scope said out loud), sort,
67
+ * active filters, the cards / list switch and the counts. Shared by every
68
+ * list.
69
+ */
70
+ export function ListToolbar({
71
+ query,
72
+ onQueryChange,
73
+ scope = 'deep',
74
+ onScopeChange,
75
+ sortOptions,
76
+ sort,
77
+ onSortChange,
78
+ filters,
79
+ onFilterRemove,
80
+ view,
81
+ onViewChange,
82
+ counts,
83
+ labels,
84
+ className,
85
+ ...rest
86
+ }: ListToolbarProps) {
87
+ const t = { ...EN, ...labels };
88
+ const active = (filters ?? []).filter((f) => f.active !== false);
89
+ const searching = Boolean(query);
90
+ return (
91
+ <div className={cn('flex flex-col gap-2', className)} {...rest}>
92
+ <div role="toolbar" aria-label={t.toolbar} className="flex flex-wrap items-center gap-2">
93
+ {onQueryChange && (
94
+ <Input
95
+ type="search"
96
+ aria-label={t.search}
97
+ placeholder={t.search}
98
+ value={query ?? ''}
99
+ onChange={(e) => onQueryChange(e.target.value)}
100
+ prefix={<Search size={15} strokeWidth={1.75} aria-hidden />}
101
+ frameClassName="min-w-[200px] flex-1"
102
+ />
103
+ )}
104
+ {sortOptions && (
105
+ <div className="w-[160px]">
106
+ <Select options={sortOptions} value={sort} onChange={onSortChange} aria-label={t.sort} />
107
+ </div>
108
+ )}
109
+ {active.map((f) => (
110
+ <span key={f.id} className="inline-flex h-[38px] items-center gap-1.5 rounded-md bg-brand-field px-3 text-ui font-medium text-ink">
111
+ {f.label}
112
+ {onFilterRemove && (
113
+ <button
114
+ type="button"
115
+ aria-label={t.removeFilter(f.label)}
116
+ onClick={() => onFilterRemove(f.id)}
117
+ className="grid cursor-pointer place-items-center rounded-xs outline-none focus-visible:shadow-(--focus-ring)"
118
+ >
119
+ <X size={14} strokeWidth={2} aria-hidden />
120
+ </button>
121
+ )}
122
+ </span>
123
+ ))}
124
+ {onViewChange && (
125
+ <div role="radiogroup" aria-label={t.view} className="inline-flex h-[38px] items-center gap-0.5 rounded-md bg-sunk p-1">
126
+ {(
127
+ [
128
+ ['cards', LayoutGrid, t.cards],
129
+ ['list', ListIcon, t.list],
130
+ ] as const
131
+ ).map(([v, Glyph, label]) => (
132
+ <button
133
+ key={v}
134
+ type="button"
135
+ role="radio"
136
+ aria-checked={view === v}
137
+ aria-label={label}
138
+ title={label}
139
+ onClick={() => onViewChange(v)}
140
+ className={cn(
141
+ 'grid size-[30px] cursor-pointer place-items-center rounded-sm text-fg outline-none focus-visible:shadow-(--focus-ring)',
142
+ view === v ? 'bg-brand-field text-ink' : 'hover:bg-hover',
143
+ )}
144
+ >
145
+ <Glyph size={15} strokeWidth={1.75} aria-hidden />
146
+ </button>
147
+ ))}
148
+ </div>
149
+ )}
150
+ </div>
151
+ {(searching || counts) && (
152
+ <div className="flex flex-wrap items-center justify-between gap-2 text-meta text-fg-secondary">
153
+ <span>
154
+ {searching && (
155
+ <>
156
+ {scope === 'deep' ? t.scopeDeep : t.scopeFolder}
157
+ {onScopeChange && (
158
+ <>
159
+ {' · '}
160
+ <button
161
+ type="button"
162
+ onClick={() => onScopeChange(scope === 'deep' ? 'folder' : 'deep')}
163
+ className="cursor-pointer text-fg underline underline-offset-2 outline-none focus-visible:shadow-(--focus-ring)"
164
+ >
165
+ {scope === 'deep' ? t.switchToFolder : t.switchToDeep}
166
+ </button>
167
+ </>
168
+ )}
169
+ </>
170
+ )}
171
+ </span>
172
+ {counts && <span aria-live="polite">{counts}</span>}
173
+ </div>
174
+ )}
175
+ </div>
176
+ );
177
+ }
@@ -0,0 +1,62 @@
1
+ import type { ComponentProps } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { LOOP, WORDMARK } from './logo-art';
5
+
6
+ export type LogoProps = Omit<ComponentProps<'span'>, 'children'> & {
7
+ /** wordmark · icon (the loop) · tile (the loop on the yellow square). @default "wordmark" */
8
+ variant?: 'wordmark' | 'icon' | 'tile' | undefined;
9
+ /** ink on paper, linen and the yellow; white on ink. Never recoloured beyond these. Unset: ink, white in ink mode. */
10
+ tone?: 'ink' | 'white' | undefined;
11
+ /** The standard lockup: a 4px accent-yellow rule beneath the wordmark. */
12
+ rule?: boolean | undefined;
13
+ /** px height. @default 28 */
14
+ height?: number | undefined;
15
+ /** Accessible name. @default "COSX" */
16
+ alt?: string | undefined;
17
+ };
18
+
19
+ function Art({ art, height, title }: { art: typeof WORDMARK | typeof LOOP; height: number; title: string }) {
20
+ const [, , w, h] = art.viewBox.split(' ').map(Number) as [number, number, number, number];
21
+ return (
22
+ <svg viewBox={art.viewBox} height={height} width={Math.round((height * w) / h)} role="img" aria-label={title} fill="currentColor" className="block shrink-0">
23
+ {art.paths.map((d) => (
24
+ <path key={d.slice(0, 24)} d={d} />
25
+ ))}
26
+ </svg>
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Logo — the COSX wordmark, the CO loop, or the yellow tile (app icon,
32
+ * avatar, favicon). Colour comes from the ground, never from the mark: the
33
+ * mark is ink or white, never yellow, never two-tone.
34
+ */
35
+ export function Logo({ variant = 'wordmark', tone, rule = false, height = 28, alt = 'COSX', className, ...rest }: LogoProps) {
36
+ if (variant === 'tile') {
37
+ return (
38
+ <span
39
+ className={cn('inline-grid shrink-0 place-items-center bg-yellow text-ink', className)}
40
+ style={{ width: height, height, borderRadius: Math.round(height * 0.22) }}
41
+ {...rest}
42
+ >
43
+ <Art art={LOOP} height={height} title={alt} />
44
+ </span>
45
+ );
46
+ }
47
+ const colour = tone === 'white' ? 'text-white' : tone === 'ink' ? 'text-ink' : 'text-ink ink:text-white';
48
+ const art = <Art art={variant === 'icon' ? LOOP : WORDMARK} height={height} title={alt} />;
49
+ if (!rule || variant === 'icon') {
50
+ return (
51
+ <span className={cn('inline-flex', colour, className)} {...rest}>
52
+ {art}
53
+ </span>
54
+ );
55
+ }
56
+ return (
57
+ <span className={cn('inline-flex flex-col items-start', colour, className)} style={{ gap: Math.round(height * 0.45) }} {...rest}>
58
+ {art}
59
+ <span aria-hidden className="h-1 w-full rounded-[2px] bg-yellow-accent" />
60
+ </span>
61
+ );
62
+ }
@@ -0,0 +1,26 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type MarkerProps = Omit<ComponentProps<'span'>, 'children'> & {
6
+ children: ReactNode;
7
+ /** On an ink ground there is no band: the phrase is set in accent yellow. */
8
+ onInk?: boolean | undefined;
9
+ /** Draw the band in after the headline lands (motion.css; still with reduced motion). */
10
+ draw?: boolean | undefined;
11
+ as?: ElementType | undefined;
12
+ };
13
+
14
+ /**
15
+ * Marker — the highlighter behind the phrase that carries the finding.
16
+ * One per headline. The band sits in the lower part of the line and is
17
+ * cloned across line breaks; Chinese starts it lower (typography-cjk.css).
18
+ * Styling comes from the design system's own .marker rules.
19
+ */
20
+ export function Marker({ children, onInk = false, draw = false, as: Tag = 'span', className, ...rest }: MarkerProps) {
21
+ return (
22
+ <Tag className={cn('marker', onInk && 'on-ink', draw && !onInk && 'draw', className)} {...rest}>
23
+ {children}
24
+ </Tag>
25
+ );
26
+ }
@@ -0,0 +1,233 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Button } from './Button';
6
+ import { useField } from './Field';
7
+ import { DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
8
+ import type { SearchStatus } from './inputs-search';
9
+
10
+ export type Mention = { value: string; label: string };
11
+
12
+ export type MentionInputProps = {
13
+ value?: string | undefined;
14
+ defaultValue?: string | undefined;
15
+ /** The text and the people mentioned in it (those whose @Name is still there). */
16
+ onChange?: ((text: string, mentions: Mention[]) => void) | undefined;
17
+ /** ⌘Enter / Ctrl+Enter, or the send button. */
18
+ onSubmit?: ((text: string, mentions: Mention[]) => void) | undefined;
19
+ /** People who can be mentioned; meta is their role (Member, Customer). */
20
+ people: ListItem[];
21
+ placeholder?: string | undefined;
22
+ /** @default "Send" — null hides the button (⌘Enter still sends). */
23
+ submitLabel?: ReactNode;
24
+ /** @default "⌘Enter to send" (Ctrl+Enter off the Mac) */
25
+ submitHint?: ReactNode;
26
+ labels?: Partial<ListboxLabels> | undefined;
27
+ disabled?: boolean | undefined;
28
+ /** Rows before it scrolls. @default 3 */
29
+ rows?: number | undefined;
30
+ /** Focus on mount, caret at the end (an @ being typed there opens the list). */
31
+ autoFocus?: boolean | undefined;
32
+ id?: string | undefined;
33
+ 'aria-label'?: string | undefined;
34
+ className?: string | undefined;
35
+ };
36
+
37
+ const isMac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
38
+
39
+ /** The @query being typed at the caret: "@" at a word start, no spaces since. */
40
+ export function mentionAt(text: string, caret: number): { start: number; query: string } | null {
41
+ const before = text.slice(0, caret);
42
+ const at = before.lastIndexOf('@');
43
+ if (at < 0) return null;
44
+ if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
45
+ const query = before.slice(at + 1);
46
+ if (/\s/.test(query) || query.length > 30) return null;
47
+ return { start: at, query };
48
+ }
49
+
50
+ /**
51
+ * MentionInput — the comment box: @ opens the people who can be mentioned
52
+ * (arrows, Enter or Tab to insert, Esc to close); the mention goes in as
53
+ * @Name. ⌘Enter sends. Mentions are reported with the text, and only while
54
+ * their @Name is still in it.
55
+ */
56
+ export function MentionInput({
57
+ value,
58
+ defaultValue = '',
59
+ onChange,
60
+ onSubmit,
61
+ people,
62
+ placeholder,
63
+ submitLabel = 'Send',
64
+ submitHint,
65
+ labels: labelsIn,
66
+ disabled,
67
+ rows = 3,
68
+ autoFocus = false,
69
+ id,
70
+ className,
71
+ ...aria
72
+ }: MentionInputProps) {
73
+ const field = useField();
74
+ const auto = useId();
75
+ const listId = `mi${auto}-list`;
76
+ const optionId = (i: number) => `mi${auto}-o${i}`;
77
+ const labels = { ...DEFAULT_LABELS, ...labelsIn, empty: labelsIn?.empty ?? ((q: string) => `No one matches “${q}”.`) };
78
+ const [own, setOwn] = useState(defaultValue);
79
+ const text = value ?? own;
80
+ const [picked, setPicked] = useState<Mention[]>([]);
81
+ const [at, setAt] = useState<{ start: number; query: string } | null>(null);
82
+ const [active, setActive] = useState(0);
83
+ const area = useRef<HTMLTextAreaElement | null>(null);
84
+ // After a token goes in, put the caret after it once the new text is on screen.
85
+ const caretTo = useRef<number | null>(null);
86
+ useLayoutEffect(() => {
87
+ const pos = caretTo.current;
88
+ if (pos === null || !area.current) return;
89
+ caretTo.current = null;
90
+ area.current.focus();
91
+ area.current.setSelectionRange(pos, pos);
92
+ }, [text]);
93
+ const off = Boolean(disabled || field?.disabled);
94
+
95
+ const matches = useMemo(() => {
96
+ if (!at) return [];
97
+ const q = at.query.toLowerCase();
98
+ return people.filter((p) => p.label.toLowerCase().includes(q) || (p.meta ?? '').toLowerCase().startsWith(q)).slice(0, 8);
99
+ }, [at, people]);
100
+ const status: SearchStatus = 'done';
101
+ const open = at !== null && !off;
102
+
103
+ const live = (t: string) => picked.filter((m, i, all) => t.includes(`@${m.label}`) && all.findIndex((x) => x.value === m.value) === i);
104
+ const set = (t: string, list = picked) => {
105
+ if (value === undefined) setOwn(t);
106
+ onChange?.(t, list.filter((m) => t.includes(`@${m.label}`)));
107
+ };
108
+ const track = (t: string, caret: number) => {
109
+ const next = mentionAt(t, caret);
110
+ setAt(next);
111
+ if (next && (!at || next.query !== at.query)) setActive(0);
112
+ };
113
+ // autoFocus: caret at the end, and an @ being typed there opens the list.
114
+ useEffect(() => {
115
+ const el = area.current;
116
+ if (!autoFocus || !el) return;
117
+ el.focus();
118
+ el.setSelectionRange(el.value.length, el.value.length);
119
+ track(el.value, el.value.length);
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
121
+ }, []);
122
+ const insert = (i: number) => {
123
+ const p = matches[i];
124
+ if (!p || !at) return;
125
+ const token = `@${p.label} `;
126
+ const caret = at.start + 1 + at.query.length;
127
+ const next = text.slice(0, at.start) + token + text.slice(caret);
128
+ const list = [...picked, { value: p.value, label: p.label }];
129
+ setPicked(list);
130
+ set(next, list);
131
+ setAt(null);
132
+ const pos = at.start + token.length;
133
+ caretTo.current = pos;
134
+ };
135
+ const submit = () => {
136
+ if (!text.trim()) return;
137
+ onSubmit?.(text, live(text));
138
+ };
139
+
140
+ const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
141
+ if (open && matches.length) {
142
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
143
+ e.preventDefault();
144
+ setActive((a) => (e.key === 'ArrowDown' ? (a + 1) % matches.length : (a - 1 + matches.length) % matches.length));
145
+ return;
146
+ }
147
+ if ((e.key === 'Enter' && !e.metaKey && !e.ctrlKey) || e.key === 'Tab') {
148
+ e.preventDefault();
149
+ insert(active);
150
+ return;
151
+ }
152
+ }
153
+ if (open && e.key === 'Escape') {
154
+ e.preventDefault();
155
+ setAt(null);
156
+ return;
157
+ }
158
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
159
+ e.preventDefault();
160
+ submit();
161
+ }
162
+ };
163
+
164
+ return (
165
+ <div
166
+ className={cn(
167
+ 'flex w-full flex-col rounded-md border border-rule bg-sunk font-sans text-fg',
168
+ 'transition-[border-color] duration-[120ms] ease-standard focus-within:border-fg focus-within:shadow-(--focus-ring)',
169
+ field?.invalid && 'border-error focus-within:border-error',
170
+ off && 'cursor-not-allowed bg-well text-fg-secondary',
171
+ className,
172
+ )}
173
+ >
174
+ <Popover.Root open={open} onOpenChange={(o) => !o && setAt(null)}>
175
+ <Popover.Anchor asChild>
176
+ <textarea
177
+ ref={area}
178
+ id={id ?? field?.id}
179
+ rows={rows}
180
+ value={text}
181
+ placeholder={placeholder}
182
+ disabled={off}
183
+ aria-controls={open ? listId : undefined}
184
+ aria-autocomplete="list"
185
+ aria-activedescendant={open && matches.length ? optionId(active) : undefined}
186
+ aria-label={aria['aria-label']}
187
+ aria-describedby={field?.describedBy}
188
+ onChange={(e) => {
189
+ set(e.target.value);
190
+ track(e.target.value, e.target.selectionStart ?? e.target.value.length);
191
+ }}
192
+ onSelect={(e) => track(e.currentTarget.value, e.currentTarget.selectionStart ?? 0)}
193
+ onKeyDown={onKey}
194
+ onBlur={() => setAt(null)}
195
+ className="min-h-0 w-full resize-none border-none bg-transparent px-2.5 pt-2 pb-1 font-sans text-[13px] leading-normal text-inherit outline-none placeholder:text-fg-secondary"
196
+ />
197
+ </Popover.Anchor>
198
+ <Popover.Portal>
199
+ <Popover.Content
200
+ align="start"
201
+ side="bottom"
202
+ sideOffset={4}
203
+ collisionPadding={12}
204
+ onOpenAutoFocus={(e) => e.preventDefault()}
205
+ className="rise z-60 w-[260px] rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
206
+ >
207
+ <ListboxBody
208
+ id={listId}
209
+ optionId={optionId}
210
+ items={matches}
211
+ active={active}
212
+ status={status}
213
+ query={at?.query ?? ''}
214
+ canCreate={false}
215
+ labels={labels}
216
+ onActive={setActive}
217
+ onChoose={insert}
218
+ onRetry={() => {}}
219
+ />
220
+ </Popover.Content>
221
+ </Popover.Portal>
222
+ </Popover.Root>
223
+ <div className="flex items-center justify-end gap-3 px-2 pb-2">
224
+ <span className="text-meta text-fg-secondary">{submitHint ?? (isMac() ? '⌘Enter to send' : 'Ctrl+Enter to send')}</span>
225
+ {submitLabel !== null && (
226
+ <Button size="sm" disabled={off || !text.trim()} onClick={submit}>
227
+ {submitLabel}
228
+ </Button>
229
+ )}
230
+ </div>
231
+ </div>
232
+ );
233
+ }
@@ -0,0 +1,109 @@
1
+ import * as MenuPrimitive from '@radix-ui/react-dropdown-menu';
2
+ import { Check, ChevronRight } from 'lucide-react';
3
+ import type { ComponentProps, ReactNode } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+
7
+ /** Menu root: open / onOpenChange, or uncontrolled with MenuTrigger. */
8
+ export const Menu = MenuPrimitive.Root;
9
+ /** The element that opens it (asChild onto a Button or IconButton). */
10
+ export const MenuTrigger = MenuPrimitive.Trigger;
11
+ export const MenuSub = MenuPrimitive.Sub;
12
+
13
+ const surface = cn(
14
+ 'z-[60] min-w-[200px] overflow-hidden rounded-lg border border-rule bg-page p-1.5 font-sans text-ui text-fg outline-none',
15
+ 'max-h-(--radix-dropdown-menu-content-available-height)',
16
+ );
17
+
18
+ export type MenuContentProps = ComponentProps<typeof MenuPrimitive.Content>;
19
+
20
+ /** MenuContent — renders in a portal (never clipped), flips to fit. No shadow: a hairline on the page colour. */
21
+ export function MenuContent({ className, sideOffset = 6, collisionPadding = 8, align = 'start', ...rest }: MenuContentProps) {
22
+ return (
23
+ <MenuPrimitive.Portal>
24
+ <MenuPrimitive.Content sideOffset={sideOffset} collisionPadding={collisionPadding} align={align} className={cn(surface, className)} {...rest} />
25
+ </MenuPrimitive.Portal>
26
+ );
27
+ }
28
+
29
+ const itemBase = cn(
30
+ 'relative flex min-h-8 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 outline-none select-none',
31
+ 'data-highlighted:bg-hover data-disabled:cursor-not-allowed data-disabled:opacity-40',
32
+ );
33
+
34
+ export type MenuItemProps = Omit<ComponentProps<typeof MenuPrimitive.Item>, 'children'> & {
35
+ children: ReactNode;
36
+ icon?: ReactNode;
37
+ /** Shortcut hint, right-aligned (⌘,). */
38
+ shortcut?: string | undefined;
39
+ /** Red text: sign out, delete. Place it last, after a separator. */
40
+ destructive?: boolean | undefined;
41
+ /** Why it is unavailable: shown under the label (disables the item). */
42
+ disabledReason?: ReactNode;
43
+ /** A second line (a workspace's role). */
44
+ description?: ReactNode;
45
+ };
46
+
47
+ /** MenuItem — one action. Keyboard: arrows, Home/End, typeahead, Enter (Radix). */
48
+ export function MenuItem({ children, icon, shortcut, destructive, disabledReason, description, disabled, className, ...rest }: MenuItemProps) {
49
+ return (
50
+ <MenuPrimitive.Item disabled={disabled || Boolean(disabledReason)} className={cn(itemBase, destructive && 'text-error-text', className)} {...rest}>
51
+ {icon && <span className="grid size-4 shrink-0 place-items-center text-fg-secondary">{icon}</span>}
52
+ <span className="flex min-w-0 flex-1 flex-col">
53
+ <span className="truncate">{children}</span>
54
+ {(disabledReason || description) && <span className="text-meta text-fg-secondary">{disabledReason ?? description}</span>}
55
+ </span>
56
+ {shortcut && <kbd className="ml-4 font-sans text-meta font-medium text-fg-secondary">{shortcut}</kbd>}
57
+ </MenuPrimitive.Item>
58
+ );
59
+ }
60
+
61
+ export type MenuCheckboxItemProps = Omit<ComponentProps<typeof MenuPrimitive.CheckboxItem>, 'children'> & { children: ReactNode; shortcut?: string | undefined };
62
+
63
+ /** A toggle in a menu; checked shows a tick. */
64
+ export function MenuCheckboxItem({ children, shortcut, className, ...rest }: MenuCheckboxItemProps) {
65
+ return (
66
+ <MenuPrimitive.CheckboxItem className={cn(itemBase, 'pl-8', className)} {...rest}>
67
+ <MenuPrimitive.ItemIndicator className="absolute left-2.5 grid size-4 place-items-center">
68
+ <Check size={14} strokeWidth={2} aria-hidden />
69
+ </MenuPrimitive.ItemIndicator>
70
+ <span className="flex-1 truncate">{children}</span>
71
+ {shortcut && <kbd className="ml-4 font-sans text-meta font-medium text-fg-secondary">{shortcut}</kbd>}
72
+ </MenuPrimitive.CheckboxItem>
73
+ );
74
+ }
75
+
76
+ /** A labelled group of items (Customer / Member when switching workspaces). */
77
+ export function MenuGroup({ label, children }: { label?: ReactNode; children: ReactNode }) {
78
+ return (
79
+ <MenuPrimitive.Group>
80
+ {label && <MenuPrimitive.Label className="px-2.5 pt-2 pb-1 text-meta font-medium text-fg-secondary">{label}</MenuPrimitive.Label>}
81
+ {children}
82
+ </MenuPrimitive.Group>
83
+ );
84
+ }
85
+
86
+ export function MenuSeparator({ className }: { className?: string | undefined }) {
87
+ return <MenuPrimitive.Separator className={cn('mx-1 my-1.5 h-px bg-rule', className)} />;
88
+ }
89
+
90
+ export type MenuSubTriggerProps = Omit<ComponentProps<typeof MenuPrimitive.SubTrigger>, 'children'> & { children: ReactNode; icon?: ReactNode };
91
+
92
+ /** Opens a nested menu (right arrow / Enter; left arrow closes). */
93
+ export function MenuSubTrigger({ children, icon, className, ...rest }: MenuSubTriggerProps) {
94
+ return (
95
+ <MenuPrimitive.SubTrigger className={cn(itemBase, 'data-[state=open]:bg-hover', className)} {...rest}>
96
+ {icon && <span className="grid size-4 shrink-0 place-items-center text-fg-secondary">{icon}</span>}
97
+ <span className="flex-1 truncate">{children}</span>
98
+ <ChevronRight size={14} aria-hidden className="text-fg-secondary" />
99
+ </MenuPrimitive.SubTrigger>
100
+ );
101
+ }
102
+
103
+ export function MenuSubContent({ className, sideOffset = 4, ...rest }: ComponentProps<typeof MenuPrimitive.SubContent>) {
104
+ return (
105
+ <MenuPrimitive.Portal>
106
+ <MenuPrimitive.SubContent sideOffset={sideOffset} collisionPadding={8} className={cn(surface, className)} {...rest} />
107
+ </MenuPrimitive.Portal>
108
+ );
109
+ }
@@ -0,0 +1,38 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type MetaLabelProps = Omit<ComponentProps<'div'>, 'children'> & {
6
+ children: ReactNode;
7
+ /** grey (default) · ink · inverse (grey on ink) · linen · yellow (legal only on an ink ground). */
8
+ tone?: 'grey' | 'ink' | 'inverse' | 'linen' | 'yellow' | undefined;
9
+ /** 11.5 · 12 · 13.5px. @default "md" */
10
+ size?: 'sm' | 'md' | 'lg' | undefined;
11
+ /** A rule beneath: hair (1px) or yellow (4px). */
12
+ rule?: 'hair' | 'yellow' | undefined;
13
+ as?: ElementType | undefined;
14
+ };
15
+
16
+ const TONE = { grey: 'text-fg-secondary', ink: 'text-fg', inverse: 'text-grey-inverse', linen: 'text-linen', yellow: 'text-yellow-accent' };
17
+ const SIZE = { sm: 'text-[11.5px]', md: 'text-meta', lg: 'text-small' };
18
+
19
+ /** MetaLabel — eyebrows, metadata, page numbers: 12px 500 grey, sentence
20
+ * case, no caps, no tracking. */
21
+ export function MetaLabel({ children, tone = 'grey', size = 'md', rule, as: Tag = 'div', className, ...rest }: MetaLabelProps) {
22
+ return (
23
+ <Tag
24
+ className={cn(
25
+ 'font-sans leading-[1.4] font-medium',
26
+ TONE[tone],
27
+ SIZE[size],
28
+ rule && 'pb-2.5',
29
+ rule === 'hair' && 'border-b border-rule',
30
+ rule === 'yellow' && 'border-b-4 border-yellow-accent',
31
+ className,
32
+ )}
33
+ {...rest}
34
+ >
35
+ {children}
36
+ </Tag>
37
+ );
38
+ }