@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,62 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type PageHeaderFigure = { value: ReactNode; label: ReactNode };
|
|
6
|
+
|
|
7
|
+
export type PageHeaderProps = Omit<ComponentProps<'header'>, 'title'> & {
|
|
8
|
+
/** "Project · Series A" */
|
|
9
|
+
eyebrow?: ReactNode;
|
|
10
|
+
title: ReactNode;
|
|
11
|
+
/** Beside the title: a Badge. */
|
|
12
|
+
status?: ReactNode;
|
|
13
|
+
/** A note line under the title: sync state, counts ("Syncing · 6,831 items"). */
|
|
14
|
+
note?: ReactNode;
|
|
15
|
+
/** A brand dot before the note (background work running). */
|
|
16
|
+
noteDot?: boolean | undefined;
|
|
17
|
+
/** Stat figures; hidden on phones. */
|
|
18
|
+
figures?: PageHeaderFigure[] | undefined;
|
|
19
|
+
/** Right-hand actions. */
|
|
20
|
+
actions?: ReactNode;
|
|
21
|
+
/** Heading level of the title. @default 1 */
|
|
22
|
+
level?: 1 | 2 | 3 | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PageHeader — the "seven in one" header card: eyebrow, title, status, a
|
|
27
|
+
* note line, figures (hidden on phones) and actions. Every part is optional
|
|
28
|
+
* but the title.
|
|
29
|
+
*/
|
|
30
|
+
export function PageHeader({ eyebrow, title, status, note, noteDot = false, figures, actions, level = 1, className, ...rest }: PageHeaderProps) {
|
|
31
|
+
const H = `h${level}` as const;
|
|
32
|
+
return (
|
|
33
|
+
<header className={cn('flex flex-col gap-4 rounded-lg bg-sunk p-5 text-fg', className)} {...rest}>
|
|
34
|
+
<div className="flex items-start justify-between gap-4">
|
|
35
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
36
|
+
{eyebrow && <div className="text-meta font-medium text-fg-secondary">{eyebrow}</div>}
|
|
37
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
|
38
|
+
<H className="m-0 truncate text-title leading-tight font-medium tracking-heading">{title}</H>
|
|
39
|
+
{status}
|
|
40
|
+
</div>
|
|
41
|
+
{note && (
|
|
42
|
+
<div className="flex items-center gap-2 text-meta text-fg-secondary">
|
|
43
|
+
{noteDot && <span aria-hidden className="size-1.5 shrink-0 rounded-pill bg-brand-mark" />}
|
|
44
|
+
<span className="truncate">{note}</span>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
49
|
+
</div>
|
|
50
|
+
{figures && figures.length > 0 && (
|
|
51
|
+
<dl className="m-0 hidden flex-wrap gap-x-8 gap-y-3 sm:flex">
|
|
52
|
+
{figures.map((f, i) => (
|
|
53
|
+
<div key={i} className="flex flex-col-reverse">
|
|
54
|
+
<dt className="text-meta text-fg-secondary">{f.label}</dt>
|
|
55
|
+
<dd className="m-0 text-title leading-tight font-medium tabular-nums">{f.value}</dd>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</dl>
|
|
59
|
+
)}
|
|
60
|
+
</header>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { CloudOff, FileQuestion, FolderOpen, Info, Lock, RefreshCw, SearchX, TriangleAlert, type LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
|
|
7
|
+
export type PageStateKind = 'empty' | 'no-results' | 'forbidden' | 'not-found' | 'error' | 'offline';
|
|
8
|
+
|
|
9
|
+
const GLYPH: Record<PageStateKind, LucideIcon> = {
|
|
10
|
+
empty: FolderOpen,
|
|
11
|
+
'no-results': SearchX,
|
|
12
|
+
forbidden: Lock,
|
|
13
|
+
'not-found': FileQuestion,
|
|
14
|
+
error: TriangleAlert,
|
|
15
|
+
offline: CloudOff,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PageStateProps = Omit<ComponentProps<'div'>, 'title' | 'children'> & {
|
|
19
|
+
kind: PageStateKind;
|
|
20
|
+
/** The one sentence: "No documents yet", "Couldn't load; your work is saved". */
|
|
21
|
+
title: ReactNode;
|
|
22
|
+
/** Optional second line. */
|
|
23
|
+
description?: ReactNode;
|
|
24
|
+
/** The one action (a Button): Upload files, Try again, Go to my documents. */
|
|
25
|
+
action?: ReactNode;
|
|
26
|
+
/** Tighter, for a panel or a list rather than a page. */
|
|
27
|
+
compact?: boolean | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PageState — one sentence and one action per state: nothing yet, no
|
|
32
|
+
* results, no access, not found, error, offline. Never "No results" while a
|
|
33
|
+
* search is still running (show a Skeleton instead).
|
|
34
|
+
*/
|
|
35
|
+
export function PageState({ kind, title, description, action, compact = false, className, ...rest }: PageStateProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
role={kind === 'error' || kind === 'offline' ? 'alert' : 'status'}
|
|
39
|
+
data-kind={kind}
|
|
40
|
+
className={cn('flex flex-col items-center text-center font-sans', compact ? 'gap-2 py-8' : 'gap-3 py-16', className)}
|
|
41
|
+
{...rest}
|
|
42
|
+
>
|
|
43
|
+
<span className={cn('grid place-items-center rounded-lg bg-sunk text-fg-secondary', compact ? 'size-10' : 'size-12')}>
|
|
44
|
+
<Icon icon={GLYPH[kind]} size={compact ? 18 : 22} />
|
|
45
|
+
</span>
|
|
46
|
+
<div className="flex flex-col gap-1">
|
|
47
|
+
<p className={cn('m-0 font-medium text-fg', compact ? 'text-small' : 'text-body')}>{title}</p>
|
|
48
|
+
{description && <p className="m-0 max-w-[44ch] text-small text-fg-secondary">{description}</p>}
|
|
49
|
+
</div>
|
|
50
|
+
{action && <div className="mt-1">{action}</div>}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PageNoticeProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
56
|
+
/** info: a quiet sunk line (a truncated document); update: a new version is out. */
|
|
57
|
+
tone?: 'info' | 'update' | undefined;
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
/** One action: Refresh. */
|
|
60
|
+
action?: ReactNode;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* PageNotice — a full-width line above the content: "This document has
|
|
65
|
+
* 1,240 pages. Only the first 500 were processed…", or "MetaRoom has been
|
|
66
|
+
* updated. Refresh to load this page."
|
|
67
|
+
*/
|
|
68
|
+
export function PageNotice({ tone = 'info', children, action, className, ...rest }: PageNoticeProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
role={tone === 'update' ? 'alert' : 'status'}
|
|
72
|
+
className={cn(
|
|
73
|
+
'flex items-center gap-3 rounded-md px-4 py-2.5 font-sans text-small',
|
|
74
|
+
tone === 'update' ? 'bg-brand-field text-ink' : 'bg-sunk text-fg',
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
79
|
+
<Icon icon={tone === 'update' ? RefreshCw : Info} size={15} className={tone === 'update' ? undefined : 'text-fg-secondary'} />
|
|
80
|
+
<span className="flex-1">{children}</span>
|
|
81
|
+
{action}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useLoop } from './status-motion';
|
|
5
|
+
|
|
6
|
+
export type ProgressSegment = 'done' | 'current' | 'failed' | 'todo';
|
|
7
|
+
|
|
8
|
+
export type ProgressProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
9
|
+
/** Accessible name ("Uploading Cap table.xlsx"). */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Determinate: how far. Leave undefined (and no segments) for indeterminate. */
|
|
12
|
+
value?: number | undefined;
|
|
13
|
+
/** @default 100 */
|
|
14
|
+
max?: number | undefined;
|
|
15
|
+
/** Segmented: one entry per step — done ink, current the brand colour, failed red. */
|
|
16
|
+
segments?: ProgressSegment[] | undefined;
|
|
17
|
+
/** Always-visible progress text: "1.2 of 3.4 MB", "Preparing download", "Batch 3 of 5". */
|
|
18
|
+
text?: ReactNode;
|
|
19
|
+
/** Announced once when it ends. */
|
|
20
|
+
status?: 'active' | 'done' | 'failed' | undefined;
|
|
21
|
+
/** Words for the announcements. */
|
|
22
|
+
labels?: { done?: string; failed?: string } | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** 0, 25, 50, 75 or 100 — screen readers hear these steps, not every tick. */
|
|
26
|
+
export function progressStep(value: number, max: number): number {
|
|
27
|
+
if (max <= 0) return 0;
|
|
28
|
+
const pct = Math.min(100, Math.max(0, (value / max) * 100));
|
|
29
|
+
return Math.floor(pct / 25) * 25;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SEGMENT = { done: 'bg-fg', current: 'bg-brand-mark', failed: 'bg-error', todo: 'bg-well' };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Progress — one component, three forms: determinate, indeterminate,
|
|
36
|
+
* segmented. The text is always shown; screen readers hear 25% steps and
|
|
37
|
+
* the ending (done or failed), never every tick.
|
|
38
|
+
*/
|
|
39
|
+
export function Progress({ label, value, max = 100, segments, text, status = 'active', labels, className, ...rest }: ProgressProps) {
|
|
40
|
+
const determinate = value !== undefined && !segments;
|
|
41
|
+
const step = determinate ? progressStep(value, max) : null;
|
|
42
|
+
const [announce, setAnnounce] = useState('');
|
|
43
|
+
const lastStep = useRef<number | null>(null);
|
|
44
|
+
const lastStatus = useRef(status);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (status !== lastStatus.current) {
|
|
48
|
+
lastStatus.current = status;
|
|
49
|
+
if (status === 'done') setAnnounce(`${label}: ${labels?.done ?? 'done'}`);
|
|
50
|
+
if (status === 'failed') setAnnounce(`${label}: ${labels?.failed ?? 'failed'}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (step === null || step === lastStep.current) return;
|
|
54
|
+
const first = lastStep.current === null;
|
|
55
|
+
lastStep.current = step;
|
|
56
|
+
if (!first) setAnnounce(`${label}: ${step}%`);
|
|
57
|
+
}, [step, status, label, labels?.done, labels?.failed]);
|
|
58
|
+
|
|
59
|
+
const bar = useRef<HTMLDivElement | null>(null);
|
|
60
|
+
useLoop(bar, [{ transform: 'translateX(-100%)' }, { transform: 'translateX(250%)' }], { duration: 1400, easing: 'cubic-bezier(.2,0,.2,1)' }, value === undefined && !segments);
|
|
61
|
+
|
|
62
|
+
const aria =
|
|
63
|
+
determinate
|
|
64
|
+
? { 'aria-valuemin': 0, 'aria-valuemax': max, 'aria-valuenow': Math.min(max, Math.max(0, value)) }
|
|
65
|
+
: segments
|
|
66
|
+
? { 'aria-valuemin': 0, 'aria-valuemax': segments.length, 'aria-valuenow': segments.filter((s) => s === 'done').length }
|
|
67
|
+
: {};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={cn('flex flex-col gap-1.5 font-sans', className)} {...rest}>
|
|
71
|
+
<div role="progressbar" aria-label={label} {...aria} aria-valuetext={typeof text === 'string' ? text : undefined} className="relative">
|
|
72
|
+
{segments ? (
|
|
73
|
+
<div className="flex gap-1">
|
|
74
|
+
{segments.map((s, i) => (
|
|
75
|
+
<span key={i} data-segment={s} className={cn('h-1 flex-1 rounded-pill', SEGMENT[s])} />
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
) : (
|
|
79
|
+
<div className={cn('h-1 overflow-hidden rounded-pill bg-well', status === 'failed' && 'bg-error-wash')}>
|
|
80
|
+
{determinate ? (
|
|
81
|
+
<div
|
|
82
|
+
className={cn('h-full rounded-pill transition-[width] duration-[180ms] ease-standard', status === 'failed' ? 'bg-error' : 'bg-brand-mark')}
|
|
83
|
+
style={{ width: `${Math.min(100, Math.max(0, (value / max) * 100))}%` }}
|
|
84
|
+
/>
|
|
85
|
+
) : (
|
|
86
|
+
<div ref={bar} className="h-full w-2/5 rounded-pill bg-brand-mark" />
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
{text !== undefined && <div className="text-meta font-medium text-fg-secondary tabular-nums">{text}</div>}
|
|
92
|
+
<span className="sr-only" aria-live="polite">
|
|
93
|
+
{announce}
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useRef, type ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useLoop } from './status-motion';
|
|
5
|
+
|
|
6
|
+
export type PulseDotProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
7
|
+
/** Pulse (background work or an agent running). Off: a still dot. @default true */
|
|
8
|
+
active?: boolean | undefined;
|
|
9
|
+
/** px. @default 9 */
|
|
10
|
+
size?: number | undefined;
|
|
11
|
+
/** Accessible text ("Agent running"). Without it the dot is decorative. */
|
|
12
|
+
label?: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** PulseDot — a slowly pulsing brand dot while work runs; a still brand
|
|
16
|
+
* dot under reduced motion. */
|
|
17
|
+
export function PulseDot({ active = true, size = 9, label, className, style, ...rest }: PulseDotProps) {
|
|
18
|
+
const ref = useRef<HTMLSpanElement | null>(null);
|
|
19
|
+
useLoop(ref, [{ opacity: 1 }, { opacity: 0.35 }, { opacity: 1 }], { duration: 2400, easing: 'ease-in-out' }, active);
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
ref={ref}
|
|
23
|
+
role={label ? 'img' : undefined}
|
|
24
|
+
aria-label={label}
|
|
25
|
+
aria-hidden={label ? undefined : true}
|
|
26
|
+
className={cn('inline-block shrink-0 rounded-pill bg-brand-mark', className)}
|
|
27
|
+
style={{ width: size, height: size, ...style }}
|
|
28
|
+
{...rest}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as RadioPrimitive from '@radix-ui/react-radio-group';
|
|
2
|
+
import { useId, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export type RadioOption = {
|
|
7
|
+
value: string;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
/** A line below the label. */
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
disabled?: boolean | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type RadioGroupProps = Omit<ComponentProps<typeof RadioPrimitive.Root>, 'value' | 'onValueChange' | 'onChange' | 'children' | 'orientation'> & {
|
|
15
|
+
/** Two to five exclusive options, all visible (more: a Select). */
|
|
16
|
+
options: Array<string | RadioOption>;
|
|
17
|
+
value?: string | undefined;
|
|
18
|
+
onChange?: ((value: string) => void) | undefined;
|
|
19
|
+
/** column (default) or row. Arrow keys move between options either way. */
|
|
20
|
+
direction?: 'column' | 'row' | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* RadioGroup — round sunk controls, an ink dot on the chosen one. Arrow
|
|
25
|
+
* keys move and choose; the group is one tab stop.
|
|
26
|
+
*/
|
|
27
|
+
export function RadioGroup({ options, value, onChange, direction = 'column', disabled, className, ...rest }: RadioGroupProps) {
|
|
28
|
+
const auto = useId();
|
|
29
|
+
const items = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
30
|
+
return (
|
|
31
|
+
<RadioPrimitive.Root
|
|
32
|
+
{...(value !== undefined && { value })}
|
|
33
|
+
onValueChange={(v) => onChange?.(v)}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
orientation={direction === 'row' ? 'horizontal' : 'vertical'}
|
|
36
|
+
className={cn('flex font-sans', direction === 'row' ? 'flex-row flex-wrap gap-[22px]' : 'flex-col gap-2.5', className)}
|
|
37
|
+
{...rest}
|
|
38
|
+
>
|
|
39
|
+
{items.map((o) => {
|
|
40
|
+
const id = `rd${auto}-${o.value}`;
|
|
41
|
+
const off = Boolean(disabled || o.disabled);
|
|
42
|
+
return (
|
|
43
|
+
<div key={o.value} className={cn('inline-flex items-start gap-2.5', off && 'opacity-40')}>
|
|
44
|
+
<RadioPrimitive.Item
|
|
45
|
+
id={id}
|
|
46
|
+
value={o.value}
|
|
47
|
+
disabled={o.disabled}
|
|
48
|
+
aria-describedby={o.description ? `${id}-desc` : undefined}
|
|
49
|
+
className={cn(
|
|
50
|
+
'mt-0.5 grid size-4 shrink-0 cursor-pointer place-items-center rounded-pill border border-fg-secondary bg-sunk',
|
|
51
|
+
'transition-[border-color] duration-[120ms] ease-standard outline-none focus-visible:shadow-(--focus-ring)',
|
|
52
|
+
'data-[state=checked]:border-fg disabled:cursor-not-allowed',
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<RadioPrimitive.Indicator className="block size-2 rounded-pill bg-fg" />
|
|
56
|
+
</RadioPrimitive.Item>
|
|
57
|
+
<span className="flex flex-col gap-0.5">
|
|
58
|
+
<label htmlFor={id} className={cn('text-small leading-[1.45] text-fg', off ? 'cursor-not-allowed' : 'cursor-pointer')}>
|
|
59
|
+
{o.label}
|
|
60
|
+
</label>
|
|
61
|
+
{o.description && (
|
|
62
|
+
<span id={`${id}-desc`} className="text-meta leading-normal text-fg-secondary">
|
|
63
|
+
{o.description}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</RadioPrimitive.Root>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import * as Popover from '@radix-ui/react-popover';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
import { useField } from './Field';
|
|
7
|
+
import { DEFAULT_LABELS, ListboxBody, type ListboxLabels, type ListItem } from './inputs-listbox';
|
|
8
|
+
import { useDebouncedSearch } from './inputs-search';
|
|
9
|
+
|
|
10
|
+
export type RecipientStatus = 'valid' | 'invalid' | 'unverified';
|
|
11
|
+
export type Recipient = { email: string; name?: string | undefined; status: RecipientStatus };
|
|
12
|
+
|
|
13
|
+
export type RecipientsInputProps = {
|
|
14
|
+
value?: Recipient[] | undefined;
|
|
15
|
+
defaultValue?: Recipient[] | undefined;
|
|
16
|
+
onChange?: ((recipients: Recipient[]) => void) | undefined;
|
|
17
|
+
/** People to suggest while typing (value = email). */
|
|
18
|
+
search?: ((query: string, signal: AbortSignal) => Promise<ListItem[]>) | undefined;
|
|
19
|
+
/** Status for a typed address that is well formed: valid, or unverified (dashed) when the caller can't confirm it. @default () => 'valid' */
|
|
20
|
+
verify?: ((email: string) => 'valid' | 'unverified') | undefined;
|
|
21
|
+
/** @default "Add people or paste emails" */
|
|
22
|
+
placeholder?: string | undefined;
|
|
23
|
+
labels?: Partial<ListboxLabels> & { remove?: ((r: Recipient) => string) | undefined; invalid?: string | undefined; unverified?: string | undefined };
|
|
24
|
+
disabled?: boolean | undefined;
|
|
25
|
+
/** A leading word inside the field, e.g. "To". */
|
|
26
|
+
prefix?: ReactNode | undefined;
|
|
27
|
+
id?: string | undefined;
|
|
28
|
+
'aria-label'?: string | undefined;
|
|
29
|
+
className?: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
|
|
33
|
+
const SPLIT = /[\s,;]+/;
|
|
34
|
+
|
|
35
|
+
/** "Anna K <anna@x.co>" → the address; anything else as typed. */
|
|
36
|
+
function addressOf(token: string): string {
|
|
37
|
+
const m = token.match(/<([^>]+)>/);
|
|
38
|
+
return (m ? m[1]! : token).trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* RecipientsInput — the "To" field. Addresses become chips as they are
|
|
43
|
+
* confirmed (Enter, comma, space or leaving the field); pasting a list
|
|
44
|
+
* splits it. A malformed address gets the red outline and opens for
|
|
45
|
+
* editing in place when clicked; one the caller can't confirm is dashed.
|
|
46
|
+
* Backspace in the empty field removes the last chip.
|
|
47
|
+
*/
|
|
48
|
+
export function RecipientsInput({
|
|
49
|
+
value,
|
|
50
|
+
defaultValue = [],
|
|
51
|
+
onChange,
|
|
52
|
+
search,
|
|
53
|
+
verify = () => 'valid',
|
|
54
|
+
placeholder = 'Add people or paste emails',
|
|
55
|
+
labels: labelsIn,
|
|
56
|
+
disabled,
|
|
57
|
+
prefix,
|
|
58
|
+
id,
|
|
59
|
+
className,
|
|
60
|
+
...aria
|
|
61
|
+
}: RecipientsInputProps) {
|
|
62
|
+
const field = useField();
|
|
63
|
+
const auto = useId();
|
|
64
|
+
const listId = `ri${auto}-list`;
|
|
65
|
+
const optionId = (i: number) => `ri${auto}-o${i}`;
|
|
66
|
+
const labels = { ...DEFAULT_LABELS, ...labelsIn };
|
|
67
|
+
const [own, setOwn] = useState<Recipient[]>(defaultValue);
|
|
68
|
+
const list = value ?? own;
|
|
69
|
+
const [text, setText] = useState('');
|
|
70
|
+
const [editing, setEditing] = useState<number | null>(null);
|
|
71
|
+
const [active, setActive] = useState(0);
|
|
72
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
73
|
+
const off = Boolean(disabled || field?.disabled);
|
|
74
|
+
const { status, result, retry } = useDebouncedSearch(text, search, { delay: 250 });
|
|
75
|
+
const items = (status === 'done' ? (result ?? []) : []).filter((i) => !list.some((r) => r.email.toLowerCase() === i.value.toLowerCase()));
|
|
76
|
+
// Suggestions only while there is something to suggest: a new address
|
|
77
|
+
// that matches no one is normal here, not "nothing matches".
|
|
78
|
+
const showList = Boolean(search) && text.trim().length > 0 && editing === null && (status === 'searching' || status === 'error' || items.length > 0);
|
|
79
|
+
|
|
80
|
+
const commitList = (next: Recipient[]) => {
|
|
81
|
+
if (value === undefined) setOwn(next);
|
|
82
|
+
onChange?.(next);
|
|
83
|
+
};
|
|
84
|
+
const toRecipient = (token: string, name?: string): Recipient => {
|
|
85
|
+
const email = addressOf(token);
|
|
86
|
+
return { email, ...(name ? { name } : {}), status: EMAIL.test(email) ? verify(email) : 'invalid' };
|
|
87
|
+
};
|
|
88
|
+
/** Turn typed text into chips; returns true when something was added. */
|
|
89
|
+
const commitText = (raw: string, at: number | null = null) => {
|
|
90
|
+
const tokens = raw.split(SPLIT).map((t) => t.trim()).filter(Boolean);
|
|
91
|
+
if (!tokens.length) return false;
|
|
92
|
+
const fresh = tokens.map((t) => toRecipient(t)).filter((r) => !list.some((x, i) => i !== at && x.email.toLowerCase() === r.email.toLowerCase()));
|
|
93
|
+
const next = [...list];
|
|
94
|
+
if (at === null) next.push(...fresh);
|
|
95
|
+
else next.splice(at, 1, ...fresh);
|
|
96
|
+
commitList(next);
|
|
97
|
+
setText('');
|
|
98
|
+
setEditing(null);
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
const choose = (i: number) => {
|
|
102
|
+
const it = items[i];
|
|
103
|
+
if (!it) return;
|
|
104
|
+
commitList([...list, { email: it.value, name: it.label, status: 'valid' }]);
|
|
105
|
+
setText('');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
109
|
+
if (showList && items.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
setActive((a) => (e.key === 'ArrowDown' ? Math.min(items.length - 1, a + 1) : Math.max(0, a - 1)));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (e.key === 'Enter' || e.key === ',' || e.key === ';' || (e.key === ' ' && text.trim()) || (e.key === 'Tab' && text.trim())) {
|
|
115
|
+
if (e.key === 'Enter' && showList && status === 'done' && items.length) {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
choose(active);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (commitText(text, editing)) e.preventDefault();
|
|
121
|
+
else if (e.key !== 'Tab' && e.key !== ' ') e.preventDefault();
|
|
122
|
+
} else if (e.key === 'Backspace' && !text && editing === null && list.length) {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
commitList(list.slice(0, -1));
|
|
125
|
+
} else if (e.key === 'Escape' && editing !== null) {
|
|
126
|
+
setEditing(null);
|
|
127
|
+
setText('');
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const editInput = (
|
|
132
|
+
<input
|
|
133
|
+
ref={input}
|
|
134
|
+
id={editing === null ? (id ?? field?.id) : undefined}
|
|
135
|
+
value={text}
|
|
136
|
+
disabled={off}
|
|
137
|
+
placeholder={list.length || editing !== null ? undefined : placeholder}
|
|
138
|
+
aria-label={aria['aria-label'] ?? (field?.labelId ? undefined : placeholder)}
|
|
139
|
+
aria-labelledby={field?.labelId}
|
|
140
|
+
aria-describedby={field?.describedBy}
|
|
141
|
+
role={search ? 'combobox' : undefined}
|
|
142
|
+
aria-expanded={search ? showList : undefined}
|
|
143
|
+
aria-controls={search && showList ? listId : undefined}
|
|
144
|
+
aria-autocomplete={search ? 'list' : undefined}
|
|
145
|
+
aria-activedescendant={showList && status === 'done' && items.length ? optionId(active) : undefined}
|
|
146
|
+
autoComplete="off"
|
|
147
|
+
onChange={(e) => {
|
|
148
|
+
setText(e.target.value);
|
|
149
|
+
setActive(0);
|
|
150
|
+
}}
|
|
151
|
+
onPaste={(e) => {
|
|
152
|
+
const pasted = e.clipboardData.getData('text');
|
|
153
|
+
if (SPLIT.test(pasted.trim())) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
commitText(text + pasted, editing);
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
onBlur={() => text.trim() && commitText(text, editing)}
|
|
159
|
+
onKeyDown={onKey}
|
|
160
|
+
className={cn('min-w-[8ch] flex-1 border-none bg-transparent p-0 py-1 font-sans text-[13px] text-fg outline-none placeholder:text-fg-secondary', editing !== null && 'min-w-[16ch]')}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Popover.Root open={showList} onOpenChange={() => {}}>
|
|
166
|
+
<Popover.Anchor asChild>
|
|
167
|
+
<div
|
|
168
|
+
onClick={(e) => e.target === e.currentTarget && input.current?.focus()}
|
|
169
|
+
className={cn(
|
|
170
|
+
'flex min-h-[38px] w-full flex-wrap items-center gap-1.5 rounded-md border border-rule bg-sunk px-2 py-1 font-sans text-fg',
|
|
171
|
+
'transition-[border-color] duration-[120ms] ease-standard focus-within:border-fg focus-within:shadow-(--focus-ring)',
|
|
172
|
+
off && 'cursor-not-allowed bg-well text-fg-secondary',
|
|
173
|
+
className,
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
{prefix !== undefined && <span className="pr-0.5 pl-1 text-[12.5px] text-fg-secondary">{prefix}</span>}
|
|
177
|
+
{list.map((r, i) =>
|
|
178
|
+
editing === i ? (
|
|
179
|
+
<span key={`edit-${i}`} className="inline-flex rounded-sm border border-error bg-page px-1.5">
|
|
180
|
+
{editInput}
|
|
181
|
+
</span>
|
|
182
|
+
) : (
|
|
183
|
+
<span
|
|
184
|
+
key={r.email + i}
|
|
185
|
+
className={cn(
|
|
186
|
+
'inline-flex max-w-full items-center gap-1 rounded-sm border bg-page py-0.5 pr-1 pl-2 text-[12.5px] leading-[1.4]',
|
|
187
|
+
r.status === 'invalid' ? 'border-error bg-error-wash' : r.status === 'unverified' ? 'border-dashed border-fg-secondary' : 'border-rule',
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
{r.status === 'invalid' ? (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
disabled={off}
|
|
194
|
+
onClick={() => {
|
|
195
|
+
setEditing(i);
|
|
196
|
+
setText(r.email);
|
|
197
|
+
requestAnimationFrame(() => input.current?.focus());
|
|
198
|
+
}}
|
|
199
|
+
className="cursor-text truncate rounded-xs outline-none focus-visible:shadow-(--focus-ring)"
|
|
200
|
+
>
|
|
201
|
+
{r.email}
|
|
202
|
+
<span className="sr-only">, {labelsIn?.invalid ?? 'not a valid address — edit'}</span>
|
|
203
|
+
</button>
|
|
204
|
+
) : (
|
|
205
|
+
<span className="truncate" title={r.name ? r.email : undefined}>
|
|
206
|
+
{r.name ?? r.email}
|
|
207
|
+
{r.status === 'unverified' && <span className="sr-only">, {labelsIn?.unverified ?? 'unverified'}</span>}
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
{!off && (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
aria-label={labelsIn?.remove?.(r) ?? `Remove ${r.name ?? r.email}`}
|
|
214
|
+
onClick={() => commitList(list.filter((_, k) => k !== i))}
|
|
215
|
+
className="inline-grid cursor-pointer place-items-center rounded-xs text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
|
|
216
|
+
>
|
|
217
|
+
<X size={12} aria-hidden />
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
</span>
|
|
221
|
+
),
|
|
222
|
+
)}
|
|
223
|
+
{editing === null && editInput}
|
|
224
|
+
</div>
|
|
225
|
+
</Popover.Anchor>
|
|
226
|
+
<Popover.Portal>
|
|
227
|
+
<Popover.Content
|
|
228
|
+
align="start"
|
|
229
|
+
sideOffset={6}
|
|
230
|
+
collisionPadding={12}
|
|
231
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
232
|
+
className="rise z-60 rounded-lg border border-rule bg-page p-1 font-sans [--motion-reveal:200ms]"
|
|
233
|
+
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
234
|
+
>
|
|
235
|
+
<ListboxBody
|
|
236
|
+
id={listId}
|
|
237
|
+
optionId={optionId}
|
|
238
|
+
items={items}
|
|
239
|
+
active={active}
|
|
240
|
+
status={status}
|
|
241
|
+
query={text.trim()}
|
|
242
|
+
canCreate={false}
|
|
243
|
+
labels={labels}
|
|
244
|
+
onActive={setActive}
|
|
245
|
+
onChoose={choose}
|
|
246
|
+
onRetry={retry}
|
|
247
|
+
/>
|
|
248
|
+
</Popover.Content>
|
|
249
|
+
</Popover.Portal>
|
|
250
|
+
</Popover.Root>
|
|
251
|
+
);
|
|
252
|
+
}
|