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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/package.json +32 -3
  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/Badge.tsx +68 -0
  8. package/src/components/Breadcrumb.tsx +129 -0
  9. package/src/components/Button.tsx +193 -0
  10. package/src/components/Card.tsx +42 -0
  11. package/src/components/Checkbox.tsx +67 -0
  12. package/src/components/ChoiceCards.tsx +68 -0
  13. package/src/components/CodeInput.tsx +150 -0
  14. package/src/components/CommandPalette.tsx +228 -0
  15. package/src/components/ConfirmDialog.tsx +294 -0
  16. package/src/components/CopyField.tsx +104 -0
  17. package/src/components/DateInput.tsx +366 -0
  18. package/src/components/Dialog.tsx +135 -0
  19. package/src/components/Dropzone.tsx +187 -0
  20. package/src/components/Field.tsx +66 -0
  21. package/src/components/Figure.tsx +30 -0
  22. package/src/components/FileCard.tsx +233 -0
  23. package/src/components/FileRow.tsx +159 -0
  24. package/src/components/FolderTree.tsx +377 -0
  25. package/src/components/Icon.tsx +32 -0
  26. package/src/components/IconButton.tsx +55 -0
  27. package/src/components/Input.tsx +74 -0
  28. package/src/components/ListToolbar.tsx +177 -0
  29. package/src/components/Logo.tsx +62 -0
  30. package/src/components/Marker.tsx +26 -0
  31. package/src/components/MentionInput.tsx +233 -0
  32. package/src/components/Menu.tsx +109 -0
  33. package/src/components/MetaLabel.tsx +38 -0
  34. package/src/components/PageHeader.tsx +62 -0
  35. package/src/components/PageState.tsx +84 -0
  36. package/src/components/Progress.tsx +97 -0
  37. package/src/components/PulseDot.tsx +31 -0
  38. package/src/components/Radio.tsx +72 -0
  39. package/src/components/RecipientsInput.tsx +252 -0
  40. package/src/components/SearchField.tsx +137 -0
  41. package/src/components/SegmentedControl.tsx +95 -0
  42. package/src/components/Select.tsx +351 -0
  43. package/src/components/SidePanel.tsx +130 -0
  44. package/src/components/Skeleton.tsx +73 -0
  45. package/src/components/Spinner.tsx +20 -0
  46. package/src/components/StageProgress.tsx +51 -0
  47. package/src/components/Steps.tsx +58 -0
  48. package/src/components/Switch.tsx +67 -0
  49. package/src/components/SyncStatus.tsx +56 -0
  50. package/src/components/Table.tsx +196 -0
  51. package/src/components/Tabs.tsx +124 -0
  52. package/src/components/Tag.tsx +35 -0
  53. package/src/components/Textarea.tsx +46 -0
  54. package/src/components/ThemeProvider.tsx +80 -0
  55. package/src/components/Toast.tsx +159 -0
  56. package/src/components/Tooltip.tsx +57 -0
  57. package/src/components/UploadCheck.tsx +118 -0
  58. package/src/components/UploadList.tsx +176 -0
  59. package/src/components/VirtualList.tsx +90 -0
  60. package/src/components/WindowDrop.tsx +131 -0
  61. package/src/components/forms-control.ts +36 -0
  62. package/src/components/inputs-aria.ts +5 -0
  63. package/src/components/inputs-date.ts +92 -0
  64. package/src/components/inputs-listbox.tsx +154 -0
  65. package/src/components/inputs-search.ts +60 -0
  66. package/src/components/logo-art.ts +4 -0
  67. package/src/components/status-files.ts +124 -0
  68. package/src/components/status-motion.ts +30 -0
  69. package/src/components/useButtonAction.ts +57 -0
  70. package/src/components/useSelection.ts +142 -0
  71. package/src/index.ts +67 -2
  72. package/src/modes.css +10 -0
  73. package/src/styles.css +2 -0
  74. package/src/theme.css +11 -0
@@ -0,0 +1,130 @@
1
+ import { X } from 'lucide-react';
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useState,
10
+ type ComponentProps,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import { createPortal } from 'react-dom';
14
+
15
+ import { cn } from '../lib/cn';
16
+ import { IconButton } from './IconButton';
17
+
18
+ type SlotContext = {
19
+ /** The panel in the slot, if any. */
20
+ openId: string | null;
21
+ open: (id: string) => void;
22
+ close: (id?: string) => void;
23
+ slot: HTMLElement | null;
24
+ setSlot: (el: HTMLElement | null) => void;
25
+ };
26
+
27
+ const SidePanelContext = createContext<SlotContext | null>(null);
28
+
29
+ /**
30
+ * SidePanelProvider — the page's one right-hand slot. Opening a panel
31
+ * closes whatever held the slot (Share and the Agent drawer never show
32
+ * together). Place <SidePanelSlot/> beside the main content.
33
+ */
34
+ export function SidePanelProvider({ children }: { children: ReactNode }) {
35
+ const [openId, setOpenId] = useState<string | null>(null);
36
+ const [slot, setSlot] = useState<HTMLElement | null>(null);
37
+ const open = useCallback((id: string) => setOpenId(id), []);
38
+ const close = useCallback((id?: string) => setOpenId((cur) => (id === undefined || cur === id ? null : cur)), []);
39
+ const value = useMemo(() => ({ openId, open, close, slot, setSlot }), [openId, open, close, slot]);
40
+ return <SidePanelContext.Provider value={value}>{children}</SidePanelContext.Provider>;
41
+ }
42
+
43
+ /** The slot's state and controls. */
44
+ export function useSidePanel() {
45
+ const ctx = useContext(SidePanelContext);
46
+ if (!ctx) throw new Error('useSidePanel needs a <SidePanelProvider>');
47
+ const { openId, open, close } = ctx;
48
+ return { openId, open, close };
49
+ }
50
+
51
+ /** Where the open panel renders: a flex sibling of the main content, so it pushes the content (no overlay). */
52
+ export function SidePanelSlot({ className, ...rest }: ComponentProps<'div'>) {
53
+ const ctx = useContext(SidePanelContext);
54
+ const setSlot = ctx?.setSlot;
55
+ return <div ref={setSlot} className={cn('flex h-full shrink-0', className)} {...rest} />;
56
+ }
57
+
58
+ const WIDTH = { sm: 'sm:w-[360px]', md: 'sm:w-[480px]', lg: 'sm:w-[640px]' } as const;
59
+
60
+ export type SidePanelProps = Omit<ComponentProps<'aside'>, 'title'> & {
61
+ /** The panel's id in the slot. */
62
+ id: string;
63
+ title: ReactNode;
64
+ /** A line above the title (the item it is about). */
65
+ eyebrow?: ReactNode;
66
+ /** 360 · 480 · 640. @default "sm" */
67
+ width?: keyof typeof WIDTH | undefined;
68
+ /** Pinned at the bottom (New share). */
69
+ footer?: ReactNode;
70
+ /** @default "Close" */
71
+ closeLabel?: string | undefined;
72
+ onClose?: (() => void) | undefined;
73
+ };
74
+
75
+ /**
76
+ * SidePanel — detail beside the page (sharing, properties, the Agent).
77
+ * No scrim and no focus trap: the page stays usable; it pushes the content
78
+ * aside. Esc closes it (unless a dialog is open above). On phones it pushes
79
+ * in full screen. Renders only while it holds the slot.
80
+ */
81
+ export function SidePanel({ id, title, eyebrow, width = 'sm', footer, closeLabel = 'Close', onClose, className, children, ...rest }: SidePanelProps) {
82
+ const ctx = useContext(SidePanelContext);
83
+ if (!ctx) throw new Error('SidePanel needs a <SidePanelProvider>');
84
+ const { openId, close, slot } = ctx;
85
+ const isOpen = openId === id;
86
+ const titleId = useId();
87
+
88
+ const dismiss = useCallback(() => {
89
+ close(id);
90
+ onClose?.();
91
+ }, [close, id, onClose]);
92
+
93
+ useEffect(() => {
94
+ if (!isOpen) return;
95
+ const onKey = (e: KeyboardEvent) => {
96
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
97
+ if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) return;
98
+ dismiss();
99
+ };
100
+ document.addEventListener('keydown', onKey);
101
+ return () => document.removeEventListener('keydown', onKey);
102
+ }, [isOpen, dismiss]);
103
+
104
+ if (!isOpen || !slot) return null;
105
+ return createPortal(
106
+ <aside
107
+ aria-labelledby={titleId}
108
+ className={cn(
109
+ 'flex h-full flex-col border-l border-rule bg-page font-sans text-fg',
110
+ 'max-sm:fixed max-sm:inset-0 max-sm:z-40 max-sm:w-full max-sm:border-l-0',
111
+ WIDTH[width],
112
+ className,
113
+ )}
114
+ {...rest}
115
+ >
116
+ <header className="flex items-start gap-3 border-b border-rule px-5 py-4">
117
+ <div className="min-w-0 flex-1">
118
+ {eyebrow && <div className="mb-1 text-meta font-medium text-fg-secondary">{eyebrow}</div>}
119
+ <h2 id={titleId} className="m-0 truncate text-[16px] leading-[1.3] font-medium">
120
+ {title}
121
+ </h2>
122
+ </div>
123
+ <IconButton icon={X} label={closeLabel} size="sm" onClick={dismiss} className="-mt-1 -mr-2" />
124
+ </header>
125
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-ui">{children}</div>
126
+ {footer && <footer className="border-t border-rule px-5 py-3.5">{footer}</footer>}
127
+ </aside>,
128
+ slot,
129
+ );
130
+ }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState, type ComponentProps } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { useLoop } from './status-motion';
5
+
6
+ export type SkeletonProps = ComponentProps<'div'>;
7
+
8
+ /** Skeleton — a sunk placeholder block that breathes slowly (still under reduced motion). */
9
+ export function Skeleton({ className, ...rest }: SkeletonProps) {
10
+ const ref = useRef<HTMLDivElement | null>(null);
11
+ useLoop(ref, [{ opacity: 1 }, { opacity: 0.55 }, { opacity: 1 }], { duration: 1600, easing: 'ease-in-out' });
12
+ return <div ref={ref} aria-hidden className={cn('rounded-sm bg-well', className)} {...rest} />;
13
+ }
14
+
15
+ type Busy = { label?: string | undefined; className?: string | undefined };
16
+
17
+ /** A list of rows loading. */
18
+ export function SkeletonList({ rows = 6, label = 'Loading', className }: Busy & { rows?: number | undefined }) {
19
+ return (
20
+ <div role="status" aria-busy="true" aria-label={label} className={cn('flex flex-col', className)}>
21
+ {Array.from({ length: rows }, (_, i) => (
22
+ <div key={i} className="flex items-center gap-3 border-b border-rule-soft py-3">
23
+ <Skeleton className="size-8 rounded-md" />
24
+ <div className="flex flex-1 flex-col gap-2">
25
+ <Skeleton className="h-3 w-2/5" />
26
+ <Skeleton className="h-2.5 w-1/4" />
27
+ </div>
28
+ <Skeleton className="h-3 w-16" />
29
+ </div>
30
+ ))}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ /** A grid of cards loading. */
36
+ export function SkeletonCards({ count = 6, label = 'Loading', className }: Busy & { count?: number | undefined }) {
37
+ return (
38
+ <div role="status" aria-busy="true" aria-label={label} className={cn('grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4', className)}>
39
+ {Array.from({ length: count }, (_, i) => (
40
+ <div key={i} className="flex flex-col gap-3 rounded-lg border border-rule p-3">
41
+ <Skeleton className="aspect-[4/3] w-full rounded-md" />
42
+ <Skeleton className="h-3 w-3/4" />
43
+ <Skeleton className="h-2.5 w-1/2" />
44
+ </div>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export type SkeletonViewerProps = Busy & {
51
+ /** What the wait is called as it gets longer. @default Opening… then, after 3 s, Rendering page 1… */
52
+ messages?: { after: number; text: string }[] | undefined;
53
+ };
54
+
55
+ /** A document opening: a page placeholder whose wording changes on long waits. */
56
+ export function SkeletonViewer({ messages = [{ after: 0, text: 'Opening…' }, { after: 3000, text: 'Rendering page 1…' }], className }: SkeletonViewerProps) {
57
+ const sorted = [...messages].sort((a, b) => a.after - b.after);
58
+ const [index, setIndex] = useState(0);
59
+ useEffect(() => {
60
+ const timers = sorted.slice(1).map((m, i) => setTimeout(() => setIndex(i + 1), m.after));
61
+ return () => timers.forEach(clearTimeout);
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [JSON.stringify(sorted)]);
64
+ const text = sorted[index]?.text ?? '';
65
+ return (
66
+ <div role="status" aria-busy="true" className={cn('flex flex-col items-center gap-4 rounded-lg bg-sunk p-8', className)}>
67
+ <Skeleton className="aspect-[1/1.3] w-full max-w-[360px] rounded-md bg-page" />
68
+ <span aria-live="polite" className="text-meta font-medium text-fg-secondary">
69
+ {text}
70
+ </span>
71
+ </div>
72
+ );
73
+ }
@@ -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,51 @@
1
+ import { Check, X } from 'lucide-react';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { PulseDot } from './PulseDot';
6
+
7
+ export type Stage = {
8
+ label: ReactNode;
9
+ state: 'done' | 'current' | 'todo' | 'failed';
10
+ /** Shown beside the state: "Batch 2 of 4", "Done", the failure. */
11
+ detail?: ReactNode;
12
+ };
13
+
14
+ export type StageProgressProps = Omit<ComponentProps<'ol'>, 'children'> & {
15
+ stages: Stage[];
16
+ /** Accessible name of the list. */
17
+ label?: string | undefined;
18
+ };
19
+
20
+ /**
21
+ * StageProgress — work that moves through named stages (Documents →
22
+ * People → Facts → Assessment → Report), not a percentage. Done stages are
23
+ * ink with a tick, the current one pulses on the brand colour, failed is red.
24
+ */
25
+ export function StageProgress({ stages, label, className, ...rest }: StageProgressProps) {
26
+ return (
27
+ <ol aria-label={label} className={cn('m-0 flex list-none flex-wrap items-center gap-x-5 gap-y-2 p-0 font-sans', className)} {...rest}>
28
+ {stages.map((s, i) => (
29
+ <li key={i} aria-current={s.state === 'current' ? 'step' : undefined} data-state={s.state} className="flex items-center gap-2 text-small">
30
+ {s.state === 'done' ? (
31
+ <span className="grid size-4 place-items-center rounded-pill bg-fg text-page">
32
+ <Check size={10} strokeWidth={3} aria-hidden />
33
+ </span>
34
+ ) : s.state === 'failed' ? (
35
+ <span className="grid size-4 place-items-center rounded-pill bg-error text-white">
36
+ <X size={10} strokeWidth={3} aria-hidden />
37
+ </span>
38
+ ) : s.state === 'current' ? (
39
+ <span className="grid size-4 place-items-center">
40
+ <PulseDot size={9} />
41
+ </span>
42
+ ) : (
43
+ <span aria-hidden className="size-4 rounded-pill border border-rule" />
44
+ )}
45
+ <span className={cn(s.state === 'todo' ? 'text-fg-secondary' : 'text-fg', s.state === 'current' && 'font-medium')}>{s.label}</span>
46
+ {s.detail && <span className={cn('text-meta', s.state === 'failed' ? 'text-error-text' : 'text-fg-secondary')}>{s.detail}</span>}
47
+ </li>
48
+ ))}
49
+ </ol>
50
+ );
51
+ }
@@ -0,0 +1,58 @@
1
+ import { Check, Lock } from 'lucide-react';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type StepState = 'done' | 'current' | 'upcoming' | 'blocked';
7
+
8
+ export type Step = {
9
+ title: ReactNode;
10
+ state: StepState;
11
+ /** The second line: "Done 22 Sep", "Current step", "Blocked · opens at the Term sheet stage". */
12
+ detail?: ReactNode;
13
+ };
14
+
15
+ export type StepsProps = Omit<ComponentProps<'ol'>, 'children'> & {
16
+ steps: Step[];
17
+ /** Read out for each state (the marker shows it visually). */
18
+ stateLabels?: Partial<Record<StepState, string>> | undefined;
19
+ };
20
+
21
+ const STATE_LABELS: Record<StepState, string> = { done: 'Done', current: 'Current step', upcoming: 'Not started', blocked: 'Blocked' };
22
+
23
+ /**
24
+ * Steps — a path in four states: done (ink tick), current (brand disc,
25
+ * aria-current="step"), upcoming (outlined number), blocked (lock, with the
26
+ * reason in the detail line).
27
+ */
28
+ export function Steps({ steps, stateLabels, className, ...rest }: StepsProps) {
29
+ const said = { ...STATE_LABELS, ...stateLabels };
30
+ return (
31
+ <ol className={cn('m-0 flex list-none flex-col p-0', className)} {...rest}>
32
+ {steps.map((s, i) => (
33
+ <li key={i} aria-current={s.state === 'current' ? 'step' : undefined} className="relative flex gap-3 pb-4 last:pb-0">
34
+ {i < steps.length - 1 && <span aria-hidden className="absolute top-6 bottom-0 left-[11px] w-px bg-rule" />}
35
+ <span
36
+ aria-hidden
37
+ className={cn(
38
+ 'relative z-[1] grid size-[22px] shrink-0 place-items-center rounded-pill text-[11px] font-semibold tabular-nums',
39
+ s.state === 'done' && 'bg-fg text-page',
40
+ s.state === 'current' && 'bg-brand-mark text-ink',
41
+ s.state === 'upcoming' && 'border border-rule bg-page text-fg-secondary',
42
+ s.state === 'blocked' && 'bg-sunk text-fg-secondary',
43
+ )}
44
+ >
45
+ {s.state === 'done' ? <Check size={12} strokeWidth={2.5} /> : s.state === 'blocked' ? <Lock size={11} strokeWidth={2} /> : i + 1}
46
+ </span>
47
+ <div className="flex min-w-0 flex-col pt-0.5">
48
+ <span className={cn('text-ui font-medium', s.state === 'upcoming' || s.state === 'blocked' ? 'text-fg-secondary' : 'text-fg')}>
49
+ {s.title}
50
+ <span className="sr-only">, {said[s.state]}</span>
51
+ </span>
52
+ {s.detail && <span className="text-meta text-fg-secondary">{s.detail}</span>}
53
+ </div>
54
+ </li>
55
+ ))}
56
+ </ol>
57
+ );
58
+ }
@@ -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,56 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { PulseDot } from './PulseDot';
5
+
6
+ /** synced (quiet) · syncing (pulsing brand dot) · error (red) · paused / off (grey ring). */
7
+ export type SyncState = 'synced' | 'syncing' | 'error' | 'paused' | 'off';
8
+
9
+ function Dot({ state }: { state: SyncState }) {
10
+ if (state === 'syncing') return <PulseDot size={8} />;
11
+ return (
12
+ <span
13
+ aria-hidden
14
+ className={cn(
15
+ 'inline-block size-2 shrink-0 rounded-pill',
16
+ state === 'error' && 'bg-error',
17
+ state === 'synced' && 'bg-fg-secondary',
18
+ (state === 'paused' || state === 'off') && 'border border-fg-secondary',
19
+ )}
20
+ />
21
+ );
22
+ }
23
+
24
+ export type SyncStatusProps = Omit<ComponentProps<'div'>, 'children'> & {
25
+ state: SyncState;
26
+ /** The wording, always present: "In sync", "Sync error", "Importing 2 batches". */
27
+ children: ReactNode;
28
+ /** Secondary detail: "Updated just now", "attempt 3". */
29
+ detail?: ReactNode;
30
+ /** One action, e.g. Retry. */
31
+ action?: ReactNode;
32
+ /** Dot only (the global indicator); the wording stays for screen readers and on hover. */
33
+ compact?: boolean | undefined;
34
+ };
35
+
36
+ /**
37
+ * SyncStatus — the global sync dot and the per-source sync lines ("Retrying
38
+ * after an error · attempt 3", "Paused by Wei Li", "Last synced 4 minutes
39
+ * ago"). Colour is never the only signal: the wording is always there.
40
+ */
41
+ export function SyncStatus({ state, children, detail, action, compact = false, className, title, ...rest }: SyncStatusProps) {
42
+ return (
43
+ <div
44
+ role="status"
45
+ data-state={state}
46
+ title={compact ? (title ?? (typeof children === 'string' ? children : undefined)) : title}
47
+ className={cn('inline-flex items-center gap-2 font-sans text-small', state === 'error' ? 'text-error-text' : 'text-fg', className)}
48
+ {...rest}
49
+ >
50
+ <Dot state={state} />
51
+ <span className={compact ? 'sr-only' : undefined}>{children}</span>
52
+ {detail && <span className={cn('text-meta text-fg-secondary', compact && 'sr-only')}>{detail}</span>}
53
+ {action && !compact && <span className="ml-1">{action}</span>}
54
+ </div>
55
+ );
56
+ }
@@ -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
+ }