@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/package.json +32 -3
- package/src/brand.css +13 -0
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +193 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +135 -0
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/Table.tsx +196 -0
- package/src/components/Tabs.tsx +124 -0
- package/src/components/Tag.tsx +35 -0
- package/src/components/Textarea.tsx +46 -0
- package/src/components/ThemeProvider.tsx +80 -0
- package/src/components/Toast.tsx +159 -0
- package/src/components/Tooltip.tsx +57 -0
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +67 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme.css +11 -0
|
@@ -0,0 +1,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
|
+
}
|