@cosxai/ui 0.24.0 → 1.0.0-alpha.0
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 +577 -0
- package/NOTICE +11 -0
- package/README.md +52 -0
- package/package.json +33 -7
- package/src/fonts.css +20 -0
- package/src/index.ts +8 -25
- package/src/lib/cn.ts +23 -6
- package/src/styles.css +17 -0
- package/src/theme.css +132 -0
- package/src/tokens/colors.css +112 -0
- package/src/tokens/compat.css +36 -0
- package/src/tokens/elevation.css +14 -0
- package/src/tokens/motion.css +42 -0
- package/src/tokens/spacing.css +55 -0
- package/src/tokens/typography-cjk.css +42 -0
- package/src/tokens/typography.css +81 -0
- package/src/actionbar/ActionBar.tsx +0 -837
- package/src/actionbar/ActionBarButton.tsx +0 -111
- package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
- package/src/actionbar/ActionBarModeHandle.tsx +0 -171
- package/src/actionbar/ActionBarProvider.tsx +0 -91
- package/src/actionbar/actionbar-context.ts +0 -45
- package/src/actionbar/index.ts +0 -28
- package/src/actionbar/types.ts +0 -147
- package/src/actionbar/useActionBarItems.ts +0 -60
- package/src/actionbar/useActionBarMode.ts +0 -144
- package/src/actionbar/useActionBarPanel.ts +0 -56
- package/src/actionbar/useActionBarStatusDot.ts +0 -49
- package/src/actionbar/useActionBarToast.ts +0 -33
- package/src/ambient/AmbientBackdrop.tsx +0 -74
- package/src/ambient/CommandInput.tsx +0 -107
- package/src/ambient/SuperbarStrip.tsx +0 -36
- package/src/ambient/index.ts +0 -6
- package/src/bento/BentoCell.tsx +0 -66
- package/src/bento/BentoGrid.tsx +0 -42
- package/src/bento/index.ts +0 -2
- package/src/command/CommandPalette.tsx +0 -329
- package/src/command/CommandProvider.tsx +0 -57
- package/src/command/command-context.ts +0 -12
- package/src/command/index.ts +0 -6
- package/src/command/rank.ts +0 -50
- package/src/command/types.ts +0 -37
- package/src/command/useCommandSource.ts +0 -37
- package/src/dialogs/DialogsProvider.tsx +0 -216
- package/src/dialogs/Modal.tsx +0 -260
- package/src/dialogs/Toast.tsx +0 -85
- package/src/dialogs/dialogs-context.ts +0 -6
- package/src/dialogs/index.ts +0 -10
- package/src/dialogs/types.ts +0 -37
- package/src/dialogs/useDialogs.ts +0 -8
- package/src/editorial/EditorialSpotlight.tsx +0 -63
- package/src/editorial/Folio.tsx +0 -52
- package/src/editorial/PlateMarker.tsx +0 -33
- package/src/editorial/RomanSection.tsx +0 -65
- package/src/editorial/RunningMarginalia.tsx +0 -65
- package/src/editorial/index.ts +0 -10
- package/src/hooks/index.ts +0 -5
- package/src/hooks/useKeyboardHotkey.ts +0 -93
- package/src/hooks/useReducedMotion.ts +0 -20
- package/src/hooks/useViewport.ts +0 -61
- package/src/layout/Breadcrumb.tsx +0 -74
- package/src/layout/LeftNavRail.tsx +0 -139
- package/src/layout/MobileTabBar.tsx +0 -101
- package/src/layout/NavItem.tsx +0 -128
- package/src/layout/NavSearchTrigger.tsx +0 -88
- package/src/layout/NavSection.tsx +0 -40
- package/src/layout/RightSidebarPanel.tsx +0 -111
- package/src/layout/Shell.tsx +0 -91
- package/src/layout/SidePanel.tsx +0 -316
- package/src/layout/StickyBanner.tsx +0 -83
- package/src/layout/Topbar.tsx +0 -68
- package/src/layout/index.ts +0 -24
- package/src/layout/useNavRailState.ts +0 -69
- package/src/lib/time-utils.ts +0 -44
- package/src/neobrutalism/Marquee.tsx +0 -81
- package/src/neobrutalism/Sticker.tsx +0 -71
- package/src/neobrutalism/index.ts +0 -4
- package/src/primitives/Avatar.tsx +0 -53
- package/src/primitives/Button.tsx +0 -67
- package/src/primitives/Card.tsx +0 -41
- package/src/primitives/Checkbox.tsx +0 -87
- package/src/primitives/Chip.tsx +0 -177
- package/src/primitives/Combobox.tsx +0 -475
- package/src/primitives/CopyField.tsx +0 -139
- package/src/primitives/CountBadge.tsx +0 -50
- package/src/primitives/Input.tsx +0 -161
- package/src/primitives/Kbd.tsx +0 -45
- package/src/primitives/MentionCombobox.tsx +0 -612
- package/src/primitives/MultiCombobox.tsx +0 -179
- package/src/primitives/PageHeader.tsx +0 -77
- package/src/primitives/Radio.tsx +0 -131
- package/src/primitives/SegmentedControl.tsx +0 -135
- package/src/primitives/Select.tsx +0 -779
- package/src/primitives/SignaturePad.tsx +0 -461
- package/src/primitives/Tag.tsx +0 -56
- package/src/primitives/Textarea.tsx +0 -62
- package/src/primitives/ToggleSwitch.tsx +0 -79
- package/src/primitives/Tooltip.tsx +0 -213
- package/src/primitives/index.ts +0 -48
- package/src/pwa/InstallPromptBanner.tsx +0 -132
- package/src/pwa/index.ts +0 -4
- package/src/pwa/manifest.template.json +0 -20
- package/src/pwa/registerSW.ts +0 -55
- package/src/riso/Halftone.tsx +0 -85
- package/src/riso/Misregister.tsx +0 -63
- package/src/riso/RisoStamp.tsx +0 -76
- package/src/riso/index.ts +0 -3
- package/src/sketch/HandUnderline.tsx +0 -53
- package/src/sketch/RoughArrow.tsx +0 -91
- package/src/sketch/RoughBox.tsx +0 -73
- package/src/sketch/StickyNote.tsx +0 -56
- package/src/sketch/index.ts +0 -4
- package/src/styles/base.css +0 -186
- package/src/styles/chrome-ambient.css +0 -235
- package/src/styles/chrome-bento.css +0 -192
- package/src/styles/chrome-editorial.css +0 -168
- package/src/styles/chrome-neobrutalism.css +0 -326
- package/src/styles/chrome-riso.css +0 -339
- package/src/styles/chrome-sketch.css +0 -362
- package/src/styles/chrome-swiss.css +0 -265
- package/src/styles/chrome-terminal.css +0 -272
- package/src/styles/fonts.css +0 -1034
- package/src/styles/index.css +0 -306
- package/src/styles/tokens.css +0 -941
- package/src/terminal/AsciiBox.tsx +0 -65
- package/src/terminal/BrailleSpinner.tsx +0 -46
- package/src/terminal/index.ts +0 -4
- package/src/theme/ThemeProvider.tsx +0 -99
- package/src/theme/index.ts +0 -6
- package/src/theme/inline-script.ts +0 -40
- package/src/theme/theme-context.ts +0 -7
- package/src/theme/types.ts +0 -39
- package/src/theme/useTheme.ts +0 -8
|
@@ -1,779 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
forwardRef,
|
|
3
|
-
useCallback,
|
|
4
|
-
useEffect,
|
|
5
|
-
useId,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
type CSSProperties,
|
|
11
|
-
type KeyboardEvent,
|
|
12
|
-
type ReactNode,
|
|
13
|
-
} from "react";
|
|
14
|
-
import { createPortal } from "react-dom";
|
|
15
|
-
|
|
16
|
-
import { cn } from "../lib/cn";
|
|
17
|
-
|
|
18
|
-
// Custom listbox-pattern select. We deliberately do NOT use native
|
|
19
|
-
// <select> — browsers refuse to style the popup, so terminal /
|
|
20
|
-
// editorial / swiss etc. would punch through with macOS/Win blue
|
|
21
|
-
// and break the visual contract.
|
|
22
|
-
//
|
|
23
|
-
// Two modes via `searchable`:
|
|
24
|
-
//
|
|
25
|
-
// searchable=false (default)
|
|
26
|
-
// Trigger button. Keyboard handlers live on the trigger.
|
|
27
|
-
// Typeahead (A-Z / 0-9) jumps to next matching label.
|
|
28
|
-
// Right for ≤ ~10 short options.
|
|
29
|
-
//
|
|
30
|
-
// searchable=true
|
|
31
|
-
// Trigger button + a search input pinned at the top of the
|
|
32
|
-
// popover. Focus moves to the search input on open; the
|
|
33
|
-
// listbox below filters by case-insensitive label substring.
|
|
34
|
-
// Right for long lists (countries, currencies, time zones,
|
|
35
|
-
// user pickers).
|
|
36
|
-
//
|
|
37
|
-
// Popover ALWAYS renders via createPortal to document.body so it
|
|
38
|
-
// escapes Card / Drawer / Dialog parents whose overflow:hidden
|
|
39
|
-
// would otherwise clip it. Position is computed against the
|
|
40
|
-
// trigger's bounding rect and re-computed on page scroll + window
|
|
41
|
-
// resize so the popover tracks the trigger. Scrolls INSIDE the
|
|
42
|
-
// popover (the option list / search input) are filtered out — only
|
|
43
|
-
// outer scrolls reposition.
|
|
44
|
-
//
|
|
45
|
-
// Keyboard model (mirrors ARIA 1.2 combobox-as-listbox spec):
|
|
46
|
-
// trigger CLOSED:
|
|
47
|
-
// Space / Enter → open
|
|
48
|
-
// ArrowDown / ArrowUp → open (first / last)
|
|
49
|
-
// A-Z / 0-9 (!searchable) → open + typeahead
|
|
50
|
-
// open, !searchable, on trigger:
|
|
51
|
-
// ArrowDown / ArrowUp → move highlight
|
|
52
|
-
// Home / End → first / last
|
|
53
|
-
// Enter → commit highlighted
|
|
54
|
-
// Escape → close
|
|
55
|
-
// Tab → close, focus advances
|
|
56
|
-
// A-Z / 0-9 → typeahead (500 ms reset window)
|
|
57
|
-
// open, searchable, on search input:
|
|
58
|
-
// Typing → filter
|
|
59
|
-
// ArrowDown / ArrowUp → move highlight inside filtered list
|
|
60
|
-
// Home / End → first / last in filtered list
|
|
61
|
-
// Enter → commit highlighted
|
|
62
|
-
// Escape → close
|
|
63
|
-
// Tab → close, focus advances
|
|
64
|
-
|
|
65
|
-
export interface SelectOption {
|
|
66
|
-
value: string;
|
|
67
|
-
label: string;
|
|
68
|
-
// Optional disabled flag — the option renders dimmed and isn't
|
|
69
|
-
// selectable via click / keyboard. Useful for "Coming soon" rows.
|
|
70
|
-
disabled?: boolean | undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface SelectProps {
|
|
74
|
-
value: string;
|
|
75
|
-
onChange: (value: string) => void;
|
|
76
|
-
options: SelectOption[];
|
|
77
|
-
label?: ReactNode | undefined;
|
|
78
|
-
helper?: ReactNode | undefined;
|
|
79
|
-
error?: string | null | undefined;
|
|
80
|
-
placeholder?: string | undefined;
|
|
81
|
-
// full = stretches to container width; auto = intrinsic.
|
|
82
|
-
fit?: "full" | "auto" | undefined;
|
|
83
|
-
disabled?: boolean | undefined;
|
|
84
|
-
required?: boolean | undefined;
|
|
85
|
-
name?: string | undefined;
|
|
86
|
-
id?: string | undefined;
|
|
87
|
-
className?: string | undefined;
|
|
88
|
-
// Max height of the popover. Long lists scroll inside.
|
|
89
|
-
maxOptionsHeight?: number | undefined;
|
|
90
|
-
// Show a search input pinned at the top of the popover. Filters
|
|
91
|
-
// options by case-insensitive label substring. Recommended for
|
|
92
|
-
// lists > ~10 items.
|
|
93
|
-
searchable?: boolean | undefined;
|
|
94
|
-
// Placeholder for the search input (when searchable).
|
|
95
|
-
searchPlaceholder?: string | undefined;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const TRIGGER_BASE_STYLE: CSSProperties = {
|
|
99
|
-
display: "inline-flex",
|
|
100
|
-
alignItems: "center",
|
|
101
|
-
justifyContent: "space-between",
|
|
102
|
-
gap: 8,
|
|
103
|
-
width: "100%",
|
|
104
|
-
height: 36,
|
|
105
|
-
padding: "0 12px",
|
|
106
|
-
font: "400 13px/1 var(--ck-font-sans)",
|
|
107
|
-
background: "var(--ck-bg-surface)",
|
|
108
|
-
color: "var(--ck-text-primary)",
|
|
109
|
-
borderRadius: "var(--ck-radius-sm)",
|
|
110
|
-
outline: "none",
|
|
111
|
-
textAlign: "left",
|
|
112
|
-
cursor: "pointer",
|
|
113
|
-
transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
// Visual gap between the trigger's bottom edge and the popover's
|
|
117
|
-
// top edge. Matches Radix / Headless UI default; small enough to
|
|
118
|
-
// read as "attached", large enough not to feel sticky.
|
|
119
|
-
const POPOVER_GAP = 4;
|
|
120
|
-
|
|
121
|
-
interface PopoverRect {
|
|
122
|
-
top: number;
|
|
123
|
-
left: number;
|
|
124
|
-
width: number;
|
|
125
|
-
/** Set when the menu opens UPWARD (trigger near the viewport
|
|
126
|
-
* bottom): `top` is then the popover's bottom edge expressed as
|
|
127
|
-
* a CSS `bottom` inset. */
|
|
128
|
-
openUp: boolean;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
|
|
132
|
-
{
|
|
133
|
-
value,
|
|
134
|
-
onChange,
|
|
135
|
-
options,
|
|
136
|
-
label,
|
|
137
|
-
helper,
|
|
138
|
-
error,
|
|
139
|
-
placeholder,
|
|
140
|
-
fit = "full",
|
|
141
|
-
disabled,
|
|
142
|
-
required,
|
|
143
|
-
name,
|
|
144
|
-
id,
|
|
145
|
-
className,
|
|
146
|
-
maxOptionsHeight = 280,
|
|
147
|
-
searchable = false,
|
|
148
|
-
searchPlaceholder = "Search…",
|
|
149
|
-
},
|
|
150
|
-
ref,
|
|
151
|
-
) {
|
|
152
|
-
const autoId = useId();
|
|
153
|
-
const triggerId = id ?? `${autoId}-trigger`;
|
|
154
|
-
const listboxId = `${autoId}-listbox`;
|
|
155
|
-
|
|
156
|
-
const [open, setOpen] = useState(false);
|
|
157
|
-
const [highlight, setHighlight] = useState(-1);
|
|
158
|
-
const [query, setQuery] = useState("");
|
|
159
|
-
const [rect, setRect] = useState<PopoverRect | null>(null);
|
|
160
|
-
|
|
161
|
-
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
162
|
-
const popoverRef = useRef<HTMLDivElement | null>(null);
|
|
163
|
-
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
164
|
-
const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
|
|
165
|
-
const typeaheadRef = useRef<{ buffer: string; resetAt: number }>({
|
|
166
|
-
buffer: "",
|
|
167
|
-
resetAt: 0,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// Filtered options when searchable; identical otherwise. Filter
|
|
171
|
-
// by case-insensitive label substring. Disabled options remain
|
|
172
|
-
// visible but unselectable; consumers can pre-filter their
|
|
173
|
-
// options array if they want them hidden when search is non-empty.
|
|
174
|
-
const filteredOptions = useMemo(() => {
|
|
175
|
-
if (!searchable || query.trim() === "") return options;
|
|
176
|
-
const q = query.trim().toLowerCase();
|
|
177
|
-
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
178
|
-
}, [searchable, options, query]);
|
|
179
|
-
|
|
180
|
-
const selectedIndex = useMemo(
|
|
181
|
-
() => options.findIndex((o) => o.value === value),
|
|
182
|
-
[options, value],
|
|
183
|
-
);
|
|
184
|
-
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
|
|
185
|
-
|
|
186
|
-
const setTriggerRef = useCallback(
|
|
187
|
-
(el: HTMLButtonElement | null) => {
|
|
188
|
-
triggerRef.current = el;
|
|
189
|
-
if (typeof ref === "function") ref(el);
|
|
190
|
-
else if (ref) ref.current = el;
|
|
191
|
-
},
|
|
192
|
-
[ref],
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
const computeRect = useCallback((): PopoverRect | null => {
|
|
196
|
-
const el = triggerRef.current;
|
|
197
|
-
if (!el) return null;
|
|
198
|
-
const r = el.getBoundingClientRect();
|
|
199
|
-
// Flip upward when the space below can't fit the menu but the
|
|
200
|
-
// space above can (QA 2026-08-20: the last options of a Select
|
|
201
|
-
// at the bottom of a drawer sat under the fixed action bar).
|
|
202
|
-
// Worst-case menu height; the listbox scrolls inside either way.
|
|
203
|
-
const needed = maxOptionsHeight + (searchable ? 48 : 0) + POPOVER_GAP;
|
|
204
|
-
const below = window.innerHeight - r.bottom;
|
|
205
|
-
const openUp = below < needed && r.top > below;
|
|
206
|
-
return {
|
|
207
|
-
top: openUp ? window.innerHeight - r.top + POPOVER_GAP : r.bottom + POPOVER_GAP,
|
|
208
|
-
left: r.left,
|
|
209
|
-
width: r.width,
|
|
210
|
-
openUp,
|
|
211
|
-
};
|
|
212
|
-
}, [maxOptionsHeight, searchable]);
|
|
213
|
-
|
|
214
|
-
// Position the popover on open + on window resize. Close on
|
|
215
|
-
// page scroll — keeps the popover from drifting off the trigger
|
|
216
|
-
// while the user pages around.
|
|
217
|
-
useLayoutEffect(() => {
|
|
218
|
-
if (!open) return;
|
|
219
|
-
setRect(computeRect());
|
|
220
|
-
}, [open, computeRect]);
|
|
221
|
-
|
|
222
|
-
useEffect(() => {
|
|
223
|
-
if (!open) return;
|
|
224
|
-
const reposition = () => {
|
|
225
|
-
const r = computeRect();
|
|
226
|
-
if (r) setRect(r);
|
|
227
|
-
};
|
|
228
|
-
const onResize = reposition;
|
|
229
|
-
const onScroll = (e: Event) => {
|
|
230
|
-
// Scrolls INSIDE the popover (the option list / search input)
|
|
231
|
-
// must not re-trigger positioning — they're not page scrolls.
|
|
232
|
-
// capture=true catches them before they bubble; we filter here.
|
|
233
|
-
const target = e.target as Node | null;
|
|
234
|
-
if (target && popoverRef.current?.contains(target)) return;
|
|
235
|
-
reposition();
|
|
236
|
-
};
|
|
237
|
-
window.addEventListener("resize", onResize);
|
|
238
|
-
window.addEventListener("scroll", onScroll, true);
|
|
239
|
-
return () => {
|
|
240
|
-
window.removeEventListener("resize", onResize);
|
|
241
|
-
window.removeEventListener("scroll", onScroll, true);
|
|
242
|
-
};
|
|
243
|
-
}, [open, computeRect]);
|
|
244
|
-
|
|
245
|
-
// Close on outside click + Esc (Esc handled in onKey below).
|
|
246
|
-
useEffect(() => {
|
|
247
|
-
if (!open) return;
|
|
248
|
-
const onDocMouseDown = (e: MouseEvent) => {
|
|
249
|
-
const t = e.target as Node | null;
|
|
250
|
-
if (!t) return;
|
|
251
|
-
if (triggerRef.current?.contains(t)) return;
|
|
252
|
-
if (popoverRef.current?.contains(t)) return;
|
|
253
|
-
setOpen(false);
|
|
254
|
-
};
|
|
255
|
-
document.addEventListener("mousedown", onDocMouseDown);
|
|
256
|
-
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
257
|
-
}, [open]);
|
|
258
|
-
|
|
259
|
-
// Auto-scroll highlighted option into view inside the popover.
|
|
260
|
-
useEffect(() => {
|
|
261
|
-
if (!open || highlight < 0) return;
|
|
262
|
-
const el = optionRefs.current[highlight];
|
|
263
|
-
if (el) el.scrollIntoView({ block: "nearest" });
|
|
264
|
-
}, [open, highlight]);
|
|
265
|
-
|
|
266
|
-
// Focus management on open:
|
|
267
|
-
// searchable=true → focus the search input
|
|
268
|
-
// searchable=false → trigger keeps focus (kbd handler lives there)
|
|
269
|
-
useEffect(() => {
|
|
270
|
-
if (!open) return;
|
|
271
|
-
if (searchable) {
|
|
272
|
-
// requestAnimationFrame to let the portal mount before focusing.
|
|
273
|
-
const raf = requestAnimationFrame(() => searchInputRef.current?.focus());
|
|
274
|
-
return () => cancelAnimationFrame(raf);
|
|
275
|
-
}
|
|
276
|
-
}, [open, searchable]);
|
|
277
|
-
|
|
278
|
-
// When the filtered list changes (user types), reset highlight
|
|
279
|
-
// to the first selectable option. Otherwise the highlight could
|
|
280
|
-
// point at an index that no longer exists.
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
if (!open) return;
|
|
283
|
-
if (filteredOptions.length === 0) {
|
|
284
|
-
setHighlight(-1);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
// Try to keep the existing selection visible; else first
|
|
288
|
-
// non-disabled option.
|
|
289
|
-
const selectedInFiltered = filteredOptions.findIndex((o) => o.value === value);
|
|
290
|
-
if (selectedInFiltered >= 0) {
|
|
291
|
-
setHighlight(selectedInFiltered);
|
|
292
|
-
} else {
|
|
293
|
-
const firstEnabled = filteredOptions.findIndex((o) => !o.disabled);
|
|
294
|
-
setHighlight(firstEnabled >= 0 ? firstEnabled : 0);
|
|
295
|
-
}
|
|
296
|
-
}, [open, filteredOptions, value]);
|
|
297
|
-
|
|
298
|
-
const openPopover = useCallback(
|
|
299
|
-
(startHighlight?: number) => {
|
|
300
|
-
if (disabled) return;
|
|
301
|
-
setOpen(true);
|
|
302
|
-
setQuery("");
|
|
303
|
-
setHighlight(
|
|
304
|
-
startHighlight !== undefined
|
|
305
|
-
? startHighlight
|
|
306
|
-
: selectedIndex >= 0
|
|
307
|
-
? selectedIndex
|
|
308
|
-
: 0,
|
|
309
|
-
);
|
|
310
|
-
},
|
|
311
|
-
[disabled, selectedIndex],
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
const closePopover = useCallback(() => {
|
|
315
|
-
setOpen(false);
|
|
316
|
-
setHighlight(-1);
|
|
317
|
-
setQuery("");
|
|
318
|
-
triggerRef.current?.focus();
|
|
319
|
-
}, []);
|
|
320
|
-
|
|
321
|
-
const commit = useCallback(
|
|
322
|
-
(idx: number) => {
|
|
323
|
-
const opt = filteredOptions[idx];
|
|
324
|
-
if (!opt || opt.disabled) return;
|
|
325
|
-
onChange(opt.value);
|
|
326
|
-
closePopover();
|
|
327
|
-
},
|
|
328
|
-
[filteredOptions, onChange, closePopover],
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
// Move highlight skipping disabled rows.
|
|
332
|
-
const moveHighlight = useCallback(
|
|
333
|
-
(delta: 1 | -1, from?: number) => {
|
|
334
|
-
const n = filteredOptions.length;
|
|
335
|
-
if (n === 0) return;
|
|
336
|
-
let i = (from ?? highlight) + delta;
|
|
337
|
-
for (let attempt = 0; attempt < n; attempt++) {
|
|
338
|
-
if (i < 0) i = n - 1;
|
|
339
|
-
if (i >= n) i = 0;
|
|
340
|
-
if (!filteredOptions[i]?.disabled) {
|
|
341
|
-
setHighlight(i);
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
i += delta;
|
|
345
|
-
}
|
|
346
|
-
},
|
|
347
|
-
[filteredOptions, highlight],
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
const typeahead = useCallback(
|
|
351
|
-
(char: string) => {
|
|
352
|
-
const now = Date.now();
|
|
353
|
-
const t = typeaheadRef.current;
|
|
354
|
-
if (now > t.resetAt) t.buffer = "";
|
|
355
|
-
t.buffer += char.toLowerCase();
|
|
356
|
-
t.resetAt = now + 500;
|
|
357
|
-
|
|
358
|
-
const start = highlight >= 0 ? highlight : 0;
|
|
359
|
-
const n = filteredOptions.length;
|
|
360
|
-
for (let off = 1; off <= n; off++) {
|
|
361
|
-
const idx = (start + off) % n;
|
|
362
|
-
const o = filteredOptions[idx];
|
|
363
|
-
if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
|
|
364
|
-
setHighlight(idx);
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
if (t.buffer.length > 1) {
|
|
369
|
-
t.buffer = char.toLowerCase();
|
|
370
|
-
for (let off = 1; off <= n; off++) {
|
|
371
|
-
const idx = (start + off) % n;
|
|
372
|
-
const o = filteredOptions[idx];
|
|
373
|
-
if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
|
|
374
|
-
setHighlight(idx);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
},
|
|
380
|
-
[filteredOptions, highlight],
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
// Keyboard handler for the trigger (non-searchable mode + closed
|
|
384
|
-
// state in searchable mode).
|
|
385
|
-
const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
386
|
-
if (disabled) return;
|
|
387
|
-
if (!open) {
|
|
388
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
389
|
-
e.preventDefault();
|
|
390
|
-
openPopover();
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
if (e.key === "ArrowDown") {
|
|
394
|
-
e.preventDefault();
|
|
395
|
-
openPopover(0);
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
if (e.key === "ArrowUp") {
|
|
399
|
-
e.preventDefault();
|
|
400
|
-
openPopover(options.length - 1);
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
if (!searchable && e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
|
|
404
|
-
openPopover();
|
|
405
|
-
typeahead(e.key);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
// Open + on trigger (only happens when !searchable).
|
|
411
|
-
if (e.key === "Escape") {
|
|
412
|
-
e.preventDefault();
|
|
413
|
-
closePopover();
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (e.key === "Tab") {
|
|
417
|
-
setOpen(false);
|
|
418
|
-
setHighlight(-1);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (e.key === "ArrowDown") {
|
|
422
|
-
e.preventDefault();
|
|
423
|
-
moveHighlight(1);
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (e.key === "ArrowUp") {
|
|
427
|
-
e.preventDefault();
|
|
428
|
-
moveHighlight(-1);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (e.key === "Home") {
|
|
432
|
-
e.preventDefault();
|
|
433
|
-
moveHighlight(1, -1);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
if (e.key === "End") {
|
|
437
|
-
e.preventDefault();
|
|
438
|
-
moveHighlight(-1, options.length);
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
if (e.key === "Enter") {
|
|
442
|
-
e.preventDefault();
|
|
443
|
-
if (highlight >= 0) commit(highlight);
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
|
|
447
|
-
e.preventDefault();
|
|
448
|
-
typeahead(e.key);
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
// Keyboard handler for the search input (searchable mode, open).
|
|
454
|
-
const onSearchKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
455
|
-
if (e.key === "Escape") {
|
|
456
|
-
e.preventDefault();
|
|
457
|
-
closePopover();
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
if (e.key === "Tab") {
|
|
461
|
-
setOpen(false);
|
|
462
|
-
setHighlight(-1);
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
if (e.key === "ArrowDown") {
|
|
466
|
-
e.preventDefault();
|
|
467
|
-
moveHighlight(1);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
if (e.key === "ArrowUp") {
|
|
471
|
-
e.preventDefault();
|
|
472
|
-
moveHighlight(-1);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
if (e.key === "Home") {
|
|
476
|
-
e.preventDefault();
|
|
477
|
-
moveHighlight(1, -1);
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
if (e.key === "End") {
|
|
481
|
-
e.preventDefault();
|
|
482
|
-
moveHighlight(-1, filteredOptions.length);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
if (e.key === "Enter") {
|
|
486
|
-
e.preventDefault();
|
|
487
|
-
if (highlight >= 0) commit(highlight);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
// Plain typing flows to the input value via React's onChange.
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
const triggerLabel = selectedOption ? selectedOption.label : placeholder ?? "Select…";
|
|
494
|
-
const showPlaceholder = !selectedOption;
|
|
495
|
-
|
|
496
|
-
const popoverNode = open && rect && (
|
|
497
|
-
<div
|
|
498
|
-
ref={popoverRef}
|
|
499
|
-
className="ck-select-popover"
|
|
500
|
-
style={{
|
|
501
|
-
position: "fixed",
|
|
502
|
-
...(rect.openUp ? { bottom: rect.top } : { top: rect.top }),
|
|
503
|
-
left: rect.left,
|
|
504
|
-
width: rect.width,
|
|
505
|
-
zIndex: 1000,
|
|
506
|
-
background: "var(--ck-bg-surface)",
|
|
507
|
-
border: "1px solid var(--ck-border-strong)",
|
|
508
|
-
borderRadius: "var(--ck-radius-sm)",
|
|
509
|
-
boxShadow: "var(--ck-shadow-3, 0 16px 48px rgba(0,0,0,0.12))",
|
|
510
|
-
// Popover constrained vertically by maxOptionsHeight + an
|
|
511
|
-
// allowance for the search row. The listbox itself scrolls.
|
|
512
|
-
maxHeight: maxOptionsHeight + (searchable ? 48 : 0),
|
|
513
|
-
display: "flex",
|
|
514
|
-
flexDirection: "column",
|
|
515
|
-
padding: 4,
|
|
516
|
-
}}
|
|
517
|
-
>
|
|
518
|
-
{searchable && (
|
|
519
|
-
<div
|
|
520
|
-
style={{
|
|
521
|
-
display: "flex",
|
|
522
|
-
alignItems: "center",
|
|
523
|
-
padding: "4px 6px",
|
|
524
|
-
borderBottom: "1px solid var(--ck-border-subtle)",
|
|
525
|
-
marginBottom: 4,
|
|
526
|
-
}}
|
|
527
|
-
>
|
|
528
|
-
<svg
|
|
529
|
-
width="12"
|
|
530
|
-
height="12"
|
|
531
|
-
viewBox="0 0 24 24"
|
|
532
|
-
fill="none"
|
|
533
|
-
stroke="currentColor"
|
|
534
|
-
strokeWidth="2"
|
|
535
|
-
strokeLinecap="round"
|
|
536
|
-
strokeLinejoin="round"
|
|
537
|
-
aria-hidden
|
|
538
|
-
style={{ color: "var(--ck-text-tertiary)", marginRight: 6, flexShrink: 0 }}
|
|
539
|
-
>
|
|
540
|
-
<circle cx="11" cy="11" r="7" />
|
|
541
|
-
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
542
|
-
</svg>
|
|
543
|
-
<input
|
|
544
|
-
ref={searchInputRef}
|
|
545
|
-
type="text"
|
|
546
|
-
value={query}
|
|
547
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
548
|
-
onKeyDown={onSearchKey}
|
|
549
|
-
placeholder={searchPlaceholder}
|
|
550
|
-
aria-autocomplete="list"
|
|
551
|
-
aria-controls={listboxId}
|
|
552
|
-
aria-activedescendant={
|
|
553
|
-
highlight >= 0 ? `${listboxId}-opt-${highlight}` : undefined
|
|
554
|
-
}
|
|
555
|
-
className="ck-select-search"
|
|
556
|
-
style={{
|
|
557
|
-
flex: "1 1 auto",
|
|
558
|
-
minWidth: 0,
|
|
559
|
-
height: 28,
|
|
560
|
-
padding: "0 4px",
|
|
561
|
-
border: "none",
|
|
562
|
-
outline: "none",
|
|
563
|
-
background: "transparent",
|
|
564
|
-
color: "var(--ck-text-primary)",
|
|
565
|
-
font: "400 13px/1 var(--ck-font-sans)",
|
|
566
|
-
}}
|
|
567
|
-
/>
|
|
568
|
-
</div>
|
|
569
|
-
)}
|
|
570
|
-
<ul
|
|
571
|
-
id={listboxId}
|
|
572
|
-
role="listbox"
|
|
573
|
-
aria-labelledby={triggerId}
|
|
574
|
-
style={{
|
|
575
|
-
listStyle: "none",
|
|
576
|
-
margin: 0,
|
|
577
|
-
padding: 0,
|
|
578
|
-
overflowY: "auto",
|
|
579
|
-
maxHeight: maxOptionsHeight,
|
|
580
|
-
}}
|
|
581
|
-
>
|
|
582
|
-
{filteredOptions.map((opt, i) => {
|
|
583
|
-
const selected = opt.value === value;
|
|
584
|
-
const highlighted = i === highlight;
|
|
585
|
-
return (
|
|
586
|
-
<li
|
|
587
|
-
ref={(el) => {
|
|
588
|
-
optionRefs.current[i] = el;
|
|
589
|
-
}}
|
|
590
|
-
key={opt.value}
|
|
591
|
-
id={`${listboxId}-opt-${i}`}
|
|
592
|
-
role="option"
|
|
593
|
-
aria-selected={selected}
|
|
594
|
-
aria-disabled={opt.disabled}
|
|
595
|
-
onMouseEnter={() => !opt.disabled && setHighlight(i)}
|
|
596
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
597
|
-
onClick={() => commit(i)}
|
|
598
|
-
className={cn(
|
|
599
|
-
"ck-select-option",
|
|
600
|
-
selected && "ck-select-option--selected",
|
|
601
|
-
highlighted && "ck-select-option--active",
|
|
602
|
-
opt.disabled && "ck-select-option--disabled",
|
|
603
|
-
)}
|
|
604
|
-
style={{
|
|
605
|
-
padding: "8px 10px",
|
|
606
|
-
borderRadius: "calc(var(--ck-radius-sm) - 2px)",
|
|
607
|
-
cursor: opt.disabled ? "not-allowed" : "pointer",
|
|
608
|
-
color: opt.disabled
|
|
609
|
-
? "var(--ck-text-tertiary)"
|
|
610
|
-
: "var(--ck-text-primary)",
|
|
611
|
-
background: highlighted ? "var(--ck-bg-muted)" : "transparent",
|
|
612
|
-
font: "400 13px/1.2 var(--ck-font-sans)",
|
|
613
|
-
display: "flex",
|
|
614
|
-
alignItems: "center",
|
|
615
|
-
justifyContent: "space-between",
|
|
616
|
-
gap: 8,
|
|
617
|
-
transition: "background var(--ck-dur-fast) var(--ck-ease)",
|
|
618
|
-
}}
|
|
619
|
-
>
|
|
620
|
-
<span
|
|
621
|
-
style={{
|
|
622
|
-
overflow: "hidden",
|
|
623
|
-
textOverflow: "ellipsis",
|
|
624
|
-
whiteSpace: "nowrap",
|
|
625
|
-
}}
|
|
626
|
-
>
|
|
627
|
-
{opt.label}
|
|
628
|
-
</span>
|
|
629
|
-
{selected && (
|
|
630
|
-
<svg
|
|
631
|
-
width="12"
|
|
632
|
-
height="12"
|
|
633
|
-
viewBox="0 0 24 24"
|
|
634
|
-
fill="none"
|
|
635
|
-
stroke="currentColor"
|
|
636
|
-
strokeWidth="2.4"
|
|
637
|
-
strokeLinecap="round"
|
|
638
|
-
strokeLinejoin="round"
|
|
639
|
-
aria-hidden
|
|
640
|
-
style={{ color: "var(--ck-accent)", flexShrink: 0 }}
|
|
641
|
-
>
|
|
642
|
-
<polyline points="20 6 9 17 4 12" />
|
|
643
|
-
</svg>
|
|
644
|
-
)}
|
|
645
|
-
</li>
|
|
646
|
-
);
|
|
647
|
-
})}
|
|
648
|
-
{filteredOptions.length === 0 && (
|
|
649
|
-
<li
|
|
650
|
-
role="presentation"
|
|
651
|
-
style={{
|
|
652
|
-
padding: "8px 10px",
|
|
653
|
-
color: "var(--ck-text-tertiary)",
|
|
654
|
-
font: "400 13px/1.2 var(--ck-font-sans)",
|
|
655
|
-
}}
|
|
656
|
-
>
|
|
657
|
-
{searchable && query.trim() !== "" ? "No matches" : "No options"}
|
|
658
|
-
</li>
|
|
659
|
-
)}
|
|
660
|
-
</ul>
|
|
661
|
-
</div>
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
return (
|
|
665
|
-
<div
|
|
666
|
-
className={cn("ck-select-field", className)}
|
|
667
|
-
style={{
|
|
668
|
-
display: "flex",
|
|
669
|
-
flexDirection: "column",
|
|
670
|
-
gap: 6,
|
|
671
|
-
width: fit === "full" ? "100%" : undefined,
|
|
672
|
-
}}
|
|
673
|
-
>
|
|
674
|
-
{label && (
|
|
675
|
-
<label
|
|
676
|
-
htmlFor={triggerId}
|
|
677
|
-
className="ck-eyebrow"
|
|
678
|
-
style={{ color: "var(--ck-text-secondary)" }}
|
|
679
|
-
>
|
|
680
|
-
{label}
|
|
681
|
-
</label>
|
|
682
|
-
)}
|
|
683
|
-
|
|
684
|
-
{name && <input type="hidden" name={name} value={value} required={required} />}
|
|
685
|
-
|
|
686
|
-
<button
|
|
687
|
-
ref={setTriggerRef}
|
|
688
|
-
id={triggerId}
|
|
689
|
-
type="button"
|
|
690
|
-
role="combobox"
|
|
691
|
-
aria-haspopup="listbox"
|
|
692
|
-
aria-expanded={open}
|
|
693
|
-
aria-controls={listboxId}
|
|
694
|
-
aria-invalid={error ? true : undefined}
|
|
695
|
-
aria-required={required}
|
|
696
|
-
disabled={disabled}
|
|
697
|
-
onClick={() => (open ? closePopover() : openPopover())}
|
|
698
|
-
onKeyDown={onTriggerKey}
|
|
699
|
-
className={cn(
|
|
700
|
-
"ck-select-trigger",
|
|
701
|
-
error && "ck-select-trigger--invalid",
|
|
702
|
-
disabled && "ck-select-trigger--disabled",
|
|
703
|
-
)}
|
|
704
|
-
style={{
|
|
705
|
-
...TRIGGER_BASE_STYLE,
|
|
706
|
-
border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
|
|
707
|
-
color: showPlaceholder ? "var(--ck-text-tertiary)" : "var(--ck-text-primary)",
|
|
708
|
-
opacity: disabled ? 0.55 : 1,
|
|
709
|
-
}}
|
|
710
|
-
>
|
|
711
|
-
<span style={{ display: "inline-grid", overflow: "hidden" }}>
|
|
712
|
-
{/* fit="auto" width stabiliser: every option label renders
|
|
713
|
-
invisibly in the same grid cell, so the trigger takes
|
|
714
|
-
the WIDEST option's width up front. Without it the
|
|
715
|
-
trigger resized to each selection and the popover
|
|
716
|
-
(which matches trigger width) truncated longer options
|
|
717
|
-
to "E-sig…". Skipped for fit="full" (width is the
|
|
718
|
-
container's) and for searchable lists (potentially
|
|
719
|
-
hundreds of rows — and those popovers are wide anyway). */}
|
|
720
|
-
{fit === "auto" &&
|
|
721
|
-
!searchable &&
|
|
722
|
-
options.map((o) => (
|
|
723
|
-
<span
|
|
724
|
-
key={o.value}
|
|
725
|
-
aria-hidden
|
|
726
|
-
style={{ gridArea: "1 / 1", visibility: "hidden", whiteSpace: "nowrap" }}
|
|
727
|
-
>
|
|
728
|
-
{o.label}
|
|
729
|
-
</span>
|
|
730
|
-
))}
|
|
731
|
-
<span
|
|
732
|
-
style={{
|
|
733
|
-
gridArea: "1 / 1",
|
|
734
|
-
overflow: "hidden",
|
|
735
|
-
textOverflow: "ellipsis",
|
|
736
|
-
whiteSpace: "nowrap",
|
|
737
|
-
}}
|
|
738
|
-
>
|
|
739
|
-
{triggerLabel}
|
|
740
|
-
</span>
|
|
741
|
-
</span>
|
|
742
|
-
<svg
|
|
743
|
-
width="10"
|
|
744
|
-
height="10"
|
|
745
|
-
viewBox="0 0 24 24"
|
|
746
|
-
fill="none"
|
|
747
|
-
stroke="currentColor"
|
|
748
|
-
strokeWidth="2.2"
|
|
749
|
-
strokeLinecap="round"
|
|
750
|
-
strokeLinejoin="round"
|
|
751
|
-
aria-hidden
|
|
752
|
-
style={{
|
|
753
|
-
flexShrink: 0,
|
|
754
|
-
opacity: 0.7,
|
|
755
|
-
transform: open ? "rotate(180deg)" : "rotate(0deg)",
|
|
756
|
-
transition: "transform 180ms var(--ck-ease)",
|
|
757
|
-
}}
|
|
758
|
-
>
|
|
759
|
-
<polyline points="6 9 12 15 18 9" />
|
|
760
|
-
</svg>
|
|
761
|
-
</button>
|
|
762
|
-
|
|
763
|
-
{(helper || error) && (
|
|
764
|
-
<div
|
|
765
|
-
style={{
|
|
766
|
-
font: "400 11px/1.4 var(--ck-font-sans)",
|
|
767
|
-
color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
|
|
768
|
-
}}
|
|
769
|
-
>
|
|
770
|
-
{error ?? helper}
|
|
771
|
-
</div>
|
|
772
|
-
)}
|
|
773
|
-
|
|
774
|
-
{typeof document !== "undefined" && popoverNode
|
|
775
|
-
? createPortal(popoverNode, document.body)
|
|
776
|
-
: null}
|
|
777
|
-
</div>
|
|
778
|
-
);
|
|
779
|
-
});
|