@chromvoid/headless-ui 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/a11y-contracts/index.d.ts +23 -0
- package/dist/a11y-contracts/index.js +1 -0
- package/dist/accordion/index.d.ts +78 -0
- package/dist/accordion/index.js +264 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1 -0
- package/dist/alert/index.d.ts +33 -0
- package/dist/alert/index.js +54 -0
- package/dist/alert-dialog/index.d.ts +69 -0
- package/dist/alert-dialog/index.js +94 -0
- package/dist/badge/index.d.ts +48 -0
- package/dist/badge/index.js +89 -0
- package/dist/breadcrumb/index.d.ts +55 -0
- package/dist/breadcrumb/index.js +77 -0
- package/dist/button/index.d.ts +46 -0
- package/dist/button/index.js +86 -0
- package/dist/callout/index.d.ts +41 -0
- package/dist/callout/index.js +63 -0
- package/dist/card/index.d.ts +54 -0
- package/dist/card/index.js +103 -0
- package/dist/carousel/index.d.ts +98 -0
- package/dist/carousel/index.js +243 -0
- package/dist/checkbox/index.d.ts +50 -0
- package/dist/checkbox/index.js +87 -0
- package/dist/combobox/index.d.ts +114 -0
- package/dist/combobox/index.js +431 -0
- package/dist/command-palette/index.d.ts +73 -0
- package/dist/command-palette/index.js +147 -0
- package/dist/context-menu/index.d.ts +111 -0
- package/dist/context-menu/index.js +372 -0
- package/dist/copy-button/index.d.ts +62 -0
- package/dist/copy-button/index.js +183 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +2 -0
- package/dist/core/selection.d.ts +5 -0
- package/dist/core/selection.js +39 -0
- package/dist/core/value-range.d.ts +49 -0
- package/dist/core/value-range.js +134 -0
- package/dist/date-picker/index.d.ts +210 -0
- package/dist/date-picker/index.js +895 -0
- package/dist/dialog/index.d.ts +95 -0
- package/dist/dialog/index.js +153 -0
- package/dist/disclosure/index.d.ts +52 -0
- package/dist/disclosure/index.js +159 -0
- package/dist/drawer/index.d.ts +30 -0
- package/dist/drawer/index.js +39 -0
- package/dist/feed/index.d.ts +77 -0
- package/dist/feed/index.js +260 -0
- package/dist/grid/index.d.ts +103 -0
- package/dist/grid/index.js +415 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/input/index.d.ts +86 -0
- package/dist/input/index.js +156 -0
- package/dist/interactions/composite-navigation.d.ts +69 -0
- package/dist/interactions/composite-navigation.js +169 -0
- package/dist/interactions/index.d.ts +15 -0
- package/dist/interactions/index.js +4 -0
- package/dist/interactions/keyboard-intents.d.ts +16 -0
- package/dist/interactions/keyboard-intents.js +33 -0
- package/dist/interactions/overlay-focus.d.ts +40 -0
- package/dist/interactions/overlay-focus.js +93 -0
- package/dist/interactions/typeahead.d.ts +20 -0
- package/dist/interactions/typeahead.js +41 -0
- package/dist/landmarks/index.d.ts +39 -0
- package/dist/landmarks/index.js +58 -0
- package/dist/link/index.d.ts +34 -0
- package/dist/link/index.js +39 -0
- package/dist/listbox/index.d.ts +92 -0
- package/dist/listbox/index.js +337 -0
- package/dist/menu/index.d.ts +132 -0
- package/dist/menu/index.js +541 -0
- package/dist/menu-button/index.d.ts +71 -0
- package/dist/menu-button/index.js +121 -0
- package/dist/meter/index.d.ts +45 -0
- package/dist/meter/index.js +106 -0
- package/dist/number/index.d.ts +113 -0
- package/dist/number/index.js +252 -0
- package/dist/popover/index.d.ts +70 -0
- package/dist/popover/index.js +126 -0
- package/dist/progress/index.d.ts +49 -0
- package/dist/progress/index.js +79 -0
- package/dist/radio-group/index.d.ts +61 -0
- package/dist/radio-group/index.js +150 -0
- package/dist/select/index.d.ts +92 -0
- package/dist/select/index.js +239 -0
- package/dist/sidebar/index.d.ts +74 -0
- package/dist/sidebar/index.js +186 -0
- package/dist/slider/index.d.ts +61 -0
- package/dist/slider/index.js +150 -0
- package/dist/slider-multi-thumb/index.d.ts +70 -0
- package/dist/slider-multi-thumb/index.js +222 -0
- package/dist/spinbutton/index.d.ts +75 -0
- package/dist/spinbutton/index.js +214 -0
- package/dist/spinner/index.d.ts +1 -0
- package/dist/spinner/index.js +1 -0
- package/dist/spinner/spinner.d.ts +23 -0
- package/dist/spinner/spinner.js +25 -0
- package/dist/switch/index.d.ts +40 -0
- package/dist/switch/index.js +61 -0
- package/dist/table/index.d.ts +117 -0
- package/dist/table/index.js +377 -0
- package/dist/tabs/index.d.ts +63 -0
- package/dist/tabs/index.js +174 -0
- package/dist/textarea/index.d.ts +68 -0
- package/dist/textarea/index.js +137 -0
- package/dist/toast/index.d.ts +67 -0
- package/dist/toast/index.js +145 -0
- package/dist/toolbar/index.d.ts +59 -0
- package/dist/toolbar/index.js +139 -0
- package/dist/tooltip/index.d.ts +52 -0
- package/dist/tooltip/index.js +169 -0
- package/dist/treegrid/index.d.ts +101 -0
- package/dist/treegrid/index.js +463 -0
- package/dist/treeview/index.d.ts +68 -0
- package/dist/treeview/index.js +370 -0
- package/dist/window-splitter/index.d.ts +65 -0
- package/dist/window-splitter/index.js +204 -0
- package/package.json +92 -0
- package/specs/ADR-001-headless-architecture.md +461 -0
- package/specs/ADR-002-repo-release-model.md +108 -0
- package/specs/ADR-003-public-api-versioning.md +136 -0
- package/specs/ADR-004-focus-selection-policy.md +117 -0
- package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
- package/specs/ISSUE-BACKLOG.md +681 -0
- package/specs/RELEASE-CANDIDATE.md +30 -0
- package/specs/components/accordion.md +130 -0
- package/specs/components/alert-dialog.md +72 -0
- package/specs/components/alert.md +65 -0
- package/specs/components/badge.md +220 -0
- package/specs/components/breadcrumb.md +74 -0
- package/specs/components/button.md +115 -0
- package/specs/components/callout.md +195 -0
- package/specs/components/card.md +280 -0
- package/specs/components/carousel.md +140 -0
- package/specs/components/checkbox.md +172 -0
- package/specs/components/combobox.md +423 -0
- package/specs/components/command-palette.md +92 -0
- package/specs/components/context-menu.md +556 -0
- package/specs/components/copy-button.md +293 -0
- package/specs/components/date-picker.md +400 -0
- package/specs/components/dialog.md +298 -0
- package/specs/components/disclosure.md +257 -0
- package/specs/components/drawer.md +353 -0
- package/specs/components/feed.md +265 -0
- package/specs/components/grid.md +186 -0
- package/specs/components/input.md +254 -0
- package/specs/components/landmarks.md +136 -0
- package/specs/components/link.md +134 -0
- package/specs/components/listbox.md +351 -0
- package/specs/components/menu-button.md +76 -0
- package/specs/components/menu.md +623 -0
- package/specs/components/meter.md +149 -0
- package/specs/components/number.md +393 -0
- package/specs/components/popover.md +252 -0
- package/specs/components/progress.md +188 -0
- package/specs/components/radio-group.md +151 -0
- package/specs/components/select.md +144 -0
- package/specs/components/sidebar.md +321 -0
- package/specs/components/slider-multi-thumb.md +78 -0
- package/specs/components/slider.md +84 -0
- package/specs/components/spinbutton.md +140 -0
- package/specs/components/spinner.md +132 -0
- package/specs/components/switch.md +175 -0
- package/specs/components/table.md +403 -0
- package/specs/components/tabs.md +265 -0
- package/specs/components/textarea.md +185 -0
- package/specs/components/toast.md +198 -0
- package/specs/components/toolbar.md +278 -0
- package/specs/components/tooltip.md +252 -0
- package/specs/components/treegrid.md +281 -0
- package/specs/components/treeview.md +91 -0
- package/specs/components/window-splitter.md +297 -0
- package/specs/ops/git-shard-sync.md +107 -0
- package/specs/ops/release-checklist.md +76 -0
- package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
- package/specs/release/api-freeze-candidate.md +54 -0
- package/specs/release/changelog-automation.md +76 -0
- package/specs/release/changelog.generated.md +53 -0
- package/specs/release/changelog.patch.generated.md +46 -0
- package/specs/release/consumer-integration.md +53 -0
- package/specs/release/migration-notes-pre-v1.md +40 -0
- package/specs/release/mvp-changelog.md +57 -0
- package/specs/release/release-notes-template.md +61 -0
- package/specs/release/release-rehearsal.md +113 -0
- package/specs/release/semver-deprecation-dry-run.md +89 -0
- package/specs/release/shard-release-drill-report.md +50 -0
- package/specs/release/shard-release-follow-ups.md +31 -0
- package/specs/signals.md +208 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { action, atom, computed } from '@reatom/core';
|
|
2
|
+
import { mapListboxKeyboardIntent } from '../interactions/keyboard-intents.js';
|
|
3
|
+
import { advanceTypeaheadState, createInitialTypeaheadState, findTypeaheadMatch, isTypeaheadEvent, normalizeTypeaheadText, } from '../interactions/typeahead.js';
|
|
4
|
+
const normalizeText = (value) => value.trim().toLocaleLowerCase();
|
|
5
|
+
const createDefaultFilter = (matchMode) => (option, inputValue) => {
|
|
6
|
+
const needle = normalizeText(inputValue);
|
|
7
|
+
if (needle.length === 0)
|
|
8
|
+
return true;
|
|
9
|
+
const haystack = normalizeText(option.label);
|
|
10
|
+
if (matchMode === 'startsWith') {
|
|
11
|
+
return haystack.startsWith(needle);
|
|
12
|
+
}
|
|
13
|
+
return haystack.includes(needle);
|
|
14
|
+
};
|
|
15
|
+
function isOptionGroup(item) {
|
|
16
|
+
return 'options' in item && Array.isArray(item.options);
|
|
17
|
+
}
|
|
18
|
+
function flattenOptions(items) {
|
|
19
|
+
const result = [];
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
if (isOptionGroup(item)) {
|
|
22
|
+
result.push(...item.options);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
result.push(item);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
export function createCombobox(options) {
|
|
31
|
+
const idBase = options.idBase ?? 'cb';
|
|
32
|
+
const comboboxType = options.type ?? 'editable';
|
|
33
|
+
const isMultiple = options.multiple ?? false;
|
|
34
|
+
const closeOnSelect = options.closeOnSelect ?? (isMultiple ? false : true);
|
|
35
|
+
const matchMode = options.matchMode ?? 'includes';
|
|
36
|
+
const filter = options.filter ?? createDefaultFilter(matchMode);
|
|
37
|
+
const isSelectOnly = comboboxType === 'select-only';
|
|
38
|
+
const allFlatOptions = flattenOptions(options.options);
|
|
39
|
+
const optionById = new Map(allFlatOptions.map((option) => [option.id, option]));
|
|
40
|
+
const groupById = new Map();
|
|
41
|
+
for (const item of options.options) {
|
|
42
|
+
if (isOptionGroup(item)) {
|
|
43
|
+
groupById.set(item.id, item);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const typeaheadEnabled = options.typeahead !== false &&
|
|
47
|
+
!(typeof options.typeahead === 'object' && options.typeahead.enabled === false);
|
|
48
|
+
const typeaheadTimeoutMs = typeof options.typeahead === 'object' && options.typeahead.timeoutMs != null
|
|
49
|
+
? Math.max(0, options.typeahead.timeoutMs)
|
|
50
|
+
: 500;
|
|
51
|
+
const inputValueAtom = atom(options.initialInputValue ?? '', `${idBase}.inputValue`);
|
|
52
|
+
const isOpenAtom = atom(options.initialOpen ?? false, `${idBase}.isOpen`);
|
|
53
|
+
const activeIdAtom = atom(null, `${idBase}.activeId`);
|
|
54
|
+
const initialSelectedIds = options.initialSelectedIds ?? (options.initialSelectedId ? [options.initialSelectedId] : []);
|
|
55
|
+
const selectedIdsAtom = atom(initialSelectedIds, `${idBase}.selectedIds`);
|
|
56
|
+
const selectedIdAtom = atom(initialSelectedIds.length > 0 ? initialSelectedIds[0] : null, `${idBase}.selectedId`);
|
|
57
|
+
const setSelectedIds = (ids) => {
|
|
58
|
+
selectedIdsAtom.set(ids);
|
|
59
|
+
selectedIdAtom.set(ids.length > 0 ? ids[0] : null);
|
|
60
|
+
};
|
|
61
|
+
const setSelectedId = (id) => {
|
|
62
|
+
selectedIdAtom.set(id);
|
|
63
|
+
selectedIdsAtom.set(id != null ? [id] : []);
|
|
64
|
+
};
|
|
65
|
+
const hasSelectionAtom = computed(() => selectedIdsAtom().length > 0, `${idBase}.hasSelection`);
|
|
66
|
+
const typeAtom = computed(() => comboboxType, `${idBase}.type`);
|
|
67
|
+
const multipleAtom = computed(() => isMultiple, `${idBase}.multiple`);
|
|
68
|
+
const inputId = `${idBase}-input`;
|
|
69
|
+
const listboxId = `${idBase}-listbox`;
|
|
70
|
+
const optionDomId = (id) => `${idBase}-option-${id}`;
|
|
71
|
+
const groupDomId = (groupId) => `${idBase}-group-${groupId}`;
|
|
72
|
+
const groupLabelDomId = (groupId) => `${idBase}-group-label-${groupId}`;
|
|
73
|
+
let typeaheadState = createInitialTypeaheadState();
|
|
74
|
+
const resetTypeahead = () => {
|
|
75
|
+
typeaheadState = createInitialTypeaheadState();
|
|
76
|
+
};
|
|
77
|
+
const getFlatVisibleOptions = () => {
|
|
78
|
+
if (isSelectOnly) {
|
|
79
|
+
return allFlatOptions;
|
|
80
|
+
}
|
|
81
|
+
return allFlatOptions.filter((option) => filter(option, inputValueAtom()));
|
|
82
|
+
};
|
|
83
|
+
const getVisibleOptions = () => {
|
|
84
|
+
const inputValue = inputValueAtom();
|
|
85
|
+
const hasGroups = options.options.some(isOptionGroup);
|
|
86
|
+
if (!hasGroups) {
|
|
87
|
+
if (isSelectOnly) {
|
|
88
|
+
return [...allFlatOptions];
|
|
89
|
+
}
|
|
90
|
+
return allFlatOptions.filter((option) => filter(option, inputValue));
|
|
91
|
+
}
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const item of options.options) {
|
|
94
|
+
if (isOptionGroup(item)) {
|
|
95
|
+
const filteredOptions = isSelectOnly
|
|
96
|
+
? item.options
|
|
97
|
+
: item.options.filter((opt) => filter(opt, inputValue));
|
|
98
|
+
if (filteredOptions.length > 0) {
|
|
99
|
+
result.push({ id: item.id, label: item.label, options: filteredOptions });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (isSelectOnly || filter(item, inputValue)) {
|
|
104
|
+
result.push(item);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
const getEnabledVisibleOptionIds = () => getFlatVisibleOptions()
|
|
111
|
+
.filter((option) => !option.disabled)
|
|
112
|
+
.map((option) => option.id);
|
|
113
|
+
const ensureActiveVisible = () => {
|
|
114
|
+
const enabledIds = getEnabledVisibleOptionIds();
|
|
115
|
+
if (enabledIds.length === 0) {
|
|
116
|
+
activeIdAtom.set(null);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const activeId = activeIdAtom();
|
|
120
|
+
if (activeId != null && enabledIds.includes(activeId)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
activeIdAtom.set(enabledIds[0] ?? null);
|
|
124
|
+
};
|
|
125
|
+
const setActive = (id) => {
|
|
126
|
+
if (id == null) {
|
|
127
|
+
activeIdAtom.set(null);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const option = optionById.get(id);
|
|
131
|
+
if (!option || option.disabled)
|
|
132
|
+
return;
|
|
133
|
+
const visibleIds = getFlatVisibleOptions().map((item) => item.id);
|
|
134
|
+
if (!visibleIds.includes(id))
|
|
135
|
+
return;
|
|
136
|
+
activeIdAtom.set(id);
|
|
137
|
+
};
|
|
138
|
+
const move = (direction) => {
|
|
139
|
+
const enabledIds = getEnabledVisibleOptionIds();
|
|
140
|
+
if (enabledIds.length === 0) {
|
|
141
|
+
activeIdAtom.set(null);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const activeId = activeIdAtom();
|
|
145
|
+
if (activeId == null || !enabledIds.includes(activeId)) {
|
|
146
|
+
activeIdAtom.set(enabledIds[0] ?? null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const currentIndex = enabledIds.indexOf(activeId);
|
|
150
|
+
if (currentIndex < 0) {
|
|
151
|
+
activeIdAtom.set(enabledIds[0] ?? null);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const nextIndex = (currentIndex + direction + enabledIds.length) % enabledIds.length;
|
|
155
|
+
activeIdAtom.set(enabledIds[nextIndex] ?? null);
|
|
156
|
+
};
|
|
157
|
+
const toggleOptionInternal = (id) => {
|
|
158
|
+
if (!isMultiple)
|
|
159
|
+
return;
|
|
160
|
+
const option = optionById.get(id);
|
|
161
|
+
if (!option || option.disabled)
|
|
162
|
+
return;
|
|
163
|
+
const current = selectedIdsAtom();
|
|
164
|
+
if (current.includes(id)) {
|
|
165
|
+
setSelectedIds(current.filter((sid) => sid !== id));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
setSelectedIds([...current, id]);
|
|
169
|
+
}
|
|
170
|
+
activeIdAtom.set(id);
|
|
171
|
+
};
|
|
172
|
+
const commitById = (id) => {
|
|
173
|
+
const option = optionById.get(id);
|
|
174
|
+
if (!option || option.disabled)
|
|
175
|
+
return;
|
|
176
|
+
if (isMultiple) {
|
|
177
|
+
toggleOptionInternal(id);
|
|
178
|
+
if (isSelectOnly) {
|
|
179
|
+
inputValueAtom.set('');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
setSelectedId(id);
|
|
184
|
+
inputValueAtom.set(option.label);
|
|
185
|
+
activeIdAtom.set(id);
|
|
186
|
+
}
|
|
187
|
+
if (closeOnSelect) {
|
|
188
|
+
isOpenAtom.set(false);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const handleTypeahead = (event) => {
|
|
192
|
+
const effectiveTypeaheadEnabled = isSelectOnly || typeaheadEnabled;
|
|
193
|
+
if (!effectiveTypeaheadEnabled || !isTypeaheadEvent(event))
|
|
194
|
+
return false;
|
|
195
|
+
const enabledVisibleOptions = getFlatVisibleOptions().filter((option) => !option.disabled);
|
|
196
|
+
if (enabledVisibleOptions.length === 0)
|
|
197
|
+
return true;
|
|
198
|
+
const typeaheadItems = enabledVisibleOptions.map((option) => ({
|
|
199
|
+
id: option.id,
|
|
200
|
+
text: normalizeTypeaheadText(option.label),
|
|
201
|
+
}));
|
|
202
|
+
const activeEnabledIndex = activeIdAtom() == null ? -1 : enabledVisibleOptions.findIndex((option) => option.id === activeIdAtom());
|
|
203
|
+
const startIndex = activeEnabledIndex < 0 ? 0 : (activeEnabledIndex + 1) % enabledVisibleOptions.length;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const { query, next } = advanceTypeaheadState(typeaheadState, normalizeTypeaheadText(event.key), now, typeaheadTimeoutMs);
|
|
206
|
+
const matchedId = findTypeaheadMatch(query, typeaheadItems, startIndex);
|
|
207
|
+
if (matchedId != null) {
|
|
208
|
+
setActive(matchedId);
|
|
209
|
+
}
|
|
210
|
+
typeaheadState = next;
|
|
211
|
+
return true;
|
|
212
|
+
};
|
|
213
|
+
const open = action(() => {
|
|
214
|
+
isOpenAtom.set(true);
|
|
215
|
+
resetTypeahead();
|
|
216
|
+
ensureActiveVisible();
|
|
217
|
+
}, `${idBase}.open`);
|
|
218
|
+
const close = action(() => {
|
|
219
|
+
isOpenAtom.set(false);
|
|
220
|
+
resetTypeahead();
|
|
221
|
+
}, `${idBase}.close`);
|
|
222
|
+
const setInputValue = action((value) => {
|
|
223
|
+
if (isSelectOnly)
|
|
224
|
+
return;
|
|
225
|
+
inputValueAtom.set(value);
|
|
226
|
+
isOpenAtom.set(true);
|
|
227
|
+
resetTypeahead();
|
|
228
|
+
ensureActiveVisible();
|
|
229
|
+
}, `${idBase}.setInputValue`);
|
|
230
|
+
const setActiveAction = action((id) => {
|
|
231
|
+
setActive(id);
|
|
232
|
+
}, `${idBase}.setActive`);
|
|
233
|
+
const moveNext = action(() => {
|
|
234
|
+
isOpenAtom.set(true);
|
|
235
|
+
move(1);
|
|
236
|
+
}, `${idBase}.moveNext`);
|
|
237
|
+
const movePrev = action(() => {
|
|
238
|
+
isOpenAtom.set(true);
|
|
239
|
+
move(-1);
|
|
240
|
+
}, `${idBase}.movePrev`);
|
|
241
|
+
const moveFirst = action(() => {
|
|
242
|
+
isOpenAtom.set(true);
|
|
243
|
+
const first = getEnabledVisibleOptionIds()[0];
|
|
244
|
+
activeIdAtom.set(first ?? null);
|
|
245
|
+
}, `${idBase}.moveFirst`);
|
|
246
|
+
const moveLast = action(() => {
|
|
247
|
+
isOpenAtom.set(true);
|
|
248
|
+
const enabledIds = getEnabledVisibleOptionIds();
|
|
249
|
+
activeIdAtom.set(enabledIds[enabledIds.length - 1] ?? null);
|
|
250
|
+
}, `${idBase}.moveLast`);
|
|
251
|
+
const commitActive = action(() => {
|
|
252
|
+
const activeId = activeIdAtom();
|
|
253
|
+
if (activeId == null)
|
|
254
|
+
return;
|
|
255
|
+
commitById(activeId);
|
|
256
|
+
}, `${idBase}.commitActive`);
|
|
257
|
+
const selectAction = action((id) => {
|
|
258
|
+
commitById(id);
|
|
259
|
+
}, `${idBase}.select`);
|
|
260
|
+
const toggleOption = action((id) => {
|
|
261
|
+
toggleOptionInternal(id);
|
|
262
|
+
}, `${idBase}.toggleOption`);
|
|
263
|
+
const removeSelected = action((id) => {
|
|
264
|
+
const current = selectedIdsAtom();
|
|
265
|
+
if (!current.includes(id))
|
|
266
|
+
return;
|
|
267
|
+
setSelectedIds(current.filter((sid) => sid !== id));
|
|
268
|
+
}, `${idBase}.removeSelected`);
|
|
269
|
+
const clearSelection = action(() => {
|
|
270
|
+
setSelectedIds([]);
|
|
271
|
+
}, `${idBase}.clearSelection`);
|
|
272
|
+
const clearAction = action(() => {
|
|
273
|
+
setSelectedIds([]);
|
|
274
|
+
inputValueAtom.set('');
|
|
275
|
+
}, `${idBase}.clear`);
|
|
276
|
+
const handleKeyDown = action((event) => {
|
|
277
|
+
if (isSelectOnly && (event.key === ' ' || event.key === 'Spacebar')) {
|
|
278
|
+
if (!isOpenAtom()) {
|
|
279
|
+
open();
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
commitActive();
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (isSelectOnly && event.key === 'Enter' && !isOpenAtom()) {
|
|
287
|
+
open();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (isSelectOnly && handleTypeahead(event)) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (!isSelectOnly && handleTypeahead(event)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
resetTypeahead();
|
|
297
|
+
const intent = mapListboxKeyboardIntent(event, {
|
|
298
|
+
orientation: 'vertical',
|
|
299
|
+
selectionMode: 'single',
|
|
300
|
+
rangeSelectionEnabled: false,
|
|
301
|
+
});
|
|
302
|
+
if (intent == null)
|
|
303
|
+
return;
|
|
304
|
+
switch (intent) {
|
|
305
|
+
case 'NAV_NEXT':
|
|
306
|
+
moveNext();
|
|
307
|
+
return;
|
|
308
|
+
case 'NAV_PREV':
|
|
309
|
+
movePrev();
|
|
310
|
+
return;
|
|
311
|
+
case 'NAV_FIRST':
|
|
312
|
+
moveFirst();
|
|
313
|
+
return;
|
|
314
|
+
case 'NAV_LAST':
|
|
315
|
+
moveLast();
|
|
316
|
+
return;
|
|
317
|
+
case 'ACTIVATE':
|
|
318
|
+
commitActive();
|
|
319
|
+
return;
|
|
320
|
+
case 'DISMISS':
|
|
321
|
+
close();
|
|
322
|
+
return;
|
|
323
|
+
default:
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}, `${idBase}.handleKeyDown`);
|
|
327
|
+
const actions = {
|
|
328
|
+
open,
|
|
329
|
+
close,
|
|
330
|
+
setInputValue,
|
|
331
|
+
setActive: setActiveAction,
|
|
332
|
+
moveNext,
|
|
333
|
+
movePrev,
|
|
334
|
+
moveFirst,
|
|
335
|
+
moveLast,
|
|
336
|
+
commitActive,
|
|
337
|
+
select: selectAction,
|
|
338
|
+
toggleOption,
|
|
339
|
+
removeSelected,
|
|
340
|
+
clearSelection,
|
|
341
|
+
clear: clearAction,
|
|
342
|
+
handleKeyDown,
|
|
343
|
+
};
|
|
344
|
+
const initialId = selectedIdAtom();
|
|
345
|
+
if (initialId != null && !isMultiple) {
|
|
346
|
+
const selected = optionById.get(initialId);
|
|
347
|
+
if (selected != null) {
|
|
348
|
+
inputValueAtom.set(selected.label);
|
|
349
|
+
activeIdAtom.set(selected.id);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
setSelectedId(null);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const contracts = {
|
|
356
|
+
getVisibleOptions,
|
|
357
|
+
getFlatVisibleOptions,
|
|
358
|
+
getInputProps() {
|
|
359
|
+
const activeId = activeIdAtom();
|
|
360
|
+
const props = {
|
|
361
|
+
id: inputId,
|
|
362
|
+
role: 'combobox',
|
|
363
|
+
tabindex: '0',
|
|
364
|
+
'aria-haspopup': 'listbox',
|
|
365
|
+
'aria-expanded': isOpenAtom() ? 'true' : 'false',
|
|
366
|
+
'aria-controls': listboxId,
|
|
367
|
+
'aria-activedescendant': isOpenAtom() && activeId != null ? optionDomId(activeId) : undefined,
|
|
368
|
+
'aria-label': options.ariaLabel,
|
|
369
|
+
};
|
|
370
|
+
if (!isSelectOnly) {
|
|
371
|
+
props['aria-autocomplete'] = 'list';
|
|
372
|
+
}
|
|
373
|
+
return props;
|
|
374
|
+
},
|
|
375
|
+
getListboxProps() {
|
|
376
|
+
const props = {
|
|
377
|
+
id: listboxId,
|
|
378
|
+
role: 'listbox',
|
|
379
|
+
tabindex: '-1',
|
|
380
|
+
'aria-label': options.ariaLabel,
|
|
381
|
+
};
|
|
382
|
+
if (isMultiple) {
|
|
383
|
+
props['aria-multiselectable'] = 'true';
|
|
384
|
+
}
|
|
385
|
+
return props;
|
|
386
|
+
},
|
|
387
|
+
getOptionProps(id) {
|
|
388
|
+
const option = optionById.get(id);
|
|
389
|
+
if (!option) {
|
|
390
|
+
throw new Error(`Unknown combobox option id: ${id}`);
|
|
391
|
+
}
|
|
392
|
+
const currentSelectedIds = selectedIdsAtom();
|
|
393
|
+
return {
|
|
394
|
+
id: optionDomId(id),
|
|
395
|
+
role: 'option',
|
|
396
|
+
tabindex: '-1',
|
|
397
|
+
'aria-selected': currentSelectedIds.includes(id) ? 'true' : 'false',
|
|
398
|
+
'aria-disabled': option.disabled ? 'true' : undefined,
|
|
399
|
+
'data-active': activeIdAtom() === id ? 'true' : 'false',
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
getGroupProps(groupId) {
|
|
403
|
+
return {
|
|
404
|
+
id: groupDomId(groupId),
|
|
405
|
+
role: 'group',
|
|
406
|
+
'aria-labelledby': groupLabelDomId(groupId),
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
getGroupLabelProps(groupId) {
|
|
410
|
+
return {
|
|
411
|
+
id: groupLabelDomId(groupId),
|
|
412
|
+
role: 'presentation',
|
|
413
|
+
};
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const state = {
|
|
417
|
+
inputValue: inputValueAtom,
|
|
418
|
+
isOpen: isOpenAtom,
|
|
419
|
+
activeId: activeIdAtom,
|
|
420
|
+
selectedId: selectedIdAtom,
|
|
421
|
+
selectedIds: selectedIdsAtom,
|
|
422
|
+
hasSelection: hasSelectionAtom,
|
|
423
|
+
type: typeAtom,
|
|
424
|
+
multiple: multipleAtom,
|
|
425
|
+
};
|
|
426
|
+
return {
|
|
427
|
+
state,
|
|
428
|
+
actions,
|
|
429
|
+
contracts,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type Atom } from '@reatom/core';
|
|
2
|
+
import { type ComboboxInputProps, type ComboboxListboxProps, type ComboboxOption, type ComboboxOptionProps } from '../combobox/index.js';
|
|
3
|
+
export interface CommandPaletteKeyboardEventLike {
|
|
4
|
+
key: string;
|
|
5
|
+
shiftKey?: boolean;
|
|
6
|
+
ctrlKey?: boolean;
|
|
7
|
+
metaKey?: boolean;
|
|
8
|
+
altKey?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface CreateCommandPaletteOptions {
|
|
11
|
+
commands: readonly ComboboxOption[];
|
|
12
|
+
idBase?: string;
|
|
13
|
+
ariaLabel?: string;
|
|
14
|
+
initialOpen?: boolean;
|
|
15
|
+
openShortcutKey?: string;
|
|
16
|
+
closeOnExecute?: boolean;
|
|
17
|
+
closeOnOutsidePointer?: boolean;
|
|
18
|
+
onExecute?: (commandId: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface CommandPaletteState {
|
|
21
|
+
isOpen: Atom<boolean>;
|
|
22
|
+
inputValue: Atom<string>;
|
|
23
|
+
activeId: Atom<string | null>;
|
|
24
|
+
selectedId: Atom<string | null>;
|
|
25
|
+
lastExecutedId: Atom<string | null>;
|
|
26
|
+
restoreTargetId: Atom<string | null>;
|
|
27
|
+
}
|
|
28
|
+
export interface CommandPaletteActions {
|
|
29
|
+
open(): void;
|
|
30
|
+
close(): void;
|
|
31
|
+
toggle(): void;
|
|
32
|
+
execute(id: string): void;
|
|
33
|
+
setInputValue(value: string): void;
|
|
34
|
+
handleGlobalKeyDown(event: CommandPaletteKeyboardEventLike): void;
|
|
35
|
+
handlePaletteKeyDown(event: CommandPaletteKeyboardEventLike): void;
|
|
36
|
+
handleOutsidePointer(): void;
|
|
37
|
+
}
|
|
38
|
+
export interface CommandPaletteTriggerProps {
|
|
39
|
+
id: string;
|
|
40
|
+
role: 'button';
|
|
41
|
+
tabindex: '0';
|
|
42
|
+
'aria-haspopup': 'dialog';
|
|
43
|
+
'aria-expanded': 'true' | 'false';
|
|
44
|
+
'aria-controls': string;
|
|
45
|
+
onClick: () => void;
|
|
46
|
+
}
|
|
47
|
+
export interface CommandPaletteDialogProps {
|
|
48
|
+
id: string;
|
|
49
|
+
role: 'dialog';
|
|
50
|
+
tabindex: '-1';
|
|
51
|
+
hidden: boolean;
|
|
52
|
+
'aria-modal': 'true';
|
|
53
|
+
'aria-label'?: string;
|
|
54
|
+
onKeyDown: (event: CommandPaletteKeyboardEventLike) => void;
|
|
55
|
+
onPointerDownOutside: () => void;
|
|
56
|
+
}
|
|
57
|
+
export interface CommandPaletteOptionProps extends ComboboxOptionProps {
|
|
58
|
+
onClick: () => void;
|
|
59
|
+
}
|
|
60
|
+
export interface CommandPaletteContracts {
|
|
61
|
+
getTriggerProps(): CommandPaletteTriggerProps;
|
|
62
|
+
getDialogProps(): CommandPaletteDialogProps;
|
|
63
|
+
getInputProps(): ComboboxInputProps;
|
|
64
|
+
getListboxProps(): ComboboxListboxProps;
|
|
65
|
+
getOptionProps(id: string): CommandPaletteOptionProps;
|
|
66
|
+
getVisibleCommands(): readonly ComboboxOption[];
|
|
67
|
+
}
|
|
68
|
+
export interface CommandPaletteModel {
|
|
69
|
+
readonly state: CommandPaletteState;
|
|
70
|
+
readonly actions: CommandPaletteActions;
|
|
71
|
+
readonly contracts: CommandPaletteContracts;
|
|
72
|
+
}
|
|
73
|
+
export declare function createCommandPalette(options: CreateCommandPaletteOptions): CommandPaletteModel;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { action, atom } from '@reatom/core';
|
|
2
|
+
import { createCombobox, } from '../combobox/index.js';
|
|
3
|
+
export function createCommandPalette(options) {
|
|
4
|
+
const idBase = options.idBase ?? 'command-palette';
|
|
5
|
+
const openShortcutKey = options.openShortcutKey ?? 'k';
|
|
6
|
+
const closeOnExecute = options.closeOnExecute ?? true;
|
|
7
|
+
const commandIds = new Set(options.commands.map((command) => command.id));
|
|
8
|
+
const lastExecutedIdAtom = atom(null, `${idBase}.lastExecutedId`);
|
|
9
|
+
const restoreTargetIdAtom = atom(null, `${idBase}.restoreTargetId`);
|
|
10
|
+
const combobox = createCombobox({
|
|
11
|
+
idBase: `${idBase}.combobox`,
|
|
12
|
+
options: options.commands,
|
|
13
|
+
ariaLabel: options.ariaLabel,
|
|
14
|
+
initialOpen: options.initialOpen ?? false,
|
|
15
|
+
});
|
|
16
|
+
const open = action(() => {
|
|
17
|
+
combobox.actions.open();
|
|
18
|
+
restoreTargetIdAtom.set(null);
|
|
19
|
+
}, `${idBase}.open`);
|
|
20
|
+
const close = action(() => {
|
|
21
|
+
combobox.actions.close();
|
|
22
|
+
restoreTargetIdAtom.set(`${idBase}-trigger`);
|
|
23
|
+
}, `${idBase}.close`);
|
|
24
|
+
const toggle = action(() => {
|
|
25
|
+
if (combobox.state.isOpen()) {
|
|
26
|
+
close();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
open();
|
|
30
|
+
}, `${idBase}.toggle`);
|
|
31
|
+
const execute = action((id) => {
|
|
32
|
+
if (!commandIds.has(id))
|
|
33
|
+
return;
|
|
34
|
+
combobox.state.selectedId.set(id);
|
|
35
|
+
combobox.state.activeId.set(id);
|
|
36
|
+
lastExecutedIdAtom.set(id);
|
|
37
|
+
options.onExecute?.(id);
|
|
38
|
+
if (closeOnExecute) {
|
|
39
|
+
close();
|
|
40
|
+
}
|
|
41
|
+
}, `${idBase}.execute`);
|
|
42
|
+
const setInputValue = action((value) => {
|
|
43
|
+
combobox.actions.setInputValue(value);
|
|
44
|
+
}, `${idBase}.setInputValue`);
|
|
45
|
+
const handleGlobalKeyDown = action((event) => {
|
|
46
|
+
const isShortcut = (event.ctrlKey === true || event.metaKey === true) && event.key.toLowerCase() === openShortcutKey;
|
|
47
|
+
if (isShortcut) {
|
|
48
|
+
toggle();
|
|
49
|
+
}
|
|
50
|
+
}, `${idBase}.handleGlobalKeyDown`);
|
|
51
|
+
const handlePaletteKeyDown = action((event) => {
|
|
52
|
+
if (!combobox.state.isOpen())
|
|
53
|
+
return;
|
|
54
|
+
if (event.key === 'Escape') {
|
|
55
|
+
close();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
59
|
+
const activeId = combobox.state.activeId() ??
|
|
60
|
+
combobox.contracts.getFlatVisibleOptions().find((option) => !option.disabled)?.id ??
|
|
61
|
+
null;
|
|
62
|
+
if (activeId != null) {
|
|
63
|
+
execute(activeId);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
combobox.actions.handleKeyDown({
|
|
68
|
+
key: event.key,
|
|
69
|
+
shiftKey: event.shiftKey ?? false,
|
|
70
|
+
ctrlKey: event.ctrlKey ?? false,
|
|
71
|
+
metaKey: event.metaKey ?? false,
|
|
72
|
+
altKey: event.altKey ?? false,
|
|
73
|
+
});
|
|
74
|
+
}, `${idBase}.handlePaletteKeyDown`);
|
|
75
|
+
const handleOutsidePointer = action(() => {
|
|
76
|
+
if (options.closeOnOutsidePointer === false)
|
|
77
|
+
return;
|
|
78
|
+
close();
|
|
79
|
+
}, `${idBase}.handleOutsidePointer`);
|
|
80
|
+
const actions = {
|
|
81
|
+
open,
|
|
82
|
+
close,
|
|
83
|
+
toggle,
|
|
84
|
+
execute,
|
|
85
|
+
setInputValue,
|
|
86
|
+
handleGlobalKeyDown,
|
|
87
|
+
handlePaletteKeyDown,
|
|
88
|
+
handleOutsidePointer,
|
|
89
|
+
};
|
|
90
|
+
const contracts = {
|
|
91
|
+
getTriggerProps() {
|
|
92
|
+
return {
|
|
93
|
+
id: `${idBase}-trigger`,
|
|
94
|
+
role: 'button',
|
|
95
|
+
tabindex: '0',
|
|
96
|
+
'aria-haspopup': 'dialog',
|
|
97
|
+
'aria-expanded': combobox.state.isOpen() ? 'true' : 'false',
|
|
98
|
+
'aria-controls': `${idBase}-dialog`,
|
|
99
|
+
onClick: toggle,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
getDialogProps() {
|
|
103
|
+
return {
|
|
104
|
+
id: `${idBase}-dialog`,
|
|
105
|
+
role: 'dialog',
|
|
106
|
+
tabindex: '-1',
|
|
107
|
+
hidden: !combobox.state.isOpen(),
|
|
108
|
+
'aria-modal': 'true',
|
|
109
|
+
'aria-label': options.ariaLabel,
|
|
110
|
+
onKeyDown: handlePaletteKeyDown,
|
|
111
|
+
onPointerDownOutside: handleOutsidePointer,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
getInputProps() {
|
|
115
|
+
return combobox.contracts.getInputProps();
|
|
116
|
+
},
|
|
117
|
+
getListboxProps() {
|
|
118
|
+
return combobox.contracts.getListboxProps();
|
|
119
|
+
},
|
|
120
|
+
getOptionProps(id) {
|
|
121
|
+
const optionProps = combobox.contracts.getOptionProps(id);
|
|
122
|
+
return {
|
|
123
|
+
...optionProps,
|
|
124
|
+
onClick: () => execute(id),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
getVisibleCommands() {
|
|
128
|
+
return combobox.contracts.getVisibleOptions();
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const state = {
|
|
132
|
+
isOpen: combobox.state.isOpen,
|
|
133
|
+
inputValue: combobox.state.inputValue,
|
|
134
|
+
activeId: combobox.state.activeId,
|
|
135
|
+
selectedId: combobox.state.selectedId,
|
|
136
|
+
lastExecutedId: lastExecutedIdAtom,
|
|
137
|
+
restoreTargetId: restoreTargetIdAtom,
|
|
138
|
+
};
|
|
139
|
+
if (options.initialOpen) {
|
|
140
|
+
open();
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
state,
|
|
144
|
+
actions,
|
|
145
|
+
contracts,
|
|
146
|
+
};
|
|
147
|
+
}
|