@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.
@@ -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
+ };