@wpmoo/ui 0.3.1 → 0.4.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.
@@ -0,0 +1,439 @@
1
+ const instances = new WeakMap();
2
+ const normalize = (value) => value.trim().toLowerCase();
3
+
4
+ export default class Combobox {
5
+ static getInstance(element) {
6
+ return element?.nodeType === 1 ? instances.get(element) || null : null;
7
+ }
8
+
9
+ static getOrCreateInstance(element, config = {}) {
10
+ return Combobox.getInstance(element) || new Combobox(element, config);
11
+ }
12
+
13
+ constructor(element, config = {}) {
14
+ if (element?.nodeType !== 1 || !element.matches(".combobox")) {
15
+ throw new TypeError("Combobox requires a .combobox root element.");
16
+ }
17
+ const existing = instances.get(element);
18
+ if (existing) {
19
+ return existing;
20
+ }
21
+
22
+ this._element = element;
23
+ this._document = element.ownerDocument;
24
+ this._window = this._document.defaultView;
25
+ this._config = { noResultsText: "No results found.", ...config };
26
+ this._input = element.querySelector(".combobox-input");
27
+ this._menu = element.querySelector(".combobox-menu");
28
+ this._options = Array.from(element.querySelectorAll(".combobox-option"));
29
+ if (!this._input || !this._menu || this._options.length === 0) {
30
+ throw new TypeError("Combobox requires an input, menu, and at least one option.");
31
+ }
32
+
33
+ this._isMultiple = element.dataset.mooComboboxMultiple === "true";
34
+ this._hidden = this._isMultiple ? null : element.querySelector('input[type="hidden"]');
35
+ this._chipValue = element.querySelector(".combobox-value");
36
+ this._valueStore = element.querySelector("[data-moo-combobox-value-store]");
37
+ this._chipIconTemplate = element.querySelector("[data-moo-combobox-chip-icon]");
38
+ this._clearTrigger = element.querySelector("[data-moo-combobox-clear]");
39
+ this._indicator = element.querySelector(".combobox-indicator");
40
+ this._groups = Array.from(element.querySelectorAll("[data-moo-combobox-group]"));
41
+ this._separators = Array.from(element.querySelectorAll("[data-moo-combobox-separator]"));
42
+ this._listeners = [];
43
+ this._generated = [];
44
+ this._startsOpen = this._menu.classList.contains("show");
45
+
46
+ this._empty = this._menu.querySelector("[data-moo-combobox-empty]");
47
+ if (!this._empty) {
48
+ this._empty = this._document.createElement("li");
49
+ this._empty.className = "combobox-empty";
50
+ this._empty.hidden = true;
51
+ this._empty.dataset.mooComboboxEmpty = "true";
52
+ this._empty.textContent = this._config.noResultsText;
53
+ this._menu.appendChild(this._empty);
54
+ this._generated.push(this._empty);
55
+ }
56
+
57
+ this._liveRegion = element.querySelector("[data-moo-combobox-live]");
58
+ if (!this._liveRegion) {
59
+ this._liveRegion = this._document.createElement("span");
60
+ this._liveRegion.className = "visually-hidden";
61
+ this._liveRegion.setAttribute("aria-live", "polite");
62
+ this._liveRegion.dataset.mooComboboxLive = "true";
63
+ element.appendChild(this._liveRegion);
64
+ this._generated.push(this._liveRegion);
65
+ }
66
+
67
+ instances.set(element, this);
68
+ this._bindEvents();
69
+ this._toggleClear(
70
+ this._input.dataset.mooComboboxSelected === "true" ||
71
+ element.dataset.mooComboboxSelected === "true"
72
+ );
73
+ this._syncMultipleValue();
74
+ this._filterOptions({ open: this._startsOpen, activate: this._startsOpen });
75
+ if (!this._startsOpen) {
76
+ this._closeMenu(false);
77
+ }
78
+ }
79
+
80
+ dispose() {
81
+ this._listeners.forEach(({ target, type, handler, options }) => {
82
+ target.removeEventListener(type, handler, options);
83
+ });
84
+ this._listeners = [];
85
+ this._closeMenu(false);
86
+ this._generated.forEach((element) => element.remove());
87
+ this._generated = [];
88
+ this._chipValue?.querySelectorAll("[data-moo-combobox-generated]").forEach((item) => item.remove());
89
+ this._valueStore?.querySelectorAll("[data-moo-combobox-generated]").forEach((item) => item.remove());
90
+ instances.delete(this._element);
91
+ }
92
+
93
+ _listen(target, type, handler, options) {
94
+ target?.addEventListener(type, handler, options);
95
+ if (target) {
96
+ this._listeners.push({ target, type, handler, options });
97
+ }
98
+ }
99
+
100
+ _trigger(name, detail = {}) {
101
+ return this._element.dispatchEvent(
102
+ new this._window.CustomEvent(`${name}.moo.combobox`, {
103
+ bubbles: true,
104
+ cancelable: name === "show" || name === "hide",
105
+ detail,
106
+ })
107
+ );
108
+ }
109
+
110
+ _optionGroupIsHidden(option) {
111
+ return option.closest("[data-moo-combobox-group]")?.hidden || false;
112
+ }
113
+
114
+ _visibleOptions() {
115
+ return this._options.filter(
116
+ (option) => !option.hidden && !option.disabled && !this._optionGroupIsHidden(option)
117
+ );
118
+ }
119
+
120
+ _syncGroupVisibility() {
121
+ if (this._groups.length === 0) {
122
+ return;
123
+ }
124
+ let visibleGroupCount = 0;
125
+ this._groups.forEach((group) => {
126
+ const visible = Array.from(group.querySelectorAll(".combobox-option")).some(
127
+ (option) => !option.hidden
128
+ );
129
+ group.hidden = !visible;
130
+ visibleGroupCount += visible ? 1 : 0;
131
+ });
132
+ this._separators.forEach((separator) => {
133
+ separator.hidden = visibleGroupCount < 2;
134
+ });
135
+ }
136
+
137
+ _openMenu(emit = true) {
138
+ if (this._menu.classList.contains("show")) {
139
+ this._input.setAttribute("aria-expanded", "true");
140
+ return;
141
+ }
142
+ if (emit && !this._trigger("show")) {
143
+ return;
144
+ }
145
+ this._menu.classList.add("show");
146
+ this._input.setAttribute("aria-expanded", "true");
147
+ if (emit) {
148
+ this._trigger("shown");
149
+ }
150
+ }
151
+
152
+ _closeMenu(emit = true) {
153
+ const wasOpen = this._menu.classList.contains("show");
154
+ if (wasOpen && emit && !this._trigger("hide")) {
155
+ return;
156
+ }
157
+ this._menu.classList.remove("show");
158
+ this._input.setAttribute("aria-expanded", "false");
159
+ this._input.removeAttribute("aria-activedescendant");
160
+ this._options.forEach((option) => option.removeAttribute("aria-current"));
161
+ if (wasOpen && emit) {
162
+ this._trigger("hidden");
163
+ }
164
+ }
165
+
166
+ _optionLabel(option) {
167
+ return option?.querySelector(".combobox-option__label")?.textContent?.trim() || "";
168
+ }
169
+
170
+ _selectedOptions() {
171
+ return this._options.filter((option) => option.getAttribute("aria-selected") === "true");
172
+ }
173
+
174
+ _toggleClear(selected) {
175
+ const value = selected ? "true" : "false";
176
+ this._element.dataset.mooComboboxSelected = value;
177
+ this._input.dataset.mooComboboxSelected = value;
178
+ if (this._clearTrigger) {
179
+ this._clearTrigger.hidden = !selected;
180
+ if (this._indicator) {
181
+ this._indicator.hidden = selected;
182
+ }
183
+ }
184
+ }
185
+
186
+ _createChip(option) {
187
+ const value = option.dataset.value || "";
188
+ const label = this._optionLabel(option);
189
+ const chip = this._document.createElement("span");
190
+ chip.className = "badge text-bg-secondary combobox-chip";
191
+ chip.dataset.value = value;
192
+ chip.dataset.mooComboboxGenerated = "true";
193
+ const labelElement = this._document.createElement("span");
194
+ labelElement.className = "combobox-chip__label";
195
+ labelElement.textContent = label;
196
+ chip.appendChild(labelElement);
197
+ const remove = this._document.createElement("button");
198
+ remove.className = "combobox-chip__remove";
199
+ remove.type = "button";
200
+ remove.dataset.value = value;
201
+ remove.dataset.mooComboboxChipRemove = "true";
202
+ remove.setAttribute("aria-label", `Remove ${label}`);
203
+ remove.append(this._chipIconTemplate?.content.cloneNode(true) || "x");
204
+ chip.appendChild(remove);
205
+ return chip;
206
+ }
207
+
208
+ _syncMultipleValue() {
209
+ if (!this._isMultiple) {
210
+ return;
211
+ }
212
+ const selected = this._selectedOptions();
213
+ if (this._chipValue) {
214
+ this._chipValue.replaceChildren(...selected.map((option) => this._createChip(option)));
215
+ }
216
+ if (this._valueStore) {
217
+ this._valueStore.replaceChildren();
218
+ const name = this._valueStore.dataset.mooComboboxName || "";
219
+ selected.forEach((option) => {
220
+ const field = this._document.createElement("input");
221
+ field.type = "hidden";
222
+ field.name = name;
223
+ field.value = option.dataset.value || "";
224
+ field.dataset.mooComboboxGenerated = "true";
225
+ this._valueStore.appendChild(field);
226
+ });
227
+ }
228
+ this._toggleClear(selected.length > 0);
229
+ }
230
+
231
+ _emitChange() {
232
+ const selected = this._selectedOptions();
233
+ this._trigger("change", {
234
+ values: selected.map((option) => option.dataset.value || ""),
235
+ });
236
+ }
237
+
238
+ _removeChip(value) {
239
+ const option = this._options.find((candidate) => candidate.dataset.value === value);
240
+ if (!option) {
241
+ return;
242
+ }
243
+ option.setAttribute("aria-selected", "false");
244
+ this._syncMultipleValue();
245
+ this._emitChange();
246
+ }
247
+
248
+ _clearSelection(emit = true) {
249
+ if (this._hidden) {
250
+ this._hidden.value = "";
251
+ }
252
+ this._options.forEach((option) => option.setAttribute("aria-selected", "false"));
253
+ if (this._isMultiple) {
254
+ this._syncMultipleValue();
255
+ } else {
256
+ this._toggleClear(false);
257
+ }
258
+ if (emit) {
259
+ this._emitChange();
260
+ }
261
+ }
262
+
263
+ _clearStaleSelection() {
264
+ if (this._isMultiple) {
265
+ this._input.value = "";
266
+ this._options.forEach((option) => {
267
+ option.hidden = false;
268
+ });
269
+ this._syncGroupVisibility();
270
+ this._empty.hidden = true;
271
+ return;
272
+ }
273
+ const selected = this._selectedOptions()[0] || null;
274
+ const label = this._optionLabel(selected);
275
+ if (!this._input.value || (label && normalize(this._input.value) === normalize(label))) {
276
+ return;
277
+ }
278
+ this._clearSelection();
279
+ this._input.value = "";
280
+ }
281
+
282
+ _setActiveOption(option) {
283
+ this._options.forEach((candidate) => candidate.toggleAttribute("aria-current", candidate === option));
284
+ if (!option) {
285
+ this._input.removeAttribute("aria-activedescendant");
286
+ return;
287
+ }
288
+ this._input.setAttribute("aria-activedescendant", option.id);
289
+ const optionRect = option.getBoundingClientRect();
290
+ const menuRect = this._menu.getBoundingClientRect();
291
+ if (optionRect.top < menuRect.top) {
292
+ this._menu.scrollTop -= menuRect.top - optionRect.top;
293
+ } else if (optionRect.bottom > menuRect.bottom) {
294
+ this._menu.scrollTop += optionRect.bottom - menuRect.bottom;
295
+ }
296
+ }
297
+
298
+ _chooseOption(option) {
299
+ if (!option || option.disabled) {
300
+ return;
301
+ }
302
+ if (this._isMultiple) {
303
+ option.setAttribute(
304
+ "aria-selected",
305
+ option.getAttribute("aria-selected") === "true" ? "false" : "true"
306
+ );
307
+ this._input.value = "";
308
+ this._syncMultipleValue();
309
+ this._filterOptions();
310
+ this._setActiveOption(option);
311
+ this._emitChange();
312
+ return;
313
+ }
314
+ this._options.forEach((candidate) => {
315
+ candidate.setAttribute("aria-selected", candidate === option ? "true" : "false");
316
+ });
317
+ this._input.value = this._optionLabel(option);
318
+ if (this._hidden) {
319
+ this._hidden.value = option.dataset.value || "";
320
+ }
321
+ this._toggleClear(true);
322
+ this._setActiveOption(option);
323
+ this._emitChange();
324
+ }
325
+
326
+ _filterOptions({ open = true, activate = true } = {}) {
327
+ if (open) {
328
+ this._openMenu();
329
+ }
330
+ const needle = normalize(this._input.value);
331
+ let count = 0;
332
+ this._options.forEach((option) => {
333
+ const matches = !needle || normalize(option.textContent).includes(needle);
334
+ option.hidden = !matches;
335
+ count += matches ? 1 : 0;
336
+ });
337
+ this._syncGroupVisibility();
338
+ this._empty.hidden = count !== 0;
339
+ this._liveRegion.textContent = count === 0 ? "No results" : `${count} result${count === 1 ? "" : "s"}`;
340
+ if (activate) {
341
+ this._setActiveOption(this._visibleOptions()[0] || null);
342
+ }
343
+ }
344
+
345
+ _bindEvents() {
346
+ this._listen(this._input, "focus", () => {
347
+ this._openMenu();
348
+ if (!this._isMultiple && this._input.dataset.mooComboboxSelected === "true") {
349
+ this._input.select();
350
+ }
351
+ this._setActiveOption(this._visibleOptions()[0] || null);
352
+ });
353
+ this._listen(this._input, "input", () => {
354
+ if (!this._isMultiple) {
355
+ this._clearSelection();
356
+ }
357
+ this._filterOptions();
358
+ });
359
+ this._listen(this._input, "blur", () => this._clearStaleSelection());
360
+ this._listen(this._input, "click", () => this._openMenu());
361
+ this._listen(this._input, "keydown", (event) => this._handleKeydown(event));
362
+ this._options.forEach((option) => {
363
+ this._listen(option, "click", () => {
364
+ this._chooseOption(option);
365
+ if (this._isMultiple) {
366
+ this._input.focus();
367
+ } else {
368
+ this._closeMenu();
369
+ }
370
+ });
371
+ });
372
+ this._listen(this._clearTrigger, "click", (event) => {
373
+ event.preventDefault();
374
+ this._clearSelection();
375
+ this._input.value = "";
376
+ this._input.focus();
377
+ this._filterOptions();
378
+ });
379
+ this._listen(this._element, "click", (event) => this._handleRootClick(event));
380
+ this._listen(this._document, "click", (event) => {
381
+ if (event.target instanceof this._window.Node && !this._element.contains(event.target)) {
382
+ this._closeMenu();
383
+ }
384
+ });
385
+ }
386
+
387
+ _handleRootClick(event) {
388
+ const target = event.target;
389
+ const trigger = target instanceof this._window.Element
390
+ ? target.closest("[data-moo-combobox-chip-remove]")
391
+ : null;
392
+ if (trigger) {
393
+ event.preventDefault();
394
+ this._removeChip(trigger.dataset.value || "");
395
+ this._input.focus();
396
+ } else if (
397
+ this._isMultiple &&
398
+ target instanceof this._window.Element &&
399
+ target.closest(".combobox-chips")
400
+ ) {
401
+ this._input.focus();
402
+ }
403
+ }
404
+
405
+ _handleKeydown(event) {
406
+ const available = this._visibleOptions();
407
+ const current = available.findIndex(
408
+ (option) => option.id === this._input.getAttribute("aria-activedescendant")
409
+ );
410
+ if (this._isMultiple && event.key === "Backspace" && this._input.value === "") {
411
+ const selected = this._selectedOptions();
412
+ const last = selected[selected.length - 1];
413
+ if (last) {
414
+ event.preventDefault();
415
+ this._removeChip(last.dataset.value || "");
416
+ }
417
+ } else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && available.length) {
418
+ event.preventDefault();
419
+ this._openMenu();
420
+ const offset = event.key === "ArrowDown" ? 1 : -1;
421
+ const next = current === -1 ? 0 : (current + offset + available.length) % available.length;
422
+ this._setActiveOption(available[next]);
423
+ } else if (event.key === "Enter") {
424
+ const option = available[current];
425
+ if (option) {
426
+ event.preventDefault();
427
+ this._chooseOption(option);
428
+ if (!this._isMultiple) {
429
+ this._closeMenu();
430
+ }
431
+ }
432
+ } else if (event.key === "Escape") {
433
+ this._closeMenu();
434
+ this._input.blur();
435
+ } else if (event.key === "Tab") {
436
+ this._closeMenu();
437
+ }
438
+ }
439
+ }