@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,137 @@
|
|
|
1
|
+
import { Search, X } from 'lucide-react';
|
|
2
|
+
import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { useField } from './Field';
|
|
6
|
+
import { controlFrame, controlInner } from './forms-control';
|
|
7
|
+
import { useDebouncedSearch } from './inputs-search';
|
|
8
|
+
import { Spinner } from './Spinner';
|
|
9
|
+
|
|
10
|
+
export type SearchFieldProps = Omit<ComponentProps<'input'>, 'size' | 'value' | 'defaultValue' | 'onChange' | 'type'> & {
|
|
11
|
+
value?: string | undefined;
|
|
12
|
+
defaultValue?: string | undefined;
|
|
13
|
+
/** Every keystroke (the field is controlled or not). */
|
|
14
|
+
onChange?: ((value: string) => void) | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Runs after the debounce with an AbortSignal that fires when newer input
|
|
17
|
+
* arrives. Resolve with the result count to show it (14 results); a late
|
|
18
|
+
* answer to an older query is ignored.
|
|
19
|
+
*/
|
|
20
|
+
onSearch?: ((query: string, signal: AbortSignal) => Promise<number | void>) | undefined;
|
|
21
|
+
/** ms. @default 250 */
|
|
22
|
+
debounce?: number | undefined;
|
|
23
|
+
/** Focus with / or ⌘K / Ctrl+K. One field per page. */
|
|
24
|
+
shortcut?: boolean | undefined;
|
|
25
|
+
/** The count line. @default (n) => `${n} results` */
|
|
26
|
+
formatCount?: ((count: number) => ReactNode) | undefined;
|
|
27
|
+
/** Accessible name of the clear button. @default "Clear search" */
|
|
28
|
+
clearLabel?: string | undefined;
|
|
29
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isTyping(el: EventTarget | null): boolean {
|
|
33
|
+
const e = el as HTMLElement | null;
|
|
34
|
+
return Boolean(e && (e.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.tagName)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SearchField — search with the debounce built in, so pages stop writing
|
|
39
|
+
* their own. Four states: empty (with the / hint when it has a shortcut),
|
|
40
|
+
* searching (a spinner — never "No results" before the answer), a result
|
|
41
|
+
* count, and cleared. New input cancels the request in flight.
|
|
42
|
+
*/
|
|
43
|
+
export function SearchField({
|
|
44
|
+
value,
|
|
45
|
+
defaultValue = '',
|
|
46
|
+
onChange,
|
|
47
|
+
onSearch,
|
|
48
|
+
debounce = 250,
|
|
49
|
+
shortcut = false,
|
|
50
|
+
formatCount = (n) => (n === 1 ? '1 result' : `${n} results`),
|
|
51
|
+
clearLabel = 'Clear search',
|
|
52
|
+
size,
|
|
53
|
+
placeholder = 'Search',
|
|
54
|
+
className,
|
|
55
|
+
id,
|
|
56
|
+
ref,
|
|
57
|
+
...rest
|
|
58
|
+
}: SearchFieldProps) {
|
|
59
|
+
const field = useField();
|
|
60
|
+
const [own, setOwn] = useState(defaultValue);
|
|
61
|
+
const text = value ?? own;
|
|
62
|
+
const inner = useRef<HTMLInputElement | null>(null);
|
|
63
|
+
const { status, result } = useDebouncedSearch(text, onSearch, { delay: debounce });
|
|
64
|
+
|
|
65
|
+
const set = (v: string) => {
|
|
66
|
+
if (value === undefined) setOwn(v);
|
|
67
|
+
onChange?.(v);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!shortcut) return;
|
|
72
|
+
const onKey = (e: KeyboardEvent) => {
|
|
73
|
+
const k = e.key.toLowerCase();
|
|
74
|
+
if ((k === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !isTyping(e.target) && !e.metaKey && !e.ctrlKey)) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
inner.current?.focus();
|
|
77
|
+
inner.current?.select();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener('keydown', onKey);
|
|
81
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
82
|
+
}, [shortcut]);
|
|
83
|
+
|
|
84
|
+
const off = Boolean(rest.disabled || field?.disabled);
|
|
85
|
+
const count = status === 'done' && typeof result === 'number' ? result : null;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div role="search" className={cn(controlFrame({ size, disabled: off, invalid: Boolean(field?.invalid) }), className)}>
|
|
89
|
+
<Search size={14} aria-hidden className="shrink-0 text-fg-secondary" />
|
|
90
|
+
<input
|
|
91
|
+
{...rest}
|
|
92
|
+
ref={(el) => {
|
|
93
|
+
inner.current = el;
|
|
94
|
+
if (typeof ref === 'function') ref(el);
|
|
95
|
+
else if (ref) ref.current = el;
|
|
96
|
+
}}
|
|
97
|
+
id={id ?? field?.id}
|
|
98
|
+
type="search"
|
|
99
|
+
value={text}
|
|
100
|
+
placeholder={placeholder}
|
|
101
|
+
disabled={off}
|
|
102
|
+
aria-describedby={cn(rest['aria-describedby'], field?.describedBy) || undefined}
|
|
103
|
+
onChange={(e) => set(e.target.value)}
|
|
104
|
+
onKeyDown={(e) => {
|
|
105
|
+
rest.onKeyDown?.(e);
|
|
106
|
+
if (e.key === 'Escape' && text) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
set('');
|
|
109
|
+
}
|
|
110
|
+
}}
|
|
111
|
+
className={cn(controlInner, '[&::-webkit-search-cancel-button]:hidden')}
|
|
112
|
+
/>
|
|
113
|
+
<span aria-live="polite" className="flex shrink-0 items-center gap-2 text-meta whitespace-nowrap text-fg-secondary tabular-nums">
|
|
114
|
+
{status === 'searching' ? <Spinner label="Searching" size={12} /> : count !== null ? formatCount(count) : null}
|
|
115
|
+
</span>
|
|
116
|
+
{text ? (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
aria-label={clearLabel}
|
|
120
|
+
onClick={() => {
|
|
121
|
+
set('');
|
|
122
|
+
inner.current?.focus();
|
|
123
|
+
}}
|
|
124
|
+
className="inline-grid shrink-0 cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
|
|
125
|
+
>
|
|
126
|
+
<X size={13} aria-hidden />
|
|
127
|
+
</button>
|
|
128
|
+
) : (
|
|
129
|
+
shortcut && (
|
|
130
|
+
<kbd aria-hidden className="shrink-0 rounded-xs border border-rule px-1.5 font-sans text-[11px] leading-[18px] text-fg-secondary">
|
|
131
|
+
/
|
|
132
|
+
</kbd>
|
|
133
|
+
)
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as RadioPrimitive from '@radix-ui/react-radio-group';
|
|
2
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { definedAria } from './inputs-aria';
|
|
6
|
+
import { Tooltip } from './Tooltip';
|
|
7
|
+
|
|
8
|
+
export type Segment = {
|
|
9
|
+
value: string;
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
/** Unavailable; say why (shown on hover and focus, and read out). */
|
|
12
|
+
disabled?: boolean | undefined;
|
|
13
|
+
reason?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SegmentedControlProps = {
|
|
17
|
+
segments: Segment[];
|
|
18
|
+
value?: string | undefined;
|
|
19
|
+
defaultValue?: string | undefined;
|
|
20
|
+
onChange?: ((value: string) => void) | undefined;
|
|
21
|
+
/** Accessible name when there is no visible label. */
|
|
22
|
+
'aria-label'?: string | undefined;
|
|
23
|
+
'aria-labelledby'?: string | undefined;
|
|
24
|
+
/** 32 · 38. @default "md" */
|
|
25
|
+
size?: 'sm' | 'md' | undefined;
|
|
26
|
+
className?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SegmentedControl — two to five mutually exclusive views or modes, side
|
|
31
|
+
* by side. Radio semantics: one tab stop, arrows move and choose. The
|
|
32
|
+
* selected segment sits on the brand colour and the marker glides to it
|
|
33
|
+
* (still, with reduced motion). A disabled segment explains why.
|
|
34
|
+
*/
|
|
35
|
+
export function SegmentedControl({ segments, value, defaultValue, onChange, size = 'md', className, ...aria }: SegmentedControlProps) {
|
|
36
|
+
const [own, setOwn] = useState(defaultValue ?? segments.find((s) => !s.disabled)?.value);
|
|
37
|
+
const current = value ?? own;
|
|
38
|
+
const root = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
const [marker, setMarker] = useState<{ left: number; width: number } | null>(null);
|
|
40
|
+
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const el = root.current?.querySelector<HTMLElement>('[data-state="checked"]');
|
|
43
|
+
setMarker(el ? { left: el.offsetLeft, width: el.offsetWidth } : null);
|
|
44
|
+
}, [current, segments]);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<RadioPrimitive.Root
|
|
48
|
+
ref={root}
|
|
49
|
+
value={current ?? ''}
|
|
50
|
+
onValueChange={(v) => {
|
|
51
|
+
if (value === undefined) setOwn(v);
|
|
52
|
+
onChange?.(v);
|
|
53
|
+
}}
|
|
54
|
+
orientation="horizontal"
|
|
55
|
+
loop
|
|
56
|
+
{...definedAria(aria)}
|
|
57
|
+
className={cn('relative inline-flex rounded-md border border-rule bg-sunk p-[3px] font-sans', className)}
|
|
58
|
+
>
|
|
59
|
+
{marker && (
|
|
60
|
+
<span
|
|
61
|
+
aria-hidden
|
|
62
|
+
className="absolute top-[3px] bottom-[3px] rounded-sm bg-brand-field motion-safe:transition-[left,width] motion-safe:duration-[320ms] motion-safe:ease-out"
|
|
63
|
+
style={{ left: marker.left, width: marker.width }}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
{segments.map((s) => {
|
|
67
|
+
const item = (
|
|
68
|
+
<RadioPrimitive.Item
|
|
69
|
+
key={s.value}
|
|
70
|
+
value={s.value}
|
|
71
|
+
disabled={s.disabled}
|
|
72
|
+
className={cn(
|
|
73
|
+
'relative z-[1] inline-flex cursor-pointer items-center gap-1.5 rounded-sm px-3 font-medium whitespace-nowrap text-fg-secondary outline-none',
|
|
74
|
+
'transition-colors duration-[120ms] ease-standard hover:text-fg focus-visible:shadow-(--focus-ring)',
|
|
75
|
+
'data-[state=checked]:text-ink disabled:cursor-not-allowed disabled:opacity-40',
|
|
76
|
+
size === 'sm' ? 'h-[24px] text-meta' : 'h-[30px] text-[13px]',
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{s.label}
|
|
80
|
+
{s.disabled && s.reason && <span className="sr-only">, {s.reason}</span>}
|
|
81
|
+
</RadioPrimitive.Item>
|
|
82
|
+
);
|
|
83
|
+
return s.disabled && s.reason ? (
|
|
84
|
+
<Tooltip key={s.value} content={s.reason}>
|
|
85
|
+
<span className="relative z-[1] inline-flex" tabIndex={-1}>
|
|
86
|
+
{item}
|
|
87
|
+
</span>
|
|
88
|
+
</Tooltip>
|
|
89
|
+
) : (
|
|
90
|
+
item
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</RadioPrimitive.Root>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import * as Popover from '@radix-ui/react-popover';
|
|
2
|
+
import { Check, ChevronDown, Search, X } from 'lucide-react';
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
import { useField } from './Field';
|
|
7
|
+
import { controlFrame } from './forms-control';
|
|
8
|
+
|
|
9
|
+
export type SelectOption = {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** Right-aligned secondary text: a count, a date — or, on a disabled row, the reason. */
|
|
13
|
+
meta?: string | undefined;
|
|
14
|
+
/** Rows sharing a group label are headed by it, in source order. */
|
|
15
|
+
group?: string | undefined;
|
|
16
|
+
disabled?: boolean | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SelectProps = {
|
|
20
|
+
options: Array<string | SelectOption>;
|
|
21
|
+
value?: string | undefined;
|
|
22
|
+
onChange?: ((value: string) => void) | undefined;
|
|
23
|
+
/** @default "Choose one" */
|
|
24
|
+
placeholder?: string | undefined;
|
|
25
|
+
/** A filter field at the top of the menu. Defaults to on above 8 options. */
|
|
26
|
+
searchable?: boolean | undefined;
|
|
27
|
+
/** @default "Nothing matches that." */
|
|
28
|
+
emptyText?: string | undefined;
|
|
29
|
+
/** Placeholder of the filter field. @default "Filter" */
|
|
30
|
+
filterPlaceholder?: string | undefined;
|
|
31
|
+
invalid?: boolean | undefined;
|
|
32
|
+
disabled?: boolean | undefined;
|
|
33
|
+
/** 32 · 38 · 44. @default "md" */
|
|
34
|
+
size?: 'sm' | 'md' | 'lg' | undefined;
|
|
35
|
+
/** auto (default): opens upward when below is short and above has more room; top / bottom force it. */
|
|
36
|
+
placement?: 'auto' | 'top' | 'bottom' | undefined;
|
|
37
|
+
/** Start open (uncontrolled). */
|
|
38
|
+
defaultOpen?: boolean | undefined;
|
|
39
|
+
id?: string | undefined;
|
|
40
|
+
name?: string | undefined;
|
|
41
|
+
className?: string | undefined;
|
|
42
|
+
'aria-label'?: string | undefined;
|
|
43
|
+
'aria-labelledby'?: string | undefined;
|
|
44
|
+
'aria-describedby'?: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TYPEAHEAD_MS = 600;
|
|
48
|
+
|
|
49
|
+
function normalise(options: SelectProps['options']): SelectOption[] {
|
|
50
|
+
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Select — a styled listbox for a fixed set of 3 to 15 (two options: a
|
|
55
|
+
* switch or segment; many or remote: the async search select). The menu
|
|
56
|
+
* renders in a portal so dialogs don't clip it and opens upward when it
|
|
57
|
+
* must. Arrows move, Enter chooses, Esc closes, Home/End jump, a letter
|
|
58
|
+
* jumps to it. The chosen row sits on the brand colour; disabled rows stay
|
|
59
|
+
* visible with their reason. No shadow — paper lifted by its hairline.
|
|
60
|
+
*/
|
|
61
|
+
export function Select({
|
|
62
|
+
options,
|
|
63
|
+
value,
|
|
64
|
+
onChange,
|
|
65
|
+
placeholder = 'Choose one',
|
|
66
|
+
searchable,
|
|
67
|
+
emptyText = 'Nothing matches that.',
|
|
68
|
+
filterPlaceholder = 'Filter',
|
|
69
|
+
invalid,
|
|
70
|
+
disabled,
|
|
71
|
+
size,
|
|
72
|
+
placement = 'auto',
|
|
73
|
+
defaultOpen = false,
|
|
74
|
+
id,
|
|
75
|
+
name,
|
|
76
|
+
className,
|
|
77
|
+
...aria
|
|
78
|
+
}: SelectProps) {
|
|
79
|
+
const field = useField();
|
|
80
|
+
const auto = useId();
|
|
81
|
+
const listId = `sl${auto}-list`;
|
|
82
|
+
const triggerId = id ?? field?.id ?? `sl${auto}-trigger`;
|
|
83
|
+
const optionId = (i: number) => `sl${auto}-o${i}`;
|
|
84
|
+
const all = useMemo(() => normalise(options), [options]);
|
|
85
|
+
const canSearch = searchable ?? all.length > 8;
|
|
86
|
+
const selected = all.find((o) => o.value === value);
|
|
87
|
+
const bad = Boolean(invalid || field?.invalid);
|
|
88
|
+
const off = Boolean(disabled || field?.disabled);
|
|
89
|
+
|
|
90
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
91
|
+
const [query, setQuery] = useState('');
|
|
92
|
+
const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, all.findIndex((o) => o.value === value)) : -1));
|
|
93
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
94
|
+
const filterRef = useRef<HTMLInputElement | null>(null);
|
|
95
|
+
const typed = useRef({ text: '', at: 0 });
|
|
96
|
+
|
|
97
|
+
const shown = useMemo(() => {
|
|
98
|
+
const q = query.trim().toLowerCase();
|
|
99
|
+
return q ? all.filter((o) => `${o.label} ${o.meta ?? ''}`.toLowerCase().includes(q)) : all;
|
|
100
|
+
}, [all, query]);
|
|
101
|
+
|
|
102
|
+
const enabled = (i: number) => i >= 0 && i < shown.length && !shown[i]!.disabled;
|
|
103
|
+
const step = (from: number, dir: 1 | -1) => {
|
|
104
|
+
for (let i = from + dir; i >= 0 && i < shown.length; i += dir) if (enabled(i)) return i;
|
|
105
|
+
return from;
|
|
106
|
+
};
|
|
107
|
+
const first = () => step(-1, 1);
|
|
108
|
+
const last = () => step(shown.length, -1);
|
|
109
|
+
|
|
110
|
+
// Keep the active row in view.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!open || active < 0) return;
|
|
113
|
+
const el = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`);
|
|
114
|
+
el?.scrollIntoView?.({ block: 'nearest' });
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
116
|
+
}, [active, open]);
|
|
117
|
+
|
|
118
|
+
const openWith = (index?: number) => {
|
|
119
|
+
setQuery('');
|
|
120
|
+
const cur = all.findIndex((o) => o.value === value);
|
|
121
|
+
setActive(index ?? (cur >= 0 && !all[cur]!.disabled ? cur : step(-1, 1)));
|
|
122
|
+
setOpen(true);
|
|
123
|
+
};
|
|
124
|
+
const choose = (i: number) => {
|
|
125
|
+
const o = shown[i];
|
|
126
|
+
if (!o || o.disabled) return;
|
|
127
|
+
onChange?.(o.value);
|
|
128
|
+
setOpen(false);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** First-letter jump: repeated keys cycle, fast typing matches a prefix. */
|
|
132
|
+
const typeahead = (key: string, from: number) => {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const t = typed.current;
|
|
135
|
+
t.text = now - t.at < TYPEAHEAD_MS ? t.text + key.toLowerCase() : key.toLowerCase();
|
|
136
|
+
t.at = now;
|
|
137
|
+
const cycling = t.text.length > 1 && t.text.split('').every((c) => c === t.text[0]);
|
|
138
|
+
const prefix = cycling ? t.text[0]! : t.text;
|
|
139
|
+
const start = cycling || t.text.length === 1 ? from + 1 : from;
|
|
140
|
+
for (let n = 0; n < shown.length; n++) {
|
|
141
|
+
const i = (start + n) % shown.length;
|
|
142
|
+
if (enabled(i) && shown[i]!.label.toLowerCase().startsWith(prefix)) return i;
|
|
143
|
+
}
|
|
144
|
+
return -1;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
148
|
+
if (off || open) return;
|
|
149
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
openWith();
|
|
152
|
+
} else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
153
|
+
const cur = all.findIndex((o) => o.value === value);
|
|
154
|
+
const hit = typeahead(e.key, cur);
|
|
155
|
+
if (hit >= 0) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
openWith(hit);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const onMenuKey = (e: KeyboardEvent<HTMLElement>) => {
|
|
163
|
+
const inFilter = e.currentTarget === filterRef.current;
|
|
164
|
+
switch (e.key) {
|
|
165
|
+
case 'ArrowDown':
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
setActive((a) => (a < 0 ? first() : step(a, 1)));
|
|
168
|
+
return;
|
|
169
|
+
case 'ArrowUp':
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
setActive((a) => (a < 0 ? last() : step(a, -1)));
|
|
172
|
+
return;
|
|
173
|
+
case 'Home':
|
|
174
|
+
if (inFilter) return;
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
setActive(first());
|
|
177
|
+
return;
|
|
178
|
+
case 'End':
|
|
179
|
+
if (inFilter) return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
setActive(last());
|
|
182
|
+
return;
|
|
183
|
+
case 'Enter':
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
choose(active);
|
|
186
|
+
return;
|
|
187
|
+
case 'Tab':
|
|
188
|
+
setOpen(false);
|
|
189
|
+
return;
|
|
190
|
+
default:
|
|
191
|
+
if (!inFilter && e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
192
|
+
const hit = typeahead(e.key, active);
|
|
193
|
+
if (hit >= 0) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
setActive(hit);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Rows in source order, runs of a group wrapped in a labelled group.
|
|
202
|
+
const rows: ReactNode[] = [];
|
|
203
|
+
let i = 0;
|
|
204
|
+
while (i < shown.length) {
|
|
205
|
+
const group = shown[i]!.group;
|
|
206
|
+
const start = i;
|
|
207
|
+
while (i < shown.length && shown[i]!.group === group) i++;
|
|
208
|
+
const items = shown.slice(start, i).map((o, k) => {
|
|
209
|
+
const idx = start + k;
|
|
210
|
+
const on = o.value === value;
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
key={o.value}
|
|
214
|
+
id={optionId(idx)}
|
|
215
|
+
role="option"
|
|
216
|
+
aria-label={o.meta ? `${o.label}, ${o.meta}` : undefined}
|
|
217
|
+
aria-selected={on}
|
|
218
|
+
aria-disabled={o.disabled || undefined}
|
|
219
|
+
data-active={idx === active || undefined}
|
|
220
|
+
onMouseMove={() => enabled(idx) && idx !== active && setActive(idx)}
|
|
221
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
222
|
+
onClick={() => choose(idx)}
|
|
223
|
+
className={cn(
|
|
224
|
+
'flex items-center gap-2 rounded-md px-2.5 py-2 text-[13px] leading-[1.35] text-fg transition-colors duration-[120ms] ease-standard',
|
|
225
|
+
o.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
|
|
226
|
+
on ? 'bg-brand-field font-medium text-ink' : idx === active && 'bg-hover',
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<span className="min-w-0 flex-1 truncate">{o.label}</span>
|
|
230
|
+
{o.meta && <span className={cn('shrink-0 text-meta tabular-nums', on ? 'text-fg-on-yellow-secondary' : 'text-fg-secondary')}>{o.meta}</span>}
|
|
231
|
+
{on && <Check size={13} strokeWidth={2} aria-hidden className="shrink-0" />}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
if (group) {
|
|
236
|
+
const gid = `sl${auto}-g${start}`;
|
|
237
|
+
rows.push(
|
|
238
|
+
<div key={gid} role="group" aria-labelledby={gid}>
|
|
239
|
+
<div id={gid} className="px-2.5 pt-2 pb-1 text-meta font-medium text-fg-secondary">
|
|
240
|
+
{group}
|
|
241
|
+
</div>
|
|
242
|
+
{items}
|
|
243
|
+
</div>,
|
|
244
|
+
);
|
|
245
|
+
} else rows.push(...items);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Popover.Root open={open} onOpenChange={(o) => (o ? openWith() : setOpen(false))}>
|
|
250
|
+
<Popover.Trigger asChild disabled={off}>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
id={triggerId}
|
|
254
|
+
role="combobox"
|
|
255
|
+
aria-expanded={open}
|
|
256
|
+
aria-haspopup="listbox"
|
|
257
|
+
aria-controls={open ? listId : undefined}
|
|
258
|
+
aria-invalid={bad || undefined}
|
|
259
|
+
aria-label={aria['aria-label']}
|
|
260
|
+
aria-labelledby={aria['aria-labelledby']}
|
|
261
|
+
aria-describedby={cn(aria['aria-describedby'], field?.describedBy) || undefined}
|
|
262
|
+
disabled={off}
|
|
263
|
+
onKeyDown={onTriggerKey}
|
|
264
|
+
className={cn(
|
|
265
|
+
controlFrame({ size, invalid: bad, disabled: off }),
|
|
266
|
+
'cursor-pointer text-left outline-none focus-visible:border-fg focus-visible:shadow-(--focus-ring) disabled:cursor-not-allowed',
|
|
267
|
+
open && !bad && 'border-fg shadow-(--focus-ring)',
|
|
268
|
+
className,
|
|
269
|
+
)}
|
|
270
|
+
>
|
|
271
|
+
<span className={cn('min-w-0 flex-1 truncate', !selected && 'text-fg-secondary')}>{selected ? selected.label : placeholder}</span>
|
|
272
|
+
{selected?.meta && <span className="shrink-0 text-meta text-fg-secondary">{selected.meta}</span>}
|
|
273
|
+
<ChevronDown
|
|
274
|
+
size={14}
|
|
275
|
+
aria-hidden
|
|
276
|
+
className={cn('shrink-0 text-fg-secondary transition-transform duration-[320ms] ease-out', open && 'rotate-180')}
|
|
277
|
+
/>
|
|
278
|
+
</button>
|
|
279
|
+
</Popover.Trigger>
|
|
280
|
+
{name && <input type="hidden" name={name} value={value ?? ''} />}
|
|
281
|
+
<Popover.Portal>
|
|
282
|
+
<Popover.Content
|
|
283
|
+
side={placement === 'top' ? 'top' : 'bottom'}
|
|
284
|
+
align="start"
|
|
285
|
+
sideOffset={6}
|
|
286
|
+
{...(aria['aria-labelledby'] ?? field?.labelId
|
|
287
|
+
? { 'aria-labelledby': aria['aria-labelledby'] ?? field?.labelId }
|
|
288
|
+
: { 'aria-label': aria['aria-label'] ?? placeholder })}
|
|
289
|
+
collisionPadding={12}
|
|
290
|
+
avoidCollisions={placement === 'auto'}
|
|
291
|
+
onOpenAutoFocus={(e) => {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
(canSearch ? filterRef.current : listRef.current)?.focus();
|
|
294
|
+
}}
|
|
295
|
+
className="rise z-60 flex flex-col overflow-hidden rounded-lg border border-rule bg-page p-1 font-sans text-fg [--motion-reveal:200ms]"
|
|
296
|
+
style={{
|
|
297
|
+
minWidth: 'var(--radix-popover-trigger-width)',
|
|
298
|
+
maxHeight: 'min(280px, calc(var(--radix-popover-content-available-height) - 12px))',
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{canSearch && (
|
|
302
|
+
<div className="mb-1 flex shrink-0 items-center gap-[7px] border-b border-rule-soft px-2 pt-[5px] pb-2">
|
|
303
|
+
<Search size={13} aria-hidden className="shrink-0 text-fg-secondary" />
|
|
304
|
+
<input
|
|
305
|
+
ref={filterRef}
|
|
306
|
+
value={query}
|
|
307
|
+
onChange={(e) => {
|
|
308
|
+
setQuery(e.target.value);
|
|
309
|
+
setActive(-1);
|
|
310
|
+
}}
|
|
311
|
+
onKeyDown={onMenuKey}
|
|
312
|
+
placeholder={filterPlaceholder}
|
|
313
|
+
aria-label={filterPlaceholder}
|
|
314
|
+
aria-controls={listId}
|
|
315
|
+
aria-activedescendant={active >= 0 ? optionId(active) : undefined}
|
|
316
|
+
aria-autocomplete="list"
|
|
317
|
+
role="combobox"
|
|
318
|
+
aria-expanded
|
|
319
|
+
className="min-w-0 flex-1 border-none bg-transparent p-0 font-sans text-[13px] text-fg outline-none placeholder:text-fg-secondary"
|
|
320
|
+
/>
|
|
321
|
+
{query && (
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
aria-label="Clear"
|
|
325
|
+
onClick={() => {
|
|
326
|
+
setQuery('');
|
|
327
|
+
filterRef.current?.focus();
|
|
328
|
+
}}
|
|
329
|
+
className="inline-grid cursor-pointer place-items-center rounded-xs p-0.5 text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
|
|
330
|
+
>
|
|
331
|
+
<X size={12} aria-hidden />
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
<div
|
|
337
|
+
ref={listRef}
|
|
338
|
+
id={listId}
|
|
339
|
+
role="listbox"
|
|
340
|
+
tabIndex={canSearch ? undefined : -1}
|
|
341
|
+
aria-activedescendant={!canSearch && active >= 0 ? optionId(active) : undefined}
|
|
342
|
+
onKeyDown={canSearch ? undefined : onMenuKey}
|
|
343
|
+
className="min-h-0 flex-1 overflow-y-auto outline-none"
|
|
344
|
+
>
|
|
345
|
+
{shown.length === 0 ? <div className="px-2.5 pt-2.5 pb-3 text-[12.5px] text-fg-secondary">{emptyText}</div> : rows}
|
|
346
|
+
</div>
|
|
347
|
+
</Popover.Content>
|
|
348
|
+
</Popover.Portal>
|
|
349
|
+
</Popover.Root>
|
|
350
|
+
);
|
|
351
|
+
}
|