@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (62) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/package.json +10 -4
  3. package/src/brand.css +1 -1
  4. package/src/components/ActionBar.tsx +607 -0
  5. package/src/components/ActivityTimeline.tsx +77 -0
  6. package/src/components/AppRail.tsx +141 -0
  7. package/src/components/AsyncSelect.tsx +205 -0
  8. package/src/components/AuthLayout.tsx +127 -0
  9. package/src/components/Avatar.tsx +75 -0
  10. package/src/components/BottomSheet.tsx +151 -0
  11. package/src/components/BottomTabs.tsx +102 -0
  12. package/src/components/BrandLoader.tsx +122 -0
  13. package/src/components/Breadcrumb.tsx +129 -0
  14. package/src/components/Button.tsx +48 -40
  15. package/src/components/ChoiceCards.tsx +68 -0
  16. package/src/components/CodeInput.tsx +150 -0
  17. package/src/components/CommandPalette.tsx +228 -0
  18. package/src/components/ConfirmDialog.tsx +294 -0
  19. package/src/components/CopyField.tsx +104 -0
  20. package/src/components/DateInput.tsx +366 -0
  21. package/src/components/Dialog.tsx +27 -3
  22. package/src/components/Dropzone.tsx +187 -0
  23. package/src/components/FileCard.tsx +233 -0
  24. package/src/components/FileRow.tsx +159 -0
  25. package/src/components/FolderTree.tsx +377 -0
  26. package/src/components/ListToolbar.tsx +177 -0
  27. package/src/components/MentionInput.tsx +233 -0
  28. package/src/components/Menu.tsx +109 -0
  29. package/src/components/PageHeader.tsx +62 -0
  30. package/src/components/PageState.tsx +84 -0
  31. package/src/components/Popover.tsx +46 -0
  32. package/src/components/Progress.tsx +97 -0
  33. package/src/components/PulseDot.tsx +31 -0
  34. package/src/components/RecipientsInput.tsx +252 -0
  35. package/src/components/SearchField.tsx +137 -0
  36. package/src/components/SegmentedControl.tsx +95 -0
  37. package/src/components/SidePanel.tsx +130 -0
  38. package/src/components/Skeleton.tsx +73 -0
  39. package/src/components/StageProgress.tsx +51 -0
  40. package/src/components/Steps.tsx +58 -0
  41. package/src/components/SyncStatus.tsx +56 -0
  42. package/src/components/ThemeSwitch.tsx +99 -0
  43. package/src/components/Toast.tsx +45 -8
  44. package/src/components/UploadCheck.tsx +118 -0
  45. package/src/components/UploadList.tsx +176 -0
  46. package/src/components/VirtualList.tsx +90 -0
  47. package/src/components/WindowDrop.tsx +131 -0
  48. package/src/components/WorkspaceRow.tsx +80 -0
  49. package/src/components/inputs-aria.ts +5 -0
  50. package/src/components/inputs-date.ts +92 -0
  51. package/src/components/inputs-listbox.tsx +154 -0
  52. package/src/components/inputs-search.ts +60 -0
  53. package/src/components/status-files.ts +124 -0
  54. package/src/components/status-motion.ts +30 -0
  55. package/src/components/useSelection.ts +142 -0
  56. package/src/index.ts +52 -1
  57. package/src/theme-base.css +123 -0
  58. package/src/theme-reset.css +14 -0
  59. package/src/theme.css +2 -125
  60. package/src/components/core.test.tsx +0 -242
  61. package/src/components/forms.test.tsx +0 -280
  62. package/src/components/overlays.test.tsx +0 -245
@@ -0,0 +1,90 @@
1
+ import { useVirtualizer } from '@tanstack/react-virtual';
2
+ import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Spinner } from './Spinner';
6
+
7
+ export type VirtualListProps<T> = {
8
+ items: readonly T[];
9
+ /** Estimated row height in px (rows may differ; they are measured). @default 42 */
10
+ estimateSize?: number | undefined;
11
+ renderItem: (item: T, index: number) => ReactNode;
12
+ getKey?: ((item: T, index: number) => string | number) | undefined;
13
+ /** Rows rendered beyond the viewport. @default 8 */
14
+ overscan?: number | undefined;
15
+ /** The scroll box's height (the list scrolls inside it). */
16
+ height: number | string;
17
+ /** Accessible name of the list. */
18
+ label: string;
19
+ className?: string | undefined;
20
+ /** Rendered after the last row: an InfiniteLoader. */
21
+ footer?: ReactNode;
22
+ };
23
+
24
+ /**
25
+ * VirtualList — renders only the visible rows of a long list (thousands of
26
+ * items), measuring each as it appears. The rows are list items.
27
+ */
28
+ export function VirtualList<T>({ items, estimateSize = 42, renderItem, getKey, overscan = 8, height, label, className, footer }: VirtualListProps<T>) {
29
+ const scroller = useRef<HTMLDivElement | null>(null);
30
+ const v = useVirtualizer({
31
+ count: items.length,
32
+ getScrollElement: () => scroller.current,
33
+ estimateSize: () => estimateSize,
34
+ overscan,
35
+ ...(getKey ? { getItemKey: (i: number) => getKey(items[i]!, i) } : {}),
36
+ });
37
+ return (
38
+ <div ref={scroller} className={cn('overflow-auto', className)} style={{ height }}>
39
+ <ul aria-label={label} className="relative m-0 list-none p-0" style={{ height: v.getTotalSize() }}>
40
+ {v.getVirtualItems().map((row) => (
41
+ <li
42
+ key={row.key}
43
+ data-index={row.index}
44
+ ref={v.measureElement}
45
+ className="absolute top-0 left-0 w-full"
46
+ style={{ transform: `translateY(${row.start}px)` } satisfies CSSProperties}
47
+ >
48
+ {renderItem(items[row.index]!, row.index)}
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ {footer}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export type InfiniteLoaderProps = {
58
+ /** Called when the sentinel comes within `margin` of the viewport. */
59
+ onLoadMore: () => void;
60
+ hasMore: boolean;
61
+ loading?: boolean | undefined;
62
+ /** How early to load: the spec asks for 200px before the end. @default 200 */
63
+ margin?: number | undefined;
64
+ /** Read out while loading. @default "Loading more" */
65
+ loadingLabel?: string | undefined;
66
+ className?: string | undefined;
67
+ };
68
+
69
+ /** InfiniteLoader — a sentinel at the end of a list that asks for the next
70
+ * page 200px before it comes into view. */
71
+ export function InfiniteLoader({ onLoadMore, hasMore, loading = false, margin = 200, loadingLabel = 'Loading more', className }: InfiniteLoaderProps) {
72
+ const el = useRef<HTMLDivElement | null>(null);
73
+ const latest = useRef(onLoadMore);
74
+ latest.current = onLoadMore;
75
+
76
+ useEffect(() => {
77
+ const node = el.current;
78
+ if (!node || !hasMore || loading || typeof IntersectionObserver === 'undefined') return;
79
+ const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && latest.current(), { rootMargin: `0px 0px ${margin}px 0px` });
80
+ io.observe(node);
81
+ return () => io.disconnect();
82
+ }, [hasMore, loading, margin]);
83
+
84
+ if (!hasMore && !loading) return null;
85
+ return (
86
+ <div ref={el} className={cn('flex h-10 items-center justify-center text-fg-secondary', className)}>
87
+ {loading && <Spinner label={loadingLabel} />}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,131 @@
1
+ import { Upload } from 'lucide-react';
2
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Icon } from './Icon';
6
+ import { hasFiles, readDataTransfer, type DroppedFile } from './status-files';
7
+
8
+ type WindowDropContextValue = {
9
+ /** Files are being dragged over the window: every drop target lights up. */
10
+ dragging: boolean;
11
+ /** A local zone under the pointer names its folder here (null on leave). */
12
+ setHoverTarget: (target: string | null) => void;
13
+ };
14
+
15
+ const WindowDropContext = createContext<WindowDropContextValue>({ dragging: false, setHoverTarget: () => {} });
16
+
17
+ /** Inside a WindowDrop: whether files are being dragged over the window. */
18
+ export function useWindowDrop(): WindowDropContextValue {
19
+ return useContext(WindowDropContext);
20
+ }
21
+
22
+ export type WindowDropProps = {
23
+ children: ReactNode;
24
+ /** Where a drop anywhere goes: the current folder, or "My uploads". */
25
+ target: string;
26
+ /** A drop outside any local zone. */
27
+ onDrop: (items: DroppedFile[], target: string) => void;
28
+ /** Read-only page: no layer — a line says you can only view here. */
29
+ readOnly?: boolean | undefined;
30
+ /** Force the layer open (previews, screenshots). */
31
+ open?: boolean | undefined;
32
+ onOpenChange?: ((open: boolean) => void) | undefined;
33
+ labels?: { release?: (target: string) => string; viewOnly?: string; close?: string } | undefined;
34
+ };
35
+
36
+ const dialogOpen = () => typeof document !== 'undefined' && document.querySelector('[role="dialog"], [role="alertdialog"]') !== null;
37
+
38
+ /**
39
+ * WindowDrop — the whole window accepts a drop while files are dragged in
40
+ * (only files: text, links or cards dragged on the page never trigger it).
41
+ * A layer above the page, below dialogs, names where the files will go;
42
+ * hovering a folder or a local Dropzone changes that wording. It leaves at
43
+ * once when the drag exits the window, on Esc, or after the drop.
44
+ */
45
+ export function WindowDrop({ children, target, onDrop, readOnly = false, open, onOpenChange, labels }: WindowDropProps) {
46
+ const [dragging, setDragging] = useState(false);
47
+ const [hoverTarget, setHoverTarget] = useState<string | null>(null);
48
+ const depth = useRef(0);
49
+
50
+ const set = useCallback(
51
+ (v: boolean) => {
52
+ setDragging(v);
53
+ if (!v) setHoverTarget(null);
54
+ onOpenChange?.(v);
55
+ },
56
+ [onOpenChange],
57
+ );
58
+
59
+ useEffect(() => {
60
+ const enter = (e: DragEvent) => {
61
+ if (!hasFiles(e.dataTransfer) || dialogOpen()) return;
62
+ depth.current += 1;
63
+ if (depth.current === 1) set(true);
64
+ };
65
+ const over = (e: DragEvent) => {
66
+ if (!hasFiles(e.dataTransfer) || dialogOpen()) return;
67
+ e.preventDefault(); // allow the drop
68
+ if (e.dataTransfer) e.dataTransfer.dropEffect = readOnly ? 'none' : 'copy';
69
+ };
70
+ const leave = (e: DragEvent) => {
71
+ if (!hasFiles(e.dataTransfer)) return;
72
+ depth.current = Math.max(0, depth.current - 1);
73
+ if (depth.current === 0) set(false);
74
+ };
75
+ const drop = (e: DragEvent) => {
76
+ if (!hasFiles(e.dataTransfer)) return;
77
+ e.preventDefault();
78
+ depth.current = 0;
79
+ set(false);
80
+ if (readOnly || !e.dataTransfer) return;
81
+ void readDataTransfer(e.dataTransfer).then((items) => items.length && onDrop(items, target));
82
+ };
83
+ const key = (e: KeyboardEvent) => {
84
+ if (e.key === 'Escape') {
85
+ depth.current = 0;
86
+ set(false);
87
+ }
88
+ };
89
+ window.addEventListener('dragenter', enter);
90
+ window.addEventListener('dragover', over);
91
+ window.addEventListener('dragleave', leave);
92
+ window.addEventListener('drop', drop);
93
+ window.addEventListener('keydown', key);
94
+ return () => {
95
+ window.removeEventListener('dragenter', enter);
96
+ window.removeEventListener('dragover', over);
97
+ window.removeEventListener('dragleave', leave);
98
+ window.removeEventListener('drop', drop);
99
+ window.removeEventListener('keydown', key);
100
+ };
101
+ }, [onDrop, readOnly, set, target]);
102
+
103
+ const shown = open ?? dragging;
104
+ const release = labels?.release ?? ((t: string) => `Release to upload to ${t}`);
105
+
106
+ return (
107
+ <WindowDropContext.Provider value={{ dragging: shown, setHoverTarget }}>
108
+ {children}
109
+ {shown && (
110
+ <div
111
+ data-window-drop
112
+ role="presentation"
113
+ onClick={() => set(false)}
114
+ className="pointer-events-none fixed inset-0 z-40 flex items-end justify-center bg-page/60 p-6 font-sans"
115
+ >
116
+ <div
117
+ role="status"
118
+ aria-live="polite"
119
+ className={cn(
120
+ 'pointer-events-auto flex items-center gap-3 rounded-lg px-5 py-4 text-body font-medium',
121
+ readOnly ? 'bg-ink text-linen' : 'border-2 border-ink bg-brand-field text-ink',
122
+ )}
123
+ >
124
+ {!readOnly && <Icon icon={Upload} size={18} />}
125
+ {readOnly ? (labels?.viewOnly ?? 'You can only view here') : release(hoverTarget ?? target)}
126
+ </div>
127
+ </div>
128
+ )}
129
+ </WindowDropContext.Provider>
130
+ );
131
+ }
@@ -0,0 +1,80 @@
1
+ import { ChevronRight } from 'lucide-react';
2
+ import { forwardRef, useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Badge } from './Badge';
6
+ import { Spinner } from './Spinner';
7
+
8
+ export type WorkspaceMarkProps = {
9
+ name: string;
10
+ /** The workspace's logo; its initial on the yellow when unset or it fails to load. */
11
+ logoUrl?: string | undefined;
12
+ /** px. @default 36 */
13
+ size?: number | undefined;
14
+ };
15
+
16
+ /** WorkspaceMark — a workspace's logo, or its initial on the brand yellow. */
17
+ export function WorkspaceMark({ name, logoUrl, size = 36 }: WorkspaceMarkProps) {
18
+ const [failed, setFailed] = useState(false);
19
+ const img = useRef<HTMLImageElement>(null);
20
+ // A server-rendered page can fail the image before React is listening:
21
+ // onError never fires, so look once it is.
22
+ useEffect(() => {
23
+ const el = img.current;
24
+ if (el?.complete && el.naturalWidth === 0) setFailed(true);
25
+ }, [logoUrl]);
26
+ if (logoUrl && !failed) {
27
+ return <img ref={img} src={logoUrl} alt="" aria-hidden style={{ width: size, height: size }} className="shrink-0 rounded-[8px] object-cover" onError={() => setFailed(true)} />;
28
+ }
29
+ return (
30
+ <span aria-hidden style={{ width: size, height: size }} className="grid shrink-0 place-items-center rounded-[8px] bg-brand-field text-[13px] font-semibold text-ink">
31
+ {name.trim().charAt(0).toUpperCase()}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ export type WorkspaceRowProps = Omit<ComponentProps<'button'>, 'children'> & {
37
+ name: string;
38
+ /** The second line: the workspace's short name or address. */
39
+ detail?: ReactNode;
40
+ logoUrl?: string | undefined;
41
+ /** Being opened: a spinner in place of the chevron. */
42
+ busy?: boolean | undefined;
43
+ /** A state beside the chevron, as an attention badge: "Needs setup". */
44
+ badge?: ReactNode;
45
+ };
46
+
47
+ /**
48
+ * WorkspaceRow — one workspace in a chooser: mark, name, detail, chevron;
49
+ * `badge` says what it still needs ("Needs setup").
50
+ * The whole row is the button; `busy` shows it opening (disable the others).
51
+ */
52
+ export const WorkspaceRow = forwardRef<HTMLButtonElement, WorkspaceRowProps>(function WorkspaceRow(
53
+ { name, detail, logoUrl, busy = false, badge, className, ...rest },
54
+ ref,
55
+ ) {
56
+ return (
57
+ <button
58
+ ref={ref}
59
+ type="button"
60
+ aria-busy={busy || undefined}
61
+ className={cn(
62
+ 'flex w-full cursor-pointer items-center gap-3 rounded-[12px] border-0 bg-page px-3.5 py-3 text-left text-fg shadow-[inset_0_0_0_1px_var(--rule)] outline-none transition-colors duration-[120ms] hover:bg-hover focus-visible:shadow-(--focus-ring) disabled:cursor-default',
63
+ className,
64
+ )}
65
+ {...rest}
66
+ >
67
+ <WorkspaceMark name={name} logoUrl={logoUrl} />
68
+ <span className="flex min-w-0 flex-1 flex-col leading-[1.35]">
69
+ <span className="truncate text-[14px] font-medium">{name}</span>
70
+ {detail && <span className="truncate text-[12px] text-fg-secondary">{detail}</span>}
71
+ </span>
72
+ {badge && (
73
+ <span className="flex shrink-0">
74
+ <Badge status="attention">{badge}</Badge>
75
+ </span>
76
+ )}
77
+ {busy ? <Spinner size={16} /> : <ChevronRight size={16} aria-hidden className="text-fg-secondary" />}
78
+ </button>
79
+ );
80
+ });
@@ -0,0 +1,5 @@
1
+ /** Only the aria props that were given — Radix roots reject explicit
2
+ * undefined under exactOptionalPropertyTypes. */
3
+ export function definedAria(aria: Record<string, string | undefined>): Record<string, string> {
4
+ return Object.fromEntries(Object.entries(aria).filter(([, v]) => v !== undefined)) as Record<string, string>;
5
+ }
@@ -0,0 +1,92 @@
1
+ // Date parsing for DateInput / FuzzyDateInput. Values are ISO strings
2
+ // (YYYY-MM-DD, or YYYY-MM / YYYY for a fuzzy date) — no time zones.
3
+
4
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
5
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
6
+
7
+ export type PartialDate = { year: number; month?: number | undefined; day?: number | undefined };
8
+
9
+ const pad = (n: number) => String(n).padStart(2, '0');
10
+
11
+ export function daysIn(year: number, month: number): number {
12
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
13
+ }
14
+
15
+ function valid(p: PartialDate): boolean {
16
+ if (!Number.isInteger(p.year) || p.year < 1000 || p.year > 9999) return false;
17
+ if (p.month === undefined) return p.day === undefined;
18
+ if (p.month < 1 || p.month > 12) return false;
19
+ if (p.day === undefined) return true;
20
+ return p.day >= 1 && p.day <= daysIn(p.year, p.month);
21
+ }
22
+
23
+ function monthOf(word: string): number | undefined {
24
+ const i = MONTHS.indexOf(word.slice(0, 3).toLowerCase());
25
+ return i < 0 ? undefined : i + 1;
26
+ }
27
+
28
+ /**
29
+ * Parse what people type: 12/03/2019 (day first, British), 12.03.2019,
30
+ * 2019-03-12, 12 Mar 2019, March 12 2019, 2019年3月12日 — and, when
31
+ * `partial`, 2019-03, 03/2019, Mar 2019, 2019年3月, 2019. Returns null when
32
+ * it cannot tell.
33
+ */
34
+ export function parseDate(text: string, { partial = false }: { partial?: boolean } = {}): PartialDate | null {
35
+ const t = text.trim().replace(/\s+/g, ' ');
36
+ if (!t) return null;
37
+ let m: RegExpMatchArray | null;
38
+ let p: PartialDate | null = null;
39
+ if ((m = t.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/))) p = { year: +m[1]!, month: +m[2]!, day: +m[3]! };
40
+ else if ((m = t.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/))) p = { year: +m[3]!, month: +m[2]!, day: +m[1]! };
41
+ else if ((m = t.match(/^(\d{1,2}) ([a-z]+)\.?,? (\d{4})$/i)) && monthOf(m[2]!)) p = { year: +m[3]!, month: monthOf(m[2]!), day: +m[1]! };
42
+ else if ((m = t.match(/^([a-z]+)\.? (\d{1,2}),? (\d{4})$/i)) && monthOf(m[1]!)) p = { year: +m[3]!, month: monthOf(m[1]!), day: +m[2]! };
43
+ else if ((m = t.match(/^(\d{4}) ?年 ?(\d{1,2}) ?月 ?(\d{1,2}) ?日?$/))) p = { year: +m[1]!, month: +m[2]!, day: +m[3]! };
44
+ else if (partial) {
45
+ if ((m = t.match(/^(\d{4})[-/.](\d{1,2})$/))) p = { year: +m[1]!, month: +m[2]! };
46
+ else if ((m = t.match(/^(\d{1,2})[-/.](\d{4})$/))) p = { year: +m[2]!, month: +m[1]! };
47
+ else if ((m = t.match(/^([a-z]+)\.? (\d{4})$/i)) && monthOf(m[1]!)) p = { year: +m[2]!, month: monthOf(m[1]!) };
48
+ else if ((m = t.match(/^(\d{4}) ?年 ?(\d{1,2}) ?月$/))) p = { year: +m[1]!, month: +m[2]! };
49
+ else if ((m = t.match(/^(\d{4})$/))) p = { year: +m[1]! };
50
+ }
51
+ return p && valid(p) ? p : null;
52
+ }
53
+
54
+ /** 2019-03-12 · 2019-03 · 2019 */
55
+ export function toIso(p: PartialDate): string {
56
+ if (p.month === undefined) return String(p.year);
57
+ if (p.day === undefined) return `${p.year}-${pad(p.month)}`;
58
+ return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
59
+ }
60
+
61
+ /** Inverse of toIso; null for anything else. */
62
+ export function fromIso(iso: string | null | undefined): PartialDate | null {
63
+ if (!iso) return null;
64
+ const m = iso.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/);
65
+ if (!m) return null;
66
+ const p: PartialDate = { year: +m[1]!, month: m[2] ? +m[2] : undefined, day: m[3] ? +m[3] : undefined };
67
+ return valid(p) ? p : null;
68
+ }
69
+
70
+ /** 12 Mar 2019 · Mar 2019 · 2019 — or 2019 年 3 月 12 日 in Chinese. */
71
+ export function formatDate(p: PartialDate, lang: 'en' | 'zh' = 'en'): string {
72
+ if (lang === 'zh') {
73
+ return `${p.year} 年${p.month ? ` ${p.month} 月` : ''}${p.day ? ` ${p.day} 日` : ''}`;
74
+ }
75
+ if (p.month === undefined) return String(p.year);
76
+ const mon = MONTH_NAMES[p.month - 1]!;
77
+ return p.day === undefined ? `${mon} ${p.year}` : `${p.day} ${mon} ${p.year}`;
78
+ }
79
+
80
+ export function toDate(p: PartialDate): Date {
81
+ return new Date(p.year, (p.month ?? 1) - 1, p.day ?? 1);
82
+ }
83
+
84
+ export function fromDate(d: Date): PartialDate {
85
+ return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
86
+ }
87
+
88
+ /** zh when the element (or page) is Chinese. */
89
+ export function langOf(el: Element | null): 'en' | 'zh' {
90
+ const lang = (el?.closest('[lang]')?.getAttribute('lang') ?? (typeof document !== 'undefined' ? document.documentElement.lang : '')).toLowerCase();
91
+ return lang.startsWith('zh') ? 'zh' : 'en';
92
+ }
@@ -0,0 +1,154 @@
1
+ import { Plus } from 'lucide-react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import type { SearchStatus } from './inputs-search';
6
+ import { Spinner } from './Spinner';
7
+
8
+ export type ListItem = {
9
+ value: string;
10
+ label: string;
11
+ /** Right-hand line: Customer · 3 projects. */
12
+ meta?: string | undefined;
13
+ /** Initials in the avatar square (HV). Derived from the label if omitted. */
14
+ initials?: string | undefined;
15
+ };
16
+
17
+ export function initialsOf(item: ListItem): string {
18
+ if (item.initials) return item.initials;
19
+ const words = item.label.trim().split(/\s+/);
20
+ const first = words[0] ?? '';
21
+ // One hanzi reads as an initial on its own (王).
22
+ if (/[㐀-鿿]/.test(first)) return first[0]!;
23
+ return ((first[0] ?? '') + (words.length > 1 ? (words[words.length - 1]![0] ?? '') : '')).toUpperCase();
24
+ }
25
+
26
+ export function Avatar({ item, className }: { item: ListItem; className?: string | undefined }) {
27
+ return (
28
+ <span aria-hidden className={cn('inline-grid size-6 shrink-0 place-items-center rounded-pill bg-well text-[10.5px] font-semibold text-fg', className)}>
29
+ {initialsOf(item)}
30
+ </span>
31
+ );
32
+ }
33
+
34
+ export type ListboxLabels = {
35
+ searching: string;
36
+ empty: (query: string) => string;
37
+ error: string;
38
+ retry: string;
39
+ create: (query: string) => string;
40
+ };
41
+
42
+ export const DEFAULT_LABELS: ListboxLabels = {
43
+ searching: 'Searching',
44
+ empty: (q) => `Nothing matches “${q}”.`,
45
+ error: 'Couldn’t load results.',
46
+ retry: 'Retry',
47
+ create: (q) => `Create “${q}”`,
48
+ };
49
+
50
+ /** Rows of an async listbox: results, then an optional Create row; or one state row. */
51
+ export function ListboxBody({
52
+ id,
53
+ optionId,
54
+ items,
55
+ active,
56
+ status,
57
+ query,
58
+ canCreate,
59
+ labels,
60
+ onActive,
61
+ onChoose,
62
+ onRetry,
63
+ selectedValues,
64
+ }: {
65
+ id: string;
66
+ optionId: (i: number) => string;
67
+ items: ListItem[];
68
+ /** Index of the active row; items.length means the Create row. */
69
+ active: number;
70
+ status: SearchStatus;
71
+ query: string;
72
+ canCreate: boolean;
73
+ labels: ListboxLabels;
74
+ onActive: (i: number) => void;
75
+ onChoose: (i: number) => void;
76
+ onRetry: () => void;
77
+ selectedValues?: ReadonlySet<string>;
78
+ }) {
79
+ const row = (i: number, content: ReactNode, selected = false) => (
80
+ <div
81
+ key={optionId(i)}
82
+ id={optionId(i)}
83
+ role="option"
84
+ aria-selected={selected}
85
+ data-active={i === active || undefined}
86
+ onMouseMove={() => i !== active && onActive(i)}
87
+ onMouseDown={(e) => e.preventDefault()}
88
+ onClick={() => onChoose(i)}
89
+ className={cn(
90
+ 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[13px] leading-[1.35] text-fg',
91
+ i === active ? 'bg-brand-field text-ink' : selected && 'bg-hover',
92
+ )}
93
+ >
94
+ {content}
95
+ </div>
96
+ );
97
+
98
+ let state: ReactNode = null;
99
+ if (status === 'searching') {
100
+ state = (
101
+ <div className="flex items-center gap-2 px-2.5 py-2.5 text-[12.5px] text-fg-secondary">
102
+ <Spinner size={12} /> {labels.searching}
103
+ </div>
104
+ );
105
+ } else if (status === 'error') {
106
+ state = (
107
+ <div className="flex items-center gap-2 px-2.5 py-2 text-[12.5px] text-fg-secondary">
108
+ <span className="flex-1">{labels.error}</span>
109
+ <button
110
+ type="button"
111
+ onMouseDown={(e) => e.preventDefault()}
112
+ onClick={onRetry}
113
+ className="cursor-pointer rounded-xs font-medium text-fg underline underline-offset-3 outline-none focus-visible:shadow-(--focus-ring)"
114
+ >
115
+ {labels.retry}
116
+ </button>
117
+ </div>
118
+ );
119
+ } else if (status === 'done' && items.length === 0 && !canCreate) {
120
+ state = <div className="px-2.5 py-2.5 text-[12.5px] text-fg-secondary">{labels.empty(query)}</div>;
121
+ }
122
+
123
+ return (
124
+ <div id={id} role="listbox" className="flex max-h-[260px] flex-col overflow-y-auto">
125
+ {status === 'done' &&
126
+ items.map((it, i) =>
127
+ row(
128
+ i,
129
+ <>
130
+ <Avatar item={it} className={i === active ? 'bg-ink text-linen' : undefined} />
131
+ <span className="min-w-0 flex-1 truncate">{it.label}</span>
132
+ {it.meta && <span className="shrink-0 text-meta text-fg-secondary">{it.meta}</span>}
133
+ </>,
134
+ selectedValues?.has(it.value),
135
+ ),
136
+ )}
137
+ {status === 'done' && items.length === 0 && canCreate && (
138
+ <div className="px-2.5 pt-1.5 pb-1 text-meta text-fg-secondary">{labels.empty(query)}</div>
139
+ )}
140
+ {status === 'done' &&
141
+ canCreate &&
142
+ row(
143
+ items.length,
144
+ <>
145
+ <span aria-hidden className="inline-grid size-6 shrink-0 place-items-center rounded-sm border border-dashed border-rule text-fg-secondary">
146
+ <Plus size={13} />
147
+ </span>
148
+ <span className="font-medium">{labels.create(query)}</span>
149
+ </>,
150
+ )}
151
+ {state}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,60 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export type SearchStatus = 'idle' | 'searching' | 'done' | 'error';
4
+
5
+ /**
6
+ * Debounced, cancellable search. Each new query aborts the previous
7
+ * request (its AbortSignal fires) and a late answer to an old query is
8
+ * dropped, so results never flicker back. While a request is pending the
9
+ * status is "searching" — callers show a spinner, never "No results".
10
+ */
11
+ export function useDebouncedSearch<T>(
12
+ query: string,
13
+ search: ((query: string, signal: AbortSignal) => Promise<T>) | undefined,
14
+ { delay = 250, minLength = 1 }: { delay?: number; minLength?: number } = {},
15
+ ) {
16
+ const [status, setStatus] = useState<SearchStatus>('idle');
17
+ const [result, setResult] = useState<T | undefined>(undefined);
18
+ const [error, setError] = useState<unknown>(null);
19
+ const [attempt, setAttempt] = useState(0);
20
+ const seq = useRef(0);
21
+ const ctrl = useRef<AbortController | null>(null);
22
+
23
+ useEffect(() => {
24
+ const q = query.trim();
25
+ ctrl.current?.abort();
26
+ ctrl.current = null;
27
+ if (!search || q.length < minLength) {
28
+ seq.current++;
29
+ setStatus('idle');
30
+ setResult(undefined);
31
+ setError(null);
32
+ return;
33
+ }
34
+ setStatus('searching');
35
+ const mine = ++seq.current;
36
+ const timer = setTimeout(() => {
37
+ const c = new AbortController();
38
+ ctrl.current = c;
39
+ search(q, c.signal).then(
40
+ (r) => {
41
+ if (mine !== seq.current || c.signal.aborted) return;
42
+ setResult(r);
43
+ setError(null);
44
+ setStatus('done');
45
+ },
46
+ (e: unknown) => {
47
+ if (mine !== seq.current || c.signal.aborted) return;
48
+ setError(e);
49
+ setStatus('error');
50
+ },
51
+ );
52
+ }, delay);
53
+ return () => clearTimeout(timer);
54
+ }, [query, search, delay, minLength, attempt]);
55
+
56
+ useEffect(() => () => ctrl.current?.abort(), []);
57
+
58
+ const retry = useCallback(() => setAttempt((a) => a + 1), []);
59
+ return { status, result, error, retry };
60
+ }