@woylie/doggo 0.15.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 +421 -0
- package/assets/js/hooks/accordion.js +33 -0
- package/assets/js/hooks/carousel.js +315 -0
- package/assets/js/hooks/combobox.js +370 -0
- package/assets/js/hooks/dialog.js +77 -0
- package/assets/js/hooks/menu.js +138 -0
- package/assets/js/hooks/menu_button.js +35 -0
- package/assets/js/hooks/split_pane.js +100 -0
- package/assets/js/hooks/tabs.js +80 -0
- package/assets/js/hooks/toolbar.js +100 -0
- package/assets/js/hooks/tooltip.js +48 -0
- package/assets/js/hooks/tree.js +197 -0
- package/assets/js/index.js +11 -0
- package/assets/js/navigation.js +69 -0
- package/package.json +62 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { selectTab, stepIndex, targetIndex } from "../navigation.js";
|
|
2
|
+
|
|
3
|
+
export function initCarousel(carousel) {
|
|
4
|
+
const baseClass = carousel.className.split(" ")[0];
|
|
5
|
+
|
|
6
|
+
const getItemsContainer = () =>
|
|
7
|
+
carousel.querySelector(`.${baseClass}-items-container`);
|
|
8
|
+
|
|
9
|
+
const getLiveRegion = () => carousel.querySelector(`.${baseClass}-items`);
|
|
10
|
+
|
|
11
|
+
const getItems = () =>
|
|
12
|
+
Array.from(getItemsContainer().querySelectorAll(`.${baseClass}-item`));
|
|
13
|
+
|
|
14
|
+
const getTabs = () =>
|
|
15
|
+
Array.from(
|
|
16
|
+
carousel.querySelectorAll(`.${baseClass}-pagination [role="tab"]`),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const getControl = (name) => carousel.querySelector(`.${baseClass}-${name}`);
|
|
20
|
+
|
|
21
|
+
// Auto rotation is enabled if there is a pause button.
|
|
22
|
+
const isAutoRotationEnabled = () => getControl("pause") != null;
|
|
23
|
+
// Ignore a non-positive interval.
|
|
24
|
+
const interval = Number(carousel.dataset.rotationIntervalMs);
|
|
25
|
+
const rotationIntervalMs = interval > 0 ? interval : 5000;
|
|
26
|
+
let autoRotationTimer = null;
|
|
27
|
+
|
|
28
|
+
const isLoopEnabled = carousel.getAttribute("data-loop") != null;
|
|
29
|
+
|
|
30
|
+
const getTargetIdx = (currentIdx, offset) =>
|
|
31
|
+
stepIndex(currentIdx, offset, getItems().length, { wrap: isLoopEnabled });
|
|
32
|
+
|
|
33
|
+
const getCurrentIdx = () =>
|
|
34
|
+
Number(carousel.getAttribute("data-active-index")) || 0;
|
|
35
|
+
|
|
36
|
+
const setDisabled = (el, disabled) => {
|
|
37
|
+
if (!el) return;
|
|
38
|
+
|
|
39
|
+
if (disabled) {
|
|
40
|
+
el.setAttribute("disabled", "");
|
|
41
|
+
} else {
|
|
42
|
+
el.removeAttribute("disabled");
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const getActiveIdx = () => {
|
|
47
|
+
const viewport = getItemsContainer().getBoundingClientRect();
|
|
48
|
+
const viewportCenter = viewport.left + viewport.width / 2;
|
|
49
|
+
|
|
50
|
+
let activeIdx = 0;
|
|
51
|
+
let shortestDistance = Infinity;
|
|
52
|
+
|
|
53
|
+
getItems().forEach((item, idx) => {
|
|
54
|
+
const rect = item.getBoundingClientRect();
|
|
55
|
+
const center = rect.left + rect.width / 2;
|
|
56
|
+
const distance = Math.abs(center - viewportCenter);
|
|
57
|
+
|
|
58
|
+
if (distance < shortestDistance) {
|
|
59
|
+
shortestDistance = distance;
|
|
60
|
+
activeIdx = idx;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return activeIdx;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const setActiveIdx = (activeIdx) => {
|
|
68
|
+
const items = getItems();
|
|
69
|
+
|
|
70
|
+
carousel.setAttribute("data-active-index", activeIdx);
|
|
71
|
+
|
|
72
|
+
if (!isLoopEnabled) {
|
|
73
|
+
setDisabled(getControl("previous"), activeIdx === 0);
|
|
74
|
+
setDisabled(getControl("next"), activeIdx === items.length - 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
selectTab(getTabs(), activeIdx);
|
|
78
|
+
|
|
79
|
+
items.forEach((item, idx) => {
|
|
80
|
+
item.setAttribute("aria-current", idx === activeIdx ? "true" : "false");
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const syncActiveState = () => setActiveIdx(getActiveIdx());
|
|
85
|
+
|
|
86
|
+
const scrollToIdx = (idx) => {
|
|
87
|
+
const item = getItems()[idx];
|
|
88
|
+
|
|
89
|
+
if (!item) return;
|
|
90
|
+
|
|
91
|
+
setActiveIdx(idx);
|
|
92
|
+
|
|
93
|
+
const itemsContainer = getItemsContainer();
|
|
94
|
+
const container = itemsContainer.getBoundingClientRect();
|
|
95
|
+
const target = item.getBoundingClientRect();
|
|
96
|
+
const offset =
|
|
97
|
+
target.left + target.width / 2 - (container.left + container.width / 2);
|
|
98
|
+
|
|
99
|
+
const maxScroll = itemsContainer.scrollWidth - itemsContainer.clientWidth;
|
|
100
|
+
|
|
101
|
+
itemsContainer.scrollLeft = Math.min(
|
|
102
|
+
Math.max(itemsContainer.scrollLeft + offset, 0),
|
|
103
|
+
maxScroll,
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Auto rotation
|
|
108
|
+
let isPaused = false;
|
|
109
|
+
|
|
110
|
+
// Stop auto rotation if the last items does not wrap.
|
|
111
|
+
const hasShowEnded = () =>
|
|
112
|
+
!isLoopEnabled && getCurrentIdx() === getItems().length - 1;
|
|
113
|
+
|
|
114
|
+
const endShow = () => {
|
|
115
|
+
isPaused = true;
|
|
116
|
+
stopAutoRotation();
|
|
117
|
+
syncPauseControl();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const startAutoRotation = () => {
|
|
121
|
+
if (!isAutoRotationEnabled() || isPaused) return;
|
|
122
|
+
if (autoRotationTimer != null) return;
|
|
123
|
+
|
|
124
|
+
if (hasShowEnded()) {
|
|
125
|
+
endShow();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
autoRotationTimer = setInterval(() => {
|
|
130
|
+
scrollToIdx(getTargetIdx(getCurrentIdx(), 1));
|
|
131
|
+
|
|
132
|
+
if (hasShowEnded()) endShow();
|
|
133
|
+
}, rotationIntervalMs);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const stopAutoRotation = () => {
|
|
137
|
+
if (autoRotationTimer != null) {
|
|
138
|
+
clearInterval(autoRotationTimer);
|
|
139
|
+
autoRotationTimer = null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The pause button changes its label, not its position.
|
|
144
|
+
const syncPauseControl = () => {
|
|
145
|
+
const pauseBtn = getControl("pause");
|
|
146
|
+
|
|
147
|
+
carousel.toggleAttribute("data-paused", isPaused);
|
|
148
|
+
|
|
149
|
+
// Announce slide changes only while auto rotation is stopped.
|
|
150
|
+
const liveRegion = getLiveRegion();
|
|
151
|
+
|
|
152
|
+
if (isAutoRotationEnabled() && liveRegion) {
|
|
153
|
+
liveRegion.setAttribute("aria-live", isPaused ? "polite" : "off");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!pauseBtn) return;
|
|
157
|
+
|
|
158
|
+
const label = isPaused
|
|
159
|
+
? pauseBtn.dataset.resumeLabel
|
|
160
|
+
: pauseBtn.dataset.pauseLabel;
|
|
161
|
+
|
|
162
|
+
if (label) pauseBtn.setAttribute("aria-label", label);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Using any control stops auto rotation.
|
|
166
|
+
const pauseForInteraction = () => {
|
|
167
|
+
if (isPaused) return;
|
|
168
|
+
|
|
169
|
+
isPaused = true;
|
|
170
|
+
stopAutoRotation();
|
|
171
|
+
syncPauseControl();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const togglePause = () => {
|
|
175
|
+
isPaused = !isPaused;
|
|
176
|
+
|
|
177
|
+
if (isPaused) {
|
|
178
|
+
stopAutoRotation();
|
|
179
|
+
} else {
|
|
180
|
+
// If looped=false and we're on the last slide, toggling the
|
|
181
|
+
// resume button starts the slide show on the first slide again.
|
|
182
|
+
if (hasShowEnded()) scrollToIdx(0);
|
|
183
|
+
|
|
184
|
+
startAutoRotation();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
syncPauseControl();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
carousel.addEventListener(
|
|
191
|
+
"scroll",
|
|
192
|
+
(e) => {
|
|
193
|
+
if (e.target === getItemsContainer()) syncActiveState();
|
|
194
|
+
},
|
|
195
|
+
true,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Event listener is added on the carousel instead of the buttons
|
|
199
|
+
// because a patch can replace the buttons, which would remove the
|
|
200
|
+
// listeners.
|
|
201
|
+
carousel.addEventListener("click", (e) => {
|
|
202
|
+
const control = e.target.closest(
|
|
203
|
+
`.${baseClass}-previous, .${baseClass}-next,` +
|
|
204
|
+
` .${baseClass}-pause, .${baseClass}-pagination [role="tab"]`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (!control) return;
|
|
208
|
+
|
|
209
|
+
if (control.matches(`.${baseClass}-pause`)) {
|
|
210
|
+
togglePause();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pauseForInteraction();
|
|
215
|
+
|
|
216
|
+
if (control.matches(`.${baseClass}-previous`)) {
|
|
217
|
+
scrollToIdx(getTargetIdx(getCurrentIdx(), -1));
|
|
218
|
+
} else if (control.matches(`.${baseClass}-next`)) {
|
|
219
|
+
scrollToIdx(getTargetIdx(getCurrentIdx(), 1));
|
|
220
|
+
} else {
|
|
221
|
+
const idx = getTabs().indexOf(control);
|
|
222
|
+
|
|
223
|
+
if (idx >= 0) scrollToIdx(idx);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
carousel.addEventListener("keydown", (e) => {
|
|
228
|
+
const tab = e.target.closest(`.${baseClass}-pagination [role="tab"]`);
|
|
229
|
+
|
|
230
|
+
if (!tab) return;
|
|
231
|
+
|
|
232
|
+
const tabs = getTabs();
|
|
233
|
+
const nextIdx = targetIndex(e.key, getCurrentIdx(), getItems().length, {
|
|
234
|
+
wrap: isLoopEnabled,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (nextIdx === null) return;
|
|
238
|
+
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
pauseForInteraction();
|
|
241
|
+
scrollToIdx(nextIdx);
|
|
242
|
+
|
|
243
|
+
if (tabs[nextIdx]) tabs[nextIdx].focus();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Pause when hovering or focusing, resume when leaving, unless
|
|
247
|
+
// rotation is paused with the pause button.
|
|
248
|
+
|
|
249
|
+
let isHovered = false;
|
|
250
|
+
let isFocused = false;
|
|
251
|
+
|
|
252
|
+
const resume = () => {
|
|
253
|
+
if (!isHovered && !isFocused) startAutoRotation();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
carousel.addEventListener("pointerenter", () => {
|
|
257
|
+
isHovered = true;
|
|
258
|
+
stopAutoRotation();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
carousel.addEventListener("pointerleave", () => {
|
|
262
|
+
isHovered = false;
|
|
263
|
+
resume();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
carousel.addEventListener("focusin", () => {
|
|
267
|
+
isFocused = true;
|
|
268
|
+
stopAutoRotation();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
carousel.addEventListener("focusout", (e) => {
|
|
272
|
+
// focusout also fires while focus moves between controls inside
|
|
273
|
+
// the carousel, and that must not resume the rotation.
|
|
274
|
+
if (carousel.contains(e.relatedTarget)) return;
|
|
275
|
+
|
|
276
|
+
isFocused = false;
|
|
277
|
+
resume();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Initialize
|
|
281
|
+
syncActiveState();
|
|
282
|
+
syncPauseControl();
|
|
283
|
+
startAutoRotation();
|
|
284
|
+
|
|
285
|
+
const syncAfterUpdate = () => {
|
|
286
|
+
syncActiveState();
|
|
287
|
+
syncPauseControl();
|
|
288
|
+
|
|
289
|
+
// A patch can add or remove the pause button, and with it the
|
|
290
|
+
// rotation. Starting is a no-op while it runs or is paused.
|
|
291
|
+
if (isAutoRotationEnabled()) {
|
|
292
|
+
resume();
|
|
293
|
+
} else {
|
|
294
|
+
stopAutoRotation();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return { update: syncAfterUpdate, destroy: stopAutoRotation };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default {
|
|
302
|
+
mounted() {
|
|
303
|
+
this.instance = initCarousel(this.el);
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
// A patch can add or remove slides. Recompute which slide is active,
|
|
307
|
+
// and set the pause button label and the disabled buttons again.
|
|
308
|
+
updated() {
|
|
309
|
+
this.instance.update();
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
destroyed() {
|
|
313
|
+
this.instance.destroy();
|
|
314
|
+
},
|
|
315
|
+
};
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { stepIndex } from "../navigation.js";
|
|
2
|
+
|
|
3
|
+
const OPTIONS = '[role="option"]';
|
|
4
|
+
const GROUPS = '[role="group"]';
|
|
5
|
+
const SEPARATORS = "hr";
|
|
6
|
+
|
|
7
|
+
let cachedNoSelectionMessage;
|
|
8
|
+
|
|
9
|
+
const noSelectionMessage = () => {
|
|
10
|
+
if (cachedNoSelectionMessage === undefined) {
|
|
11
|
+
const select = document.createElement("select");
|
|
12
|
+
select.required = true;
|
|
13
|
+
cachedNoSelectionMessage = select.validationMessage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return cachedNoSelectionMessage;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function initCombobox(combobox) {
|
|
20
|
+
const input = combobox.querySelector('[role="combobox"]');
|
|
21
|
+
const listbox = document.getElementById(input.getAttribute("aria-controls"));
|
|
22
|
+
const toggle = combobox.querySelector("button[aria-expanded]");
|
|
23
|
+
const clear = combobox.querySelector("[data-clear]");
|
|
24
|
+
const hidden = combobox.querySelector('input[type="hidden"]');
|
|
25
|
+
|
|
26
|
+
const serverFiltering = combobox.dataset.filter === "server";
|
|
27
|
+
|
|
28
|
+
let activeIdx = null;
|
|
29
|
+
|
|
30
|
+
// Open state of the listbox, owned by the hook.
|
|
31
|
+
let isOpen = false;
|
|
32
|
+
|
|
33
|
+
// The display value for the current selection.
|
|
34
|
+
let displayValue = input.value;
|
|
35
|
+
|
|
36
|
+
// The value that the form submits, owned by the server.
|
|
37
|
+
let submitValue = hidden.value;
|
|
38
|
+
|
|
39
|
+
const options = () => Array.from(listbox.querySelectorAll(OPTIONS));
|
|
40
|
+
|
|
41
|
+
const visibleOptions = () => options().filter((option) => !option.hidden);
|
|
42
|
+
|
|
43
|
+
const isDisabled = (option) =>
|
|
44
|
+
option.getAttribute("aria-disabled") === "true";
|
|
45
|
+
|
|
46
|
+
const navigableOptions = () => visibleOptions().filter((o) => !isDisabled(o));
|
|
47
|
+
|
|
48
|
+
const freeTextOption = () => listbox.querySelector("[data-free-text]");
|
|
49
|
+
|
|
50
|
+
const optionValue = (option) =>
|
|
51
|
+
option.hasAttribute("data-free-text") ? null : option.dataset.value;
|
|
52
|
+
|
|
53
|
+
const label = (option) =>
|
|
54
|
+
option.querySelector("span")?.textContent.trim() ?? "";
|
|
55
|
+
|
|
56
|
+
const inert = () => input.readOnly || input.disabled;
|
|
57
|
+
|
|
58
|
+
const reflect = () => {
|
|
59
|
+
listbox.hidden = !isOpen;
|
|
60
|
+
input.setAttribute("aria-expanded", String(isOpen));
|
|
61
|
+
toggle?.setAttribute("aria-expanded", String(isOpen));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// An option is selected while it is active, following the ARIA Authoring
|
|
65
|
+
// Practices. If no option is active, the option with the submit value is
|
|
66
|
+
// selected.
|
|
67
|
+
const markSelected = () => {
|
|
68
|
+
const active = activeIdx === null ? null : navigableOptions()[activeIdx];
|
|
69
|
+
|
|
70
|
+
const selected =
|
|
71
|
+
active ?? options().find((option) => optionValue(option) === submitValue);
|
|
72
|
+
|
|
73
|
+
options().forEach((option) => {
|
|
74
|
+
option.setAttribute("aria-selected", String(option === selected));
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const setActive = (idx) => {
|
|
79
|
+
const visible = navigableOptions();
|
|
80
|
+
activeIdx = idx === null || visible.length === 0 ? null : idx;
|
|
81
|
+
|
|
82
|
+
if (activeIdx === null) {
|
|
83
|
+
input.removeAttribute("aria-activedescendant");
|
|
84
|
+
} else {
|
|
85
|
+
input.setAttribute("aria-activedescendant", visible[activeIdx].id);
|
|
86
|
+
visible[activeIdx].scrollIntoView({ block: "nearest" });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
markSelected();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Returns the index of the visible option for the submit value.
|
|
93
|
+
const submitValueIdx = () => {
|
|
94
|
+
const idx = navigableOptions().findIndex(
|
|
95
|
+
(option) => optionValue(option) === submitValue,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return idx === -1 ? null : idx;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const open = () => {
|
|
102
|
+
if (inert() || visibleOptions().length === 0) return;
|
|
103
|
+
|
|
104
|
+
isOpen = true;
|
|
105
|
+
reflect();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const close = () => {
|
|
109
|
+
isOpen = false;
|
|
110
|
+
reflect();
|
|
111
|
+
setActive(null);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const validate = () => {
|
|
115
|
+
const missing = submitValue === "";
|
|
116
|
+
const typed = input.value.trim() !== "";
|
|
117
|
+
|
|
118
|
+
input.setCustomValidity(
|
|
119
|
+
input.required && missing && typed ? noSelectionMessage() : "",
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const commit = (value, text) => {
|
|
124
|
+
displayValue = text;
|
|
125
|
+
submitValue = value;
|
|
126
|
+
hidden.value = value;
|
|
127
|
+
input.value = text;
|
|
128
|
+
|
|
129
|
+
if (clear) clear.hidden = value === "";
|
|
130
|
+
|
|
131
|
+
// Changing the value of the hidden input on its own does not trigger a
|
|
132
|
+
// `phx-change` event. We need to dispatch one manually.
|
|
133
|
+
hidden.dispatchEvent(new Event("input", { bubbles: true }));
|
|
134
|
+
validate();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const select = (option) => {
|
|
138
|
+
if (inert() || isDisabled(option)) return;
|
|
139
|
+
|
|
140
|
+
// Replace search term in the input value with the selected value. The free
|
|
141
|
+
// text option submits the typed term itself.
|
|
142
|
+
if (option.hasAttribute("data-free-text")) {
|
|
143
|
+
const term = input.value.trim();
|
|
144
|
+
commit(term, term);
|
|
145
|
+
} else {
|
|
146
|
+
commit(option.dataset.value, label(option));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Update the filtered options based on the new input value.
|
|
150
|
+
filter();
|
|
151
|
+
close();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const filter = () => {
|
|
155
|
+
const term = input.value.trim();
|
|
156
|
+
const needle = term.toLowerCase();
|
|
157
|
+
const free = freeTextOption();
|
|
158
|
+
|
|
159
|
+
// The display value of the current selection is not a search term and
|
|
160
|
+
// should not filter the options.
|
|
161
|
+
const searching = term !== displayValue.trim();
|
|
162
|
+
|
|
163
|
+
if (!serverFiltering) {
|
|
164
|
+
options().forEach((option) => {
|
|
165
|
+
if (option === free) return;
|
|
166
|
+
|
|
167
|
+
option.hidden =
|
|
168
|
+
searching &&
|
|
169
|
+
needle !== "" &&
|
|
170
|
+
!label(option).toLowerCase().includes(needle);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Hide groups without visible options.
|
|
174
|
+
listbox.querySelectorAll(GROUPS).forEach((group) => {
|
|
175
|
+
group.hidden = !Array.from(group.querySelectorAll(OPTIONS)).some(
|
|
176
|
+
(option) => !option.hidden,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Separators structure the full list only.
|
|
181
|
+
const filtering = searching && needle !== "";
|
|
182
|
+
|
|
183
|
+
listbox
|
|
184
|
+
.querySelectorAll(SEPARATORS)
|
|
185
|
+
.forEach((separator) => (separator.hidden = filtering));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (free) {
|
|
189
|
+
const exists = options().some(
|
|
190
|
+
(option) => option !== free && label(option).toLowerCase() === needle,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
free.hidden = !searching || term === "" || exists;
|
|
194
|
+
free.querySelector("span + span").textContent = free.hidden ? "" : term;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Handles the `Up` (offset=-1) and `Down` (offset=1) keys.
|
|
199
|
+
const move = (offset) => {
|
|
200
|
+
// If the listbox is closed, open it and move to the index of the submit
|
|
201
|
+
// value or to the first or last item depending on the offset.
|
|
202
|
+
if (!isOpen) {
|
|
203
|
+
open();
|
|
204
|
+
|
|
205
|
+
// The listbox is not opened if the input is disabled or readonly, so we
|
|
206
|
+
// need to check the state after calling `open()`.
|
|
207
|
+
if (isOpen) {
|
|
208
|
+
const from = submitValueIdx();
|
|
209
|
+
|
|
210
|
+
// If there is a submit value: Move to that index.
|
|
211
|
+
// If offset=-1 (arrow up): Move to the last index.
|
|
212
|
+
// If offset=1 (arrow down): Move to the first index.
|
|
213
|
+
setActive(
|
|
214
|
+
from === null
|
|
215
|
+
? offset > 0
|
|
216
|
+
? 0
|
|
217
|
+
: navigableOptions().length - 1
|
|
218
|
+
: from,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// If the listbox is already open, move by one from the active index, or
|
|
226
|
+
// if there is no active index, move to the first or last item depending
|
|
227
|
+
// on the given offset.
|
|
228
|
+
|
|
229
|
+
const total = navigableOptions().length;
|
|
230
|
+
if (total === 0) return;
|
|
231
|
+
|
|
232
|
+
// If the listbox is open and there is no active option, only the selected
|
|
233
|
+
// option is marked in the list. Move relative to it in that case.
|
|
234
|
+
const from =
|
|
235
|
+
activeIdx === null
|
|
236
|
+
? (submitValueIdx() ?? (offset > 0 ? -1 : total))
|
|
237
|
+
: activeIdx;
|
|
238
|
+
|
|
239
|
+
setActive(stepIndex(from, offset, total));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
input.addEventListener("keydown", (e) => {
|
|
243
|
+
if (inert()) return;
|
|
244
|
+
|
|
245
|
+
switch (e.key) {
|
|
246
|
+
case "ArrowDown":
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
if (e.altKey) open();
|
|
249
|
+
else move(1);
|
|
250
|
+
break;
|
|
251
|
+
case "ArrowUp":
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
if (e.altKey) close();
|
|
254
|
+
else move(-1);
|
|
255
|
+
break;
|
|
256
|
+
case "Enter":
|
|
257
|
+
if (!isOpen || activeIdx === null) return;
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
select(navigableOptions()[activeIdx]);
|
|
260
|
+
break;
|
|
261
|
+
case "Tab":
|
|
262
|
+
if (isOpen && activeIdx !== null) select(navigableOptions()[activeIdx]);
|
|
263
|
+
break;
|
|
264
|
+
case "Escape": {
|
|
265
|
+
let handled = false;
|
|
266
|
+
|
|
267
|
+
// If the listbox is open or if the display value is a search term
|
|
268
|
+
// (as opposed to the display value of the selection), reset the
|
|
269
|
+
// input value to the display value of the selection and reset the
|
|
270
|
+
// option filter.
|
|
271
|
+
// If the listbox is closed and there is a selection, reset it.
|
|
272
|
+
if (isOpen || input.value.trim() !== displayValue.trim()) {
|
|
273
|
+
input.value = displayValue;
|
|
274
|
+
filter();
|
|
275
|
+
close();
|
|
276
|
+
handled = true;
|
|
277
|
+
} else if (input.value !== "" || submitValue !== "") {
|
|
278
|
+
commit("", "");
|
|
279
|
+
filter();
|
|
280
|
+
markSelected();
|
|
281
|
+
handled = true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Only prevent default and stop propagation if the `Escape` key had an
|
|
285
|
+
// effect on the component.
|
|
286
|
+
if (handled) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
e.stopPropagation();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
input.addEventListener("input", () => {
|
|
297
|
+
filter();
|
|
298
|
+
setActive(null);
|
|
299
|
+
validate();
|
|
300
|
+
|
|
301
|
+
if (visibleOptions().length === 0) close();
|
|
302
|
+
else open();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
clear?.addEventListener("click", () => {
|
|
306
|
+
if (inert()) return;
|
|
307
|
+
|
|
308
|
+
commit("", "");
|
|
309
|
+
filter();
|
|
310
|
+
markSelected();
|
|
311
|
+
input.focus();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
toggle?.addEventListener("click", () => {
|
|
315
|
+
if (isOpen) close();
|
|
316
|
+
else open();
|
|
317
|
+
|
|
318
|
+
input.focus();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Keep focus in the input, so that typing keeps working when the listbox is
|
|
322
|
+
// open.
|
|
323
|
+
listbox.addEventListener("mousedown", (e) => e.preventDefault());
|
|
324
|
+
|
|
325
|
+
listbox.addEventListener("click", (e) => {
|
|
326
|
+
const option = e.target.closest(OPTIONS);
|
|
327
|
+
if (option && !option.hidden) select(option);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
combobox.addEventListener("focusout", (e) => {
|
|
331
|
+
if (combobox.contains(e.relatedTarget)) return;
|
|
332
|
+
close();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
filter();
|
|
336
|
+
markSelected();
|
|
337
|
+
validate();
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
// A patch restores the listbox's `hidden` attribute and may have replaced
|
|
341
|
+
// the options. Re-apply open state with `reflect()` and apply filters on
|
|
342
|
+
// the potentially new options. Reset active index, since it may be stale
|
|
343
|
+
// if the options changed.
|
|
344
|
+
update() {
|
|
345
|
+
// The server owns the hidden input value. Set the internal submit value
|
|
346
|
+
// to the value of the hidden input.
|
|
347
|
+
submitValue = hidden.value;
|
|
348
|
+
validate();
|
|
349
|
+
|
|
350
|
+
// The server only owns the display value while the input is not focused.
|
|
351
|
+
// LiveView does not patch focused inputs.
|
|
352
|
+
if (document.activeElement !== input) displayValue = input.value;
|
|
353
|
+
|
|
354
|
+
filter();
|
|
355
|
+
setActive(null);
|
|
356
|
+
|
|
357
|
+
if (isOpen && visibleOptions().length === 0) close();
|
|
358
|
+
else reflect();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export default {
|
|
364
|
+
mounted() {
|
|
365
|
+
this.instance = initCombobox(this.el);
|
|
366
|
+
},
|
|
367
|
+
updated() {
|
|
368
|
+
this.instance.update();
|
|
369
|
+
},
|
|
370
|
+
};
|