@visitwonders/assembly 0.10.0 → 0.11.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/README.md +83 -0
- package/declarations/form/combobox-field.d.ts +71 -0
- package/declarations/form/combobox-field.d.ts.map +1 -0
- package/declarations/form/combobox-shared.d.ts +36 -0
- package/declarations/form/combobox-shared.d.ts.map +1 -0
- package/declarations/form/combobox.d.ts +239 -0
- package/declarations/form/combobox.d.ts.map +1 -0
- package/declarations/form/index.d.ts +4 -0
- package/declarations/form/index.d.ts.map +1 -1
- package/declarations/form/multi-combobox-field.d.ts +72 -0
- package/declarations/form/multi-combobox-field.d.ts.map +1 -0
- package/declarations/form/multi-combobox.d.ts +202 -0
- package/declarations/form/multi-combobox.d.ts.map +1 -0
- package/declarations/layout/h-stack.d.ts.map +1 -1
- package/declarations/layout/stack.d.ts.map +1 -1
- package/declarations/layout/v-stack.d.ts.map +1 -1
- package/declarations/overlay/popover.d.ts +20 -1
- package/declarations/overlay/popover.d.ts.map +1 -1
- package/dist/_app_/form/combobox-field.js +1 -0
- package/dist/_app_/form/combobox-shared.js +1 -0
- package/dist/_app_/form/combobox.js +1 -0
- package/dist/_app_/form/multi-combobox-field.js +1 -0
- package/dist/_app_/form/multi-combobox.js +1 -0
- package/dist/data/{sortable-list-css-211fcfeedc08052ccbac7f51549ce0b1.css → sortable-list-css-03e5d237ea377f7d6056e76cc85b2aaa.css} +8 -4
- package/dist/data/sortable-list.js +1 -1
- package/dist/form/combobox-field.js +37 -0
- package/dist/form/combobox-field.js.map +1 -0
- package/dist/form/combobox-shared.js +76 -0
- package/dist/form/combobox-shared.js.map +1 -0
- package/dist/form/combobox.css +345 -0
- package/dist/form/combobox.js +612 -0
- package/dist/form/combobox.js.map +1 -0
- package/dist/form/{display-field-css-890d9be4b5da61613fd017071f330735.css → display-field-css-502236a2343d47e31e52bdb93a769ca1.css} +2 -2
- package/dist/form/display-field.js +1 -1
- package/dist/form/index.js +4 -0
- package/dist/form/index.js.map +1 -1
- package/dist/form/multi-combobox-field.js +36 -0
- package/dist/form/multi-combobox-field.js.map +1 -0
- package/dist/form/multi-combobox.css +422 -0
- package/dist/form/multi-combobox.js +626 -0
- package/dist/form/multi-combobox.js.map +1 -0
- package/dist/layout/h-stack.js.map +1 -1
- package/dist/layout/panel.css +10 -0
- package/dist/layout/v-stack.js.map +1 -1
- package/dist/overlay/popover.js +19 -1
- package/package.json +6 -1
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import "./multi-combobox.css"
|
|
2
|
+
import Component from '@glimmer/component';
|
|
3
|
+
import { tracked, cached } from '@glimmer/tracking';
|
|
4
|
+
import { on } from '@ember/modifier';
|
|
5
|
+
import { fn } from '@ember/helper';
|
|
6
|
+
import { modifier } from 'ember-modifier';
|
|
7
|
+
import { uniqueId } from 'ember-primitives/utils';
|
|
8
|
+
import Popover from '../overlay/popover.js';
|
|
9
|
+
import Icon from '../media/icon.js';
|
|
10
|
+
import { ChevronDown, X } from 'lucide';
|
|
11
|
+
import { isOptionGroup, flattenOptions, splitOnMatch } from './combobox-shared.js';
|
|
12
|
+
import { precompileTemplate } from '@ember/template-compilation';
|
|
13
|
+
import { setComponentTemplate } from '@ember/component';
|
|
14
|
+
import { g, i, n } from 'decorator-transforms/runtime';
|
|
15
|
+
|
|
16
|
+
;
|
|
17
|
+
|
|
18
|
+
/** Rows skipped per PageUp / PageDown keypress. */const PAGE_STEP = 10;
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Derived types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Main Component
|
|
29
|
+
// ============================================================================
|
|
30
|
+
class MultiComboBox extends Component {
|
|
31
|
+
static {
|
|
32
|
+
g(this.prototype, "isOpen", [tracked], function () {
|
|
33
|
+
return false;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
#isOpen = (i(this, "isOpen"), void 0);
|
|
37
|
+
static {
|
|
38
|
+
g(this.prototype, "query", [tracked], function () {
|
|
39
|
+
return '';
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
#query = (i(this, "query"), void 0);
|
|
43
|
+
static {
|
|
44
|
+
g(this.prototype, "activeId", [tracked], function () {
|
|
45
|
+
return null;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
#activeId = (i(this, "activeId"), void 0);
|
|
49
|
+
static {
|
|
50
|
+
g(this.prototype, "remoteItems", [tracked], function () {
|
|
51
|
+
return null;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
#remoteItems = (i(this, "remoteItems"), void 0);
|
|
55
|
+
static {
|
|
56
|
+
g(this.prototype, "isLoadingInternal", [tracked], function () {
|
|
57
|
+
return false;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
#isLoadingInternal = (i(this, "isLoadingInternal"), void 0);
|
|
61
|
+
searchSeq = 0;
|
|
62
|
+
searchTimer = null;
|
|
63
|
+
inputElement = null;
|
|
64
|
+
listboxElement = null;
|
|
65
|
+
componentId = `multi-combobox-${uniqueId()}`;
|
|
66
|
+
listboxId = `${this.componentId}-listbox`;
|
|
67
|
+
createOptionId = `${this.componentId}-create`;
|
|
68
|
+
willDestroy() {
|
|
69
|
+
super.willDestroy();
|
|
70
|
+
if (this.searchTimer) clearTimeout(this.searchTimer);
|
|
71
|
+
}
|
|
72
|
+
// --------------------------------------------------------------------------
|
|
73
|
+
// Items + filtering
|
|
74
|
+
// --------------------------------------------------------------------------
|
|
75
|
+
get isAsync() {
|
|
76
|
+
return this.args.onSearch !== undefined;
|
|
77
|
+
}
|
|
78
|
+
get effectiveItems() {
|
|
79
|
+
if (this.isAsync && this.remoteItems !== null) {
|
|
80
|
+
return this.remoteItems;
|
|
81
|
+
}
|
|
82
|
+
return this.args.items ?? [];
|
|
83
|
+
}
|
|
84
|
+
get values() {
|
|
85
|
+
return this.args.values ?? [];
|
|
86
|
+
}
|
|
87
|
+
get valueSet() {
|
|
88
|
+
return new Set(this.values);
|
|
89
|
+
}
|
|
90
|
+
static {
|
|
91
|
+
n(this.prototype, "valueSet", [cached]);
|
|
92
|
+
}
|
|
93
|
+
get normalizedGroups() {
|
|
94
|
+
const items = this.effectiveItems;
|
|
95
|
+
if (items.length === 0) return [];
|
|
96
|
+
if (isOptionGroup(items[0])) {
|
|
97
|
+
return items.map((g, i) => ({
|
|
98
|
+
label: g.label,
|
|
99
|
+
headingId: `${this.componentId}-group-${i}`,
|
|
100
|
+
options: g.options
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
return [{
|
|
104
|
+
label: null,
|
|
105
|
+
headingId: null,
|
|
106
|
+
options: items
|
|
107
|
+
}];
|
|
108
|
+
}
|
|
109
|
+
get renderedGroups() {
|
|
110
|
+
const trimmed = this.query.trim().toLowerCase();
|
|
111
|
+
const skipLocalFilter = this.isAsync || !trimmed;
|
|
112
|
+
const selected = this.valueSet;
|
|
113
|
+
const result = [];
|
|
114
|
+
for (const group of this.normalizedGroups) {
|
|
115
|
+
const matched = skipLocalFilter ? group.options : group.options.filter(option => {
|
|
116
|
+
const inLabel = option.label.toLowerCase().includes(trimmed);
|
|
117
|
+
const inDescription = option.description ? option.description.toLowerCase().includes(trimmed) : false;
|
|
118
|
+
return inLabel || inDescription;
|
|
119
|
+
});
|
|
120
|
+
if (matched.length === 0) continue;
|
|
121
|
+
const rows = matched.map(option => {
|
|
122
|
+
const optionId = `${this.componentId}-option-${option.value}`;
|
|
123
|
+
return {
|
|
124
|
+
kind: 'option',
|
|
125
|
+
option,
|
|
126
|
+
optionId,
|
|
127
|
+
isActive: this.activeId === optionId,
|
|
128
|
+
isSelected: selected.has(option.value)
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
result.push({
|
|
132
|
+
key: group.headingId ?? 'ungrouped',
|
|
133
|
+
label: group.label,
|
|
134
|
+
headingId: group.headingId,
|
|
135
|
+
rows
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
static {
|
|
141
|
+
n(this.prototype, "renderedGroups", [cached]);
|
|
142
|
+
}
|
|
143
|
+
get navigableRows() {
|
|
144
|
+
const rows = [];
|
|
145
|
+
for (const group of this.renderedGroups) {
|
|
146
|
+
for (const row of group.rows) {
|
|
147
|
+
if (!row.option.isDisabled) rows.push(row);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const create = this.createRow;
|
|
151
|
+
if (create) rows.push(create);
|
|
152
|
+
return rows;
|
|
153
|
+
}
|
|
154
|
+
static {
|
|
155
|
+
n(this.prototype, "navigableRows", [cached]);
|
|
156
|
+
}
|
|
157
|
+
get hasOptionResults() {
|
|
158
|
+
return this.renderedGroups.some(g => g.rows.length > 0);
|
|
159
|
+
}
|
|
160
|
+
// --------------------------------------------------------------------------
|
|
161
|
+
// Chips (from the selected values)
|
|
162
|
+
// --------------------------------------------------------------------------
|
|
163
|
+
get maxVisibleChips() {
|
|
164
|
+
return this.args.maxVisibleChips ?? 3;
|
|
165
|
+
}
|
|
166
|
+
get selectedOptions() {
|
|
167
|
+
// Resolve `values` to their option objects by looking across all
|
|
168
|
+
// items (incl. async payload) AND the current render. If an option
|
|
169
|
+
// isn't found in items, fabricate a minimal one so the chip still
|
|
170
|
+
// shows — common with async-backed data where items lag.
|
|
171
|
+
const flat = flattenOptions(this.effectiveItems);
|
|
172
|
+
const byValue = new Map(flat.map(o => [o.value, o]));
|
|
173
|
+
return this.values.map(v => byValue.get(v) ?? {
|
|
174
|
+
value: v,
|
|
175
|
+
label: v
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
get chips() {
|
|
179
|
+
return this.selectedOptions.map(option => ({
|
|
180
|
+
value: option.value,
|
|
181
|
+
label: option.label,
|
|
182
|
+
option
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
get visibleChips() {
|
|
186
|
+
return this.chips.slice(0, this.maxVisibleChips);
|
|
187
|
+
}
|
|
188
|
+
get overflowCount() {
|
|
189
|
+
return Math.max(0, this.chips.length - this.maxVisibleChips);
|
|
190
|
+
}
|
|
191
|
+
get hasOverflow() {
|
|
192
|
+
return this.overflowCount > 0;
|
|
193
|
+
}
|
|
194
|
+
// --------------------------------------------------------------------------
|
|
195
|
+
// Create row
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
get isCreateVisible() {
|
|
198
|
+
if (!this.args.isCreatable) return false;
|
|
199
|
+
const trimmed = this.query.trim();
|
|
200
|
+
if (!trimmed) return false;
|
|
201
|
+
const flat = flattenOptions(this.effectiveItems);
|
|
202
|
+
const lower = trimmed.toLowerCase();
|
|
203
|
+
return !flat.some(opt => opt.label.toLowerCase() === lower);
|
|
204
|
+
}
|
|
205
|
+
get createRow() {
|
|
206
|
+
if (!this.isCreateVisible) return null;
|
|
207
|
+
const query = this.query.trim();
|
|
208
|
+
const label = this.args.createLabel ? this.args.createLabel(query) : `Create "${query}"`;
|
|
209
|
+
return {
|
|
210
|
+
kind: 'create',
|
|
211
|
+
optionId: this.createOptionId,
|
|
212
|
+
isActive: this.activeId === this.createOptionId,
|
|
213
|
+
query,
|
|
214
|
+
label
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// --------------------------------------------------------------------------
|
|
218
|
+
// Async search
|
|
219
|
+
// --------------------------------------------------------------------------
|
|
220
|
+
get isLoading() {
|
|
221
|
+
if (this.args.isLoading !== undefined) return this.args.isLoading;
|
|
222
|
+
return this.isLoadingInternal;
|
|
223
|
+
}
|
|
224
|
+
get loadingText() {
|
|
225
|
+
return this.args.loadingText ?? 'Loading…';
|
|
226
|
+
}
|
|
227
|
+
get searchDebounceMs() {
|
|
228
|
+
return this.args.searchDebounceMs ?? 200;
|
|
229
|
+
}
|
|
230
|
+
scheduleAsyncSearch(query) {
|
|
231
|
+
if (!this.args.onSearch) return;
|
|
232
|
+
if (this.searchTimer) clearTimeout(this.searchTimer);
|
|
233
|
+
this.isLoadingInternal = true;
|
|
234
|
+
this.searchTimer = setTimeout(() => {
|
|
235
|
+
this.searchTimer = null;
|
|
236
|
+
void this.runAsyncSearch(query);
|
|
237
|
+
}, this.searchDebounceMs);
|
|
238
|
+
}
|
|
239
|
+
async runAsyncSearch(query) {
|
|
240
|
+
if (!this.args.onSearch) return;
|
|
241
|
+
const seq = ++this.searchSeq;
|
|
242
|
+
try {
|
|
243
|
+
const result = await this.args.onSearch(query);
|
|
244
|
+
if (seq !== this.searchSeq) return;
|
|
245
|
+
this.remoteItems = result;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// Only surface the error for the latest request; stale errors
|
|
248
|
+
// are as uninteresting as stale results.
|
|
249
|
+
if (seq === this.searchSeq) {
|
|
250
|
+
this.args.onSearchError?.(error);
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
if (seq === this.searchSeq) {
|
|
254
|
+
this.isLoadingInternal = false;
|
|
255
|
+
this.resetActiveToFirstNavigable();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// --------------------------------------------------------------------------
|
|
260
|
+
// Derived UI state
|
|
261
|
+
// --------------------------------------------------------------------------
|
|
262
|
+
get hasAnyValue() {
|
|
263
|
+
return this.values.length > 0;
|
|
264
|
+
}
|
|
265
|
+
get isClearable() {
|
|
266
|
+
return this.args.isClearable ?? true;
|
|
267
|
+
}
|
|
268
|
+
get showClearButton() {
|
|
269
|
+
return this.isClearable && this.hasAnyValue && !this.args.isDisabled;
|
|
270
|
+
}
|
|
271
|
+
get noResultsText() {
|
|
272
|
+
return this.args.noResultsText ?? 'No results';
|
|
273
|
+
}
|
|
274
|
+
/** Text for the aria-live region — see combobox.gts for rationale. */
|
|
275
|
+
get announcement() {
|
|
276
|
+
if (!this.isOpen) return '';
|
|
277
|
+
if (this.isLoading) return this.loadingText;
|
|
278
|
+
const optionCount = this.navigableRows.filter(r => r.kind === 'option').length;
|
|
279
|
+
if (optionCount === 0) {
|
|
280
|
+
if (this.isCreateVisible) return 'Create new option available';
|
|
281
|
+
return this.noResultsText;
|
|
282
|
+
}
|
|
283
|
+
return `${optionCount} ${optionCount === 1 ? 'option' : 'options'} available`;
|
|
284
|
+
}
|
|
285
|
+
get showEmptyState() {
|
|
286
|
+
if (this.isLoading) return false;
|
|
287
|
+
if (this.hasOptionResults) return false;
|
|
288
|
+
if (this.isCreateVisible) return false;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
get ariaExpanded() {
|
|
292
|
+
return this.isOpen ? 'true' : 'false';
|
|
293
|
+
}
|
|
294
|
+
get ariaRequired() {
|
|
295
|
+
return this.args.isRequired ? 'true' : undefined;
|
|
296
|
+
}
|
|
297
|
+
get ariaInvalid() {
|
|
298
|
+
return this.args.isInvalid ? 'true' : undefined;
|
|
299
|
+
}
|
|
300
|
+
// --------------------------------------------------------------------------
|
|
301
|
+
// Element registration
|
|
302
|
+
// --------------------------------------------------------------------------
|
|
303
|
+
setupInput = modifier(element => {
|
|
304
|
+
this.inputElement = element;
|
|
305
|
+
return () => {
|
|
306
|
+
this.inputElement = null;
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
setupListbox = modifier(element => {
|
|
310
|
+
this.listboxElement = element;
|
|
311
|
+
return () => {
|
|
312
|
+
this.listboxElement = null;
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
// --------------------------------------------------------------------------
|
|
316
|
+
// Event handlers — input (the trigger)
|
|
317
|
+
// --------------------------------------------------------------------------
|
|
318
|
+
handleInputFocus = event => {
|
|
319
|
+
this.args.onFocus?.(event);
|
|
320
|
+
};
|
|
321
|
+
handleInputBlur = event => {
|
|
322
|
+
this.args.onBlur?.(event);
|
|
323
|
+
};
|
|
324
|
+
handleInputClick = () => {
|
|
325
|
+
if (this.args.isDisabled) return;
|
|
326
|
+
if (!this.isOpen) this.openDropdown();
|
|
327
|
+
};
|
|
328
|
+
handleInput = event => {
|
|
329
|
+
const target = event.target;
|
|
330
|
+
this.query = target.value;
|
|
331
|
+
if (!this.isOpen) this.openDropdown({
|
|
332
|
+
resetActive: false
|
|
333
|
+
});
|
|
334
|
+
if (this.isAsync) {
|
|
335
|
+
this.scheduleAsyncSearch(this.query);
|
|
336
|
+
} else {
|
|
337
|
+
this.resetActiveToFirstNavigable();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
handleInputKeyDown = event => {
|
|
341
|
+
// Backspace on an empty input removes the last chip — available
|
|
342
|
+
// whether open or closed.
|
|
343
|
+
if (event.key === 'Backspace' && this.query === '' && this.values.length > 0) {
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
this.removeLastChip();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (!this.isOpen) {
|
|
349
|
+
switch (event.key) {
|
|
350
|
+
case 'ArrowDown':
|
|
351
|
+
case 'ArrowUp':
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
this.openDropdown();
|
|
354
|
+
return;
|
|
355
|
+
case 'Escape':
|
|
356
|
+
if (this.showClearButton) {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
this.clearAll();
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
default:
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
switch (event.key) {
|
|
366
|
+
case 'ArrowDown':
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
this.moveActive(1);
|
|
369
|
+
break;
|
|
370
|
+
case 'ArrowUp':
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
this.moveActive(-1);
|
|
373
|
+
break;
|
|
374
|
+
case 'PageDown':
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
this.movePage(1);
|
|
377
|
+
break;
|
|
378
|
+
case 'PageUp':
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
this.movePage(-1);
|
|
381
|
+
break;
|
|
382
|
+
case 'Home':
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
this.setActiveToFirst();
|
|
385
|
+
break;
|
|
386
|
+
case 'End':
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
this.setActiveToLast();
|
|
389
|
+
break;
|
|
390
|
+
case 'Enter':
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
this.activateActive();
|
|
393
|
+
break;
|
|
394
|
+
case 'Escape':
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
this.closeDropdown();
|
|
397
|
+
break;
|
|
398
|
+
case 'Tab':
|
|
399
|
+
this.closeDropdown();
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
handleChevronMouseDown = event => {
|
|
404
|
+
event.preventDefault();
|
|
405
|
+
};
|
|
406
|
+
handleChevronClick = event => {
|
|
407
|
+
event.preventDefault();
|
|
408
|
+
if (this.args.isDisabled) return;
|
|
409
|
+
if (this.isOpen) {
|
|
410
|
+
this.closeDropdown();
|
|
411
|
+
} else {
|
|
412
|
+
this.openDropdown();
|
|
413
|
+
}
|
|
414
|
+
this.inputElement?.focus({
|
|
415
|
+
preventScroll: true
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
// --------------------------------------------------------------------------
|
|
419
|
+
// Open / close
|
|
420
|
+
// --------------------------------------------------------------------------
|
|
421
|
+
openDropdown = (opts = {}) => {
|
|
422
|
+
if (this.args.isDisabled || this.isOpen) return;
|
|
423
|
+
this.isOpen = true;
|
|
424
|
+
if (opts.resetActive !== false) {
|
|
425
|
+
this.query = '';
|
|
426
|
+
this.resetActiveToInitial();
|
|
427
|
+
} else {
|
|
428
|
+
this.resetActiveToFirstNavigable();
|
|
429
|
+
}
|
|
430
|
+
this.args.onOpen?.();
|
|
431
|
+
};
|
|
432
|
+
closeDropdown = () => {
|
|
433
|
+
// Guard so onClose doesn't double-fire; several sources can call
|
|
434
|
+
// this when the dropdown is already closed.
|
|
435
|
+
const wasOpen = this.isOpen;
|
|
436
|
+
this.isOpen = false;
|
|
437
|
+
this.query = '';
|
|
438
|
+
this.activeId = null;
|
|
439
|
+
if (this.isAsync) {
|
|
440
|
+
this.remoteItems = null;
|
|
441
|
+
this.isLoadingInternal = false;
|
|
442
|
+
if (this.searchTimer) {
|
|
443
|
+
clearTimeout(this.searchTimer);
|
|
444
|
+
this.searchTimer = null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (wasOpen) this.args.onClose?.();
|
|
448
|
+
};
|
|
449
|
+
handleOpenChange = open => {
|
|
450
|
+
if (open) {
|
|
451
|
+
this.openDropdown();
|
|
452
|
+
} else {
|
|
453
|
+
this.closeDropdown();
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
// --------------------------------------------------------------------------
|
|
457
|
+
// Active-row management
|
|
458
|
+
// --------------------------------------------------------------------------
|
|
459
|
+
resetActiveToInitial() {
|
|
460
|
+
const rows = this.navigableRows;
|
|
461
|
+
if (rows.length === 0) {
|
|
462
|
+
this.activeId = null;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.activeId = rows[0].optionId;
|
|
466
|
+
}
|
|
467
|
+
resetActiveToFirstNavigable() {
|
|
468
|
+
const rows = this.navigableRows;
|
|
469
|
+
this.activeId = rows.length > 0 ? rows[0].optionId : null;
|
|
470
|
+
}
|
|
471
|
+
setActiveToFirst() {
|
|
472
|
+
const rows = this.navigableRows;
|
|
473
|
+
if (rows.length === 0) return;
|
|
474
|
+
this.activeId = rows[0].optionId;
|
|
475
|
+
this.scrollActiveIntoView();
|
|
476
|
+
}
|
|
477
|
+
setActiveToLast() {
|
|
478
|
+
const rows = this.navigableRows;
|
|
479
|
+
if (rows.length === 0) return;
|
|
480
|
+
this.activeId = rows[rows.length - 1].optionId;
|
|
481
|
+
this.scrollActiveIntoView();
|
|
482
|
+
}
|
|
483
|
+
moveActive(direction) {
|
|
484
|
+
const rows = this.navigableRows;
|
|
485
|
+
if (rows.length === 0) {
|
|
486
|
+
this.activeId = null;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const currentIndex = this.activeId ? rows.findIndex(r => r.optionId === this.activeId) : -1;
|
|
490
|
+
let nextIndex;
|
|
491
|
+
if (currentIndex === -1) {
|
|
492
|
+
nextIndex = direction === 1 ? 0 : rows.length - 1;
|
|
493
|
+
} else {
|
|
494
|
+
nextIndex = currentIndex + direction;
|
|
495
|
+
if (nextIndex < 0) nextIndex = rows.length - 1;
|
|
496
|
+
if (nextIndex >= rows.length) nextIndex = 0;
|
|
497
|
+
}
|
|
498
|
+
this.activeId = rows[nextIndex].optionId;
|
|
499
|
+
this.scrollActiveIntoView();
|
|
500
|
+
}
|
|
501
|
+
/** Move by `PAGE_STEP` rows; clamped (no wrap) at the list ends. */
|
|
502
|
+
movePage(direction) {
|
|
503
|
+
const rows = this.navigableRows;
|
|
504
|
+
if (rows.length === 0) {
|
|
505
|
+
this.activeId = null;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const currentIndex = this.activeId ? rows.findIndex(r => r.optionId === this.activeId) : direction === 1 ? -1 : rows.length;
|
|
509
|
+
const targetIndex = currentIndex + direction * PAGE_STEP;
|
|
510
|
+
const clamped = Math.max(0, Math.min(rows.length - 1, targetIndex));
|
|
511
|
+
this.activeId = rows[clamped].optionId;
|
|
512
|
+
this.scrollActiveIntoView();
|
|
513
|
+
}
|
|
514
|
+
scrollActiveIntoView() {
|
|
515
|
+
if (!this.activeId) return;
|
|
516
|
+
const node = document.getElementById(this.activeId);
|
|
517
|
+
node?.scrollIntoView({
|
|
518
|
+
block: 'nearest'
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
// --------------------------------------------------------------------------
|
|
522
|
+
// Selection / activation
|
|
523
|
+
// --------------------------------------------------------------------------
|
|
524
|
+
activateActive() {
|
|
525
|
+
if (!this.activeId) return;
|
|
526
|
+
const row = this.navigableRows.find(r => r.optionId === this.activeId);
|
|
527
|
+
if (!row) return;
|
|
528
|
+
if (row.kind === 'create') {
|
|
529
|
+
this.fireCreate();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (row.option.isDisabled) return;
|
|
533
|
+
this.toggleOption(row.option);
|
|
534
|
+
}
|
|
535
|
+
toggleOption = option => {
|
|
536
|
+
if (option.isDisabled) return;
|
|
537
|
+
const selected = this.valueSet;
|
|
538
|
+
const nextValues = selected.has(option.value) ? this.values.filter(v => v !== option.value) : [...this.values, option.value];
|
|
539
|
+
this.emitChange(nextValues);
|
|
540
|
+
// Clear the query so the filter resets but keep the dropdown open
|
|
541
|
+
// — multi-select is explicit about staying open until the user
|
|
542
|
+
// dismisses.
|
|
543
|
+
this.query = '';
|
|
544
|
+
this.resetActiveToFirstNavigable();
|
|
545
|
+
this.inputElement?.focus({
|
|
546
|
+
preventScroll: true
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
fireCreate = () => {
|
|
550
|
+
if (!this.args.onCreate) return;
|
|
551
|
+
const query = this.query.trim();
|
|
552
|
+
if (!query) return;
|
|
553
|
+
this.args.onCreate(query);
|
|
554
|
+
this.query = '';
|
|
555
|
+
this.resetActiveToFirstNavigable();
|
|
556
|
+
this.inputElement?.focus({
|
|
557
|
+
preventScroll: true
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
removeLastChip() {
|
|
561
|
+
if (this.values.length === 0) return;
|
|
562
|
+
const nextValues = this.values.slice(0, -1);
|
|
563
|
+
this.emitChange(nextValues);
|
|
564
|
+
}
|
|
565
|
+
removeChip = value => {
|
|
566
|
+
const nextValues = this.values.filter(v => v !== value);
|
|
567
|
+
this.emitChange(nextValues);
|
|
568
|
+
this.inputElement?.focus({
|
|
569
|
+
preventScroll: true
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
handleChipRemoveMouseDown = event => {
|
|
573
|
+
event.preventDefault();
|
|
574
|
+
};
|
|
575
|
+
handleChipRemove = (value, event) => {
|
|
576
|
+
event.preventDefault();
|
|
577
|
+
event.stopPropagation();
|
|
578
|
+
this.removeChip(value);
|
|
579
|
+
};
|
|
580
|
+
clearAll() {
|
|
581
|
+
this.emitChange([]);
|
|
582
|
+
this.inputElement?.focus({
|
|
583
|
+
preventScroll: true
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
handleClearMouseDown = event => {
|
|
587
|
+
event.preventDefault();
|
|
588
|
+
};
|
|
589
|
+
handleClearClick = event => {
|
|
590
|
+
event.preventDefault();
|
|
591
|
+
event.stopPropagation();
|
|
592
|
+
this.clearAll();
|
|
593
|
+
};
|
|
594
|
+
handleOptionMouseDown = event => {
|
|
595
|
+
event.preventDefault();
|
|
596
|
+
};
|
|
597
|
+
handleOptionMouseEnter = optionId => {
|
|
598
|
+
this.activeId = optionId;
|
|
599
|
+
};
|
|
600
|
+
emitChange(nextValues) {
|
|
601
|
+
const flat = flattenOptions(this.effectiveItems);
|
|
602
|
+
const byValue = new Map(flat.map(o => [o.value, o]));
|
|
603
|
+
const nextOptions = nextValues.map(v => byValue.get(v) ?? {
|
|
604
|
+
value: v,
|
|
605
|
+
label: v
|
|
606
|
+
});
|
|
607
|
+
this.args.onChange?.(nextValues, nextOptions);
|
|
608
|
+
}
|
|
609
|
+
static {
|
|
610
|
+
setComponentTemplate(precompileTemplate("{{!-- Mousedown handlers preventDefault to keep focus on the input when\n clicking chips / chevron / options \u2014 without this, clicks move\n focus off the input and fire spurious blur events. --}}\n{{!-- template-lint-disable no-pointer-down-event-binding --}}\n<Popover @trigger=\"manual\" @open={{this.isOpen}} @onOpenChange={{this.handleOpenChange}} @dismissOnClickOutside={{true}} @dismissOnEscape={{false}} class=\"multi-combobox_e1a041f66\" data-invalid={{if @isInvalid \"true\"}} data-disabled={{if @isDisabled \"true\"}} data-open={{if this.isOpen \"true\"}} data-test-multi-combobox ...attributes as |popover|>\n {{#if @name}}\n {{#each this.values as |v|}}\n <input type=\"hidden\" name={{@name}} value={{v}} data-test-multi-combobox-input-hidden />\n {{/each}}\n {{/if}}\n\n {{!-- Visually-hidden aria-live region for result-count\n announcements. See combobox.gts for rationale. --}}\n <div class=\"multi-combobox-sr-only_e1a041f66\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" data-test-multi-combobox-announcement>{{this.announcement}}</div>\n\n <div {{popover.registerTrigger}} class=\"multi-combobox-trigger_e1a041f66\" data-test-multi-combobox-trigger-wrapper>\n <div class=\"multi-combobox-chips_e1a041f66\" data-test-multi-combobox-chips>\n {{#each this.visibleChips key=\"value\" as |chip|}}\n <span class=\"multi-combobox-chip_e1a041f66\" data-test-multi-combobox-chip data-value={{chip.value}}>\n <span class=\"multi-combobox-chip-label_e1a041f66\">{{chip.label}}</span>\n <button type=\"button\" class=\"multi-combobox-chip-remove_e1a041f66\" aria-label=\"Remove {{chip.label}}\" tabindex=\"-1\" {{on \"mousedown\" this.handleChipRemoveMouseDown}} {{on \"click\" (fn this.handleChipRemove chip.value)}} data-test-multi-combobox-chip-remove>\n <Icon @icon={{X}} @size=\"sm\" />\n </button>\n </span>\n {{/each}}\n {{#if this.hasOverflow}}\n <span class=\"multi-combobox-more_e1a041f66\" data-test-multi-combobox-more>+{{this.overflowCount}}\n more</span>\n {{/if}}\n\n {{!-- template-lint-disable no-redundant-role --}}\n <input {{this.setupInput}} {{on \"click\" this.handleInputClick}} {{on \"focus\" this.handleInputFocus}} {{on \"blur\" this.handleInputBlur}} {{on \"input\" this.handleInput}} {{on \"keydown\" this.handleInputKeyDown}} id={{@id}} type=\"text\" class=\"multi-combobox-input_e1a041f66\" role=\"combobox\" aria-autocomplete=\"list\" aria-haspopup=\"listbox\" aria-expanded={{this.ariaExpanded}} aria-controls={{this.listboxId}} aria-activedescendant={{this.activeId}} aria-required={{this.ariaRequired}} aria-invalid={{this.ariaInvalid}} aria-describedby={{@aria-describedby}} autocomplete=\"off\" spellcheck=\"false\" placeholder={{unless this.hasAnyValue @placeholder}} value={{this.query}} disabled={{@isDisabled}} data-test-multi-combobox-trigger data-test-multi-combobox-input />\n </div>\n\n {{#if this.showClearButton}}\n <button type=\"button\" class=\"multi-combobox-clear_e1a041f66\" aria-label=\"Clear all\" tabindex=\"-1\" {{on \"mousedown\" this.handleClearMouseDown}} {{on \"click\" this.handleClearClick}} data-test-multi-combobox-clear>\n <Icon @icon={{X}} @size=\"sm\" />\n </button>\n {{/if}}\n\n <button type=\"button\" class=\"multi-combobox-chevron_e1a041f66\" aria-label={{if this.isOpen \"Close\" \"Open\"}} tabindex=\"-1\" disabled={{@isDisabled}} {{on \"mousedown\" this.handleChevronMouseDown}} {{on \"click\" this.handleChevronClick}} data-test-multi-combobox-chevron>\n <Icon @icon={{ChevronDown}} @size=\"sm\" />\n </button>\n </div>\n\n <popover.Content @side=\"bottom\" @align=\"start\" @sideOffset={{4}} @animation=\"scale\" class=\"multi-combobox-popover-content_e1a041f66\">\n <div id={{this.listboxId}} role=\"listbox\" aria-multiselectable=\"true\" class=\"multi-combobox-listbox_e1a041f66\" tabindex=\"-1\" {{this.setupListbox}} data-test-multi-combobox-listbox>\n {{#each this.renderedGroups key=\"key\" as |group|}}\n {{#if group.label}}\n <div role=\"group\" aria-labelledby={{group.headingId}} class=\"multi-combobox-group_e1a041f66\" data-test-multi-combobox-group>\n <div id={{group.headingId}} class=\"multi-combobox-group-heading_e1a041f66\" data-test-multi-combobox-group-heading>{{group.label}}</div>\n {{!-- template-lint-disable require-context-role --}}\n {{#each group.rows key=\"optionId\" as |row|}}\n <div id={{row.optionId}} role=\"option\" class=\"multi-combobox-option_e1a041f66\" aria-selected={{if row.isSelected \"true\" \"false\"}} aria-disabled={{if row.option.isDisabled \"true\"}} data-active={{if row.isActive \"true\"}} data-selected={{if row.isSelected \"true\"}} data-disabled={{if row.option.isDisabled \"true\"}} data-option-id={{row.optionId}} data-value={{row.option.value}} {{on \"mousedown\" this.handleOptionMouseDown}} {{on \"click\" (fn this.toggleOption row.option)}} {{on \"mouseenter\" (fn this.handleOptionMouseEnter row.optionId)}} data-test-multi-combobox-option>\n <div class=\"multi-combobox-option-content_e1a041f66\">\n <span class=\"multi-combobox-option-label_e1a041f66\" data-test-multi-combobox-option-label>\n {{~#each (splitOnMatch row.option.label this.query) as |segment|~}}\n {{~#if segment.isMatch~}}\n <span class=\"multi-combobox-mark_e1a041f66\" data-test-multi-combobox-mark>{{segment.text}}</span>\n {{~else~}}\n {{segment.text}}\n {{~/if~}}\n {{~/each~}}\n </span>\n {{#if row.option.description}}\n <span class=\"multi-combobox-option-description_e1a041f66\" data-test-multi-combobox-option-description>\n {{~#each (splitOnMatch row.option.description this.query) as |segment|~}}\n {{~#if segment.isMatch~}}\n <span class=\"multi-combobox-mark_e1a041f66\" data-test-multi-combobox-mark>{{segment.text}}</span>\n {{~else~}}\n {{segment.text}}\n {{~/if~}}\n {{~/each~}}\n </span>\n {{/if}}\n </div>\n <span class=\"multi-combobox-checkbox_e1a041f66\" data-checked={{if row.isSelected \"true\"}} aria-hidden=\"true\"></span>\n </div>\n {{/each}}\n </div>\n {{else}}\n {{#each group.rows key=\"optionId\" as |row|}}\n <div id={{row.optionId}} role=\"option\" class=\"multi-combobox-option_e1a041f66\" aria-selected={{if row.isSelected \"true\" \"false\"}} aria-disabled={{if row.option.isDisabled \"true\"}} data-active={{if row.isActive \"true\"}} data-selected={{if row.isSelected \"true\"}} data-disabled={{if row.option.isDisabled \"true\"}} data-option-id={{row.optionId}} data-value={{row.option.value}} {{on \"mousedown\" this.handleOptionMouseDown}} {{on \"click\" (fn this.toggleOption row.option)}} {{on \"mouseenter\" (fn this.handleOptionMouseEnter row.optionId)}} data-test-multi-combobox-option>\n <div class=\"multi-combobox-option-content_e1a041f66\">\n <span class=\"multi-combobox-option-label_e1a041f66\" data-test-multi-combobox-option-label>\n {{~#each (splitOnMatch row.option.label this.query) as |segment|~}}\n {{~#if segment.isMatch~}}\n <span class=\"multi-combobox-mark_e1a041f66\" data-test-multi-combobox-mark>{{segment.text}}</span>\n {{~else~}}\n {{segment.text}}\n {{~/if~}}\n {{~/each~}}\n </span>\n {{#if row.option.description}}\n <span class=\"multi-combobox-option-description_e1a041f66\" data-test-multi-combobox-option-description>\n {{~#each (splitOnMatch row.option.description this.query) as |segment|~}}\n {{~#if segment.isMatch~}}\n <span class=\"multi-combobox-mark_e1a041f66\" data-test-multi-combobox-mark>{{segment.text}}</span>\n {{~else~}}\n {{segment.text}}\n {{~/if~}}\n {{~/each~}}\n </span>\n {{/if}}\n </div>\n <span class=\"multi-combobox-checkbox_e1a041f66\" data-checked={{if row.isSelected \"true\"}} aria-hidden=\"true\"></span>\n </div>\n {{/each}}\n {{/if}}\n {{/each}}\n\n {{#if this.createRow}}\n <div id={{this.createRow.optionId}} role=\"option\" class=\"multi-combobox-option_e1a041f66 multi-combobox-create_e1a041f66\" aria-selected=\"false\" data-active={{if this.createRow.isActive \"true\"}} data-option-id={{this.createRow.optionId}} {{on \"mousedown\" this.handleOptionMouseDown}} {{on \"click\" this.fireCreate}} {{on \"mouseenter\" (fn this.handleOptionMouseEnter this.createRow.optionId)}} data-test-multi-combobox-create>\n <span class=\"multi-combobox-create-icon_e1a041f66\" aria-hidden=\"true\">+</span>\n <span class=\"multi-combobox-option-label_e1a041f66\">{{this.createRow.label}}</span>\n </div>\n {{/if}}\n\n {{#if this.isLoading}}\n <div class=\"multi-combobox-loading_e1a041f66\" data-test-multi-combobox-loading>{{this.loadingText}}</div>\n {{else if this.showEmptyState}}\n <div class=\"multi-combobox-empty_e1a041f66\" data-test-multi-combobox-empty>{{this.noResultsText}}</div>\n {{/if}}\n </div>\n </popover.Content>\n</Popover>", {
|
|
611
|
+
strictMode: true,
|
|
612
|
+
scope: () => ({
|
|
613
|
+
Popover,
|
|
614
|
+
on,
|
|
615
|
+
fn,
|
|
616
|
+
Icon,
|
|
617
|
+
X,
|
|
618
|
+
ChevronDown,
|
|
619
|
+
splitOnMatch
|
|
620
|
+
})
|
|
621
|
+
}), this);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export { MultiComboBox as default };
|
|
626
|
+
//# sourceMappingURL=multi-combobox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"multi-combobox.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"h-stack.js","sources":["../../src/layout/h-stack.gts"],"sourcesContent":["import type { TOC } from '@ember/component/template-only';\nimport Stack from './stack.gts';\n\nexport interface HStackSignature {\n Element: HTMLDivElement;\n Args: {\n gap
|
|
1
|
+
{"version":3,"file":"h-stack.js","sources":["../../src/layout/h-stack.gts"],"sourcesContent":["import type { TOC } from '@ember/component/template-only';\nimport Stack from './stack.gts';\n\nexport interface HStackSignature {\n Element: HTMLDivElement;\n Args: {\n gap?:\n | 'none'\n | '2xs'\n | 'xs'\n | 'sm'\n | 'md'\n | 'lg'\n | 'xl'\n | '2xl'\n | '3xl'\n | '4xl'\n | '5xl';\n align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';\n justify?: 'start' | 'center' | 'end' | 'between';\n wrap?: boolean;\n inline?: boolean;\n };\n Blocks: {\n default: [];\n };\n}\n\nconst HStack: TOC<HStackSignature> = <template>\n <Stack\n @direction=\"row\"\n @gap={{@gap}}\n @align={{@align}}\n @justify={{@justify}}\n @wrap={{@wrap}}\n @inline={{@inline}}\n ...attributes\n >\n {{yield}}\n </Stack>\n</template>;\n\nexport default HStack;\n"],"names":["HStack","setComponentTemplate","precompileTemplate","strictMode","scope","Stack","templateOnly"],"mappings":";;;;;AA4BA,MAAMA,MAAY,GAAAC,oBAAA,CAAmBC,kBAAA,CAAA,2JAAA,EAYrC;EAAAC,UAAA,EAAA,IAAA;AAAAC,EAAAA,KAAA,EAAAA,OAAA;AAAAC,IAAAA;AAAA,GAAA;AAAU,CAAA,CAAA,EAAAC,YAAA,EAAA;;;;"}
|
package/dist/layout/panel.css
CHANGED
|
@@ -227,3 +227,13 @@
|
|
|
227
227
|
.panel_efcbf2ade[data-variant="filled"][data-tone="neutral"] > .panel-footer_efcbf2ade {
|
|
228
228
|
border-top-color: rgba(0, 0, 0, 0.1);
|
|
229
229
|
}
|
|
230
|
+
|
|
231
|
+
/* Strip separators at the structural edge — a header-only or
|
|
232
|
+
footer-only Panel has nothing to separate. */
|
|
233
|
+
.panel_efcbf2ade > .panel-header_efcbf2ade:last-child {
|
|
234
|
+
border-bottom: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.panel_efcbf2ade > .panel-footer_efcbf2ade:first-child {
|
|
238
|
+
border-top: none;
|
|
239
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"v-stack.js","sources":["../../src/layout/v-stack.gts"],"sourcesContent":["import type { TOC } from '@ember/component/template-only';\nimport Stack from './stack.gts';\n\nexport interface VStackSignature {\n Element: HTMLDivElement;\n Args: {\n gap
|
|
1
|
+
{"version":3,"file":"v-stack.js","sources":["../../src/layout/v-stack.gts"],"sourcesContent":["import type { TOC } from '@ember/component/template-only';\nimport Stack from './stack.gts';\n\nexport interface VStackSignature {\n Element: HTMLDivElement;\n Args: {\n gap?:\n | 'none'\n | '2xs'\n | 'xs'\n | 'sm'\n | 'md'\n | 'lg'\n | 'xl'\n | '2xl'\n | '3xl'\n | '4xl'\n | '5xl';\n align?: 'start' | 'center' | 'end' | 'stretch';\n justify?: 'start' | 'center' | 'end' | 'between';\n inline?: boolean;\n };\n Blocks: {\n default: [];\n };\n}\n\nconst VStack: TOC<VStackSignature> = <template>\n <Stack\n @direction=\"column\"\n @gap={{@gap}}\n @align={{@align}}\n @justify={{@justify}}\n @inline={{@inline}}\n ...attributes\n >\n {{yield}}\n </Stack>\n</template>;\n\nexport default VStack;\n"],"names":["VStack","setComponentTemplate","precompileTemplate","strictMode","scope","Stack","templateOnly"],"mappings":";;;;;AA2BA,MAAMA,MAAY,GAAAC,oBAAA,CAAmBC,kBAAA,CAAA,8IAAA,EAWrC;EAAAC,UAAA,EAAA,IAAA;AAAAC,EAAAA,KAAA,EAAAA,OAAA;AAAAC,IAAAA;AAAA,GAAA;AAAU,CAAA,CAAA,EAAAC,YAAA,EAAA;;;;"}
|