@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,77 @@
1
+ // Native `<dialog>` behaviour, with the parts browsers do not all have yet
2
+ // filled in. Each fill is behind a feature test, so it drops out on its own as
3
+ // support arrives, and the markup is already the markup we want.
4
+ const hasCommand = () => "command" in HTMLButtonElement.prototype;
5
+ const hasClosedBy = () => "closedBy" in HTMLDialogElement.prototype;
6
+
7
+ // `execJS` is the one thing a framework has to supply: the dialog runs the
8
+ // caller's `on_cancel` when it closes, and only the framework knows how.
9
+ export function initDialog(dialog, { execJS = () => {} } = {}) {
10
+ let invoke;
11
+
12
+ const dismissable = () => dialog.getAttribute("closedby") === "any";
13
+
14
+ // Dispatched by `Doggo.show_modal/2`.
15
+ dialog.addEventListener("doggo:open", () => {
16
+ if (!dialog.open) dialog.showModal();
17
+ });
18
+
19
+ // Dispatched by `Doggo.hide_modal/2`.
20
+ dialog.addEventListener("doggo:close", () => dialog.close());
21
+
22
+ // The browser closes the dialog itself, so `on_cancel` runs from here
23
+ // rather than from the control that closed it.
24
+ dialog.addEventListener("close", () => {
25
+ const command = dialog.getAttribute("data-cancel");
26
+
27
+ if (command) execJS(dialog, command);
28
+ });
29
+
30
+ if (!hasCommand()) {
31
+ // On the document, not on the dialog.
32
+ invoke = (e) => {
33
+ const button = e.target.closest("button[commandfor]");
34
+
35
+ if (!button || button.getAttribute("commandfor") !== dialog.id) return;
36
+
37
+ const command = button.getAttribute("command");
38
+
39
+ if (command === "show-modal" && !dialog.open) dialog.showModal();
40
+ else if (command === "close") dialog.close();
41
+ };
42
+
43
+ document.addEventListener("click", invoke);
44
+ }
45
+
46
+ if (!hasClosedBy()) {
47
+ // `closedby="none"` has to hold Escape as well, which is the one part of
48
+ // the attribute that native `<dialog>` does not give us anyway.
49
+ dialog.addEventListener("cancel", (e) => {
50
+ if (!dismissable()) e.preventDefault();
51
+ });
52
+
53
+ // A click on the backdrop reaches the dialog itself, never a child, so
54
+ // the target is the test for being outside.
55
+ dialog.addEventListener("click", (e) => {
56
+ if (dismissable() && e.target === dialog) dialog.close();
57
+ });
58
+ }
59
+
60
+ return {
61
+ destroy() {
62
+ if (invoke) document.removeEventListener("click", invoke);
63
+ },
64
+ };
65
+ }
66
+
67
+ export default {
68
+ mounted() {
69
+ this.instance = initDialog(this.el, {
70
+ execJS: (el, command) => this.liveSocket.execJS(el, command),
71
+ });
72
+ },
73
+
74
+ destroyed() {
75
+ this.instance.destroy();
76
+ },
77
+ };
@@ -0,0 +1,138 @@
1
+ import { searchIndex, setRovingTabindex, targetIndex } from "../navigation.js";
2
+
3
+ const ITEMS =
4
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
5
+
6
+ const ORIENTATIONS = { menubar: "horizontal", menu: "vertical" };
7
+
8
+ // How long the characters a user types stay one search. The ARIA Authoring
9
+ // Practices describe this for type-ahead, and 500ms is taken from their
10
+ // example.
11
+ const SEARCH_MS = 500;
12
+
13
+ export function initMenu(menu) {
14
+ const role = menu.getAttribute("role");
15
+
16
+ // Read on demand, and only the items of this menu: `menu_group` and
17
+ // `menu_item_radio_group` nest, and a submenu has its own hook.
18
+ const getItems = () =>
19
+ Array.from(menu.querySelectorAll(ITEMS)).filter(
20
+ (item) => item.closest('[role="menu"], [role="menubar"]') === menu,
21
+ );
22
+
23
+ let activeIdx = 0;
24
+ let search = "";
25
+ let searchTimeout;
26
+
27
+ const setActive = (idx) => {
28
+ activeIdx = idx;
29
+ setRovingTabindex(getItems(), idx);
30
+ };
31
+
32
+ const moveTo = (idx) => {
33
+ setActive(idx);
34
+ getItems()[idx].focus();
35
+ };
36
+
37
+ // Whatever the user focused becomes the tab stop.
38
+ menu.addEventListener("focusin", (e) => {
39
+ const idx = getItems().indexOf(e.target.closest(ITEMS));
40
+
41
+ if (idx >= 0 && idx !== activeIdx) setActive(idx);
42
+ });
43
+
44
+ const typeAhead = (key) => {
45
+ clearTimeout(searchTimeout);
46
+ search += key;
47
+ searchTimeout = setTimeout(() => (search = ""), SEARCH_MS);
48
+
49
+ // The same character typed again walks through the items starting with
50
+ // it, rather than searching for a repeated string. Any other second
51
+ // character refines the search, so it starts at the current item instead
52
+ // of after it.
53
+ const repeated = [...search].every((char) => char === search[0]);
54
+ const needle = repeated ? search[0] : search;
55
+ const from = repeated ? activeIdx : activeIdx - 1;
56
+
57
+ const labels = getItems().map((item) => item.textContent);
58
+ const idx = searchIndex(labels, needle, from);
59
+
60
+ if (idx !== null) moveTo(idx);
61
+ };
62
+
63
+ // Closing belongs to the button that opened the menu, which the menu finds
64
+ // through the `aria-controls` pointing at it. Compared rather than put in a
65
+ // selector, because the id is the caller's and may hold characters that
66
+ // would change what the selector matches.
67
+ const close = () => {
68
+ const button = Array.from(
69
+ document.querySelectorAll("[aria-controls]"),
70
+ ).find((el) => el.getAttribute("aria-controls") === menu.id);
71
+
72
+ if (!button) return false;
73
+
74
+ menu.setAttribute("hidden", "");
75
+ button.setAttribute("aria-expanded", "false");
76
+ button.focus();
77
+
78
+ return true;
79
+ };
80
+
81
+ menu.addEventListener("keydown", (e) => {
82
+ const items = getItems();
83
+ const currentIdx = items.indexOf(e.target.closest(ITEMS));
84
+
85
+ if (currentIdx < 0) return;
86
+
87
+ if (e.key === "Escape") {
88
+ // One press closes one thing: a menu inside a dialog must not close the
89
+ // dialog as well. When nothing closed, the key event doesn't belong to
90
+ // the component.
91
+ if (close()) {
92
+ e.preventDefault();
93
+ e.stopPropagation();
94
+ }
95
+
96
+ return;
97
+ }
98
+
99
+ const nextIdx = targetIndex(e.key, currentIdx, items.length, {
100
+ orientation: ORIENTATIONS[role],
101
+ });
102
+
103
+ if (nextIdx !== null) {
104
+ e.preventDefault();
105
+ moveTo(nextIdx);
106
+ return;
107
+ }
108
+
109
+ // A single printable character, and no modifier, is a search rather than
110
+ // a shortcut.
111
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
112
+ e.preventDefault();
113
+ typeAhead(e.key);
114
+ }
115
+ });
116
+
117
+ const restoreTabStop = () => {
118
+ const items = getItems();
119
+
120
+ if (items.length > 0) {
121
+ setActive(Math.min(activeIdx, items.length - 1));
122
+ }
123
+ };
124
+
125
+ restoreTabStop();
126
+
127
+ return { update: restoreTabStop };
128
+ }
129
+
130
+ export default {
131
+ mounted() {
132
+ this.instance = initMenu(this.el);
133
+ },
134
+
135
+ updated() {
136
+ this.instance.update();
137
+ },
138
+ };
@@ -0,0 +1,35 @@
1
+ export function initMenuButton(button) {
2
+ const getMenu = () =>
3
+ document.getElementById(button.getAttribute("aria-controls"));
4
+
5
+ // Opening from the keyboard puts the focus on an item straight away, which
6
+ // is the difference from opening with a pointer.
7
+ const open = (position) => {
8
+ const menu = getMenu();
9
+
10
+ if (!menu) return;
11
+
12
+ menu.removeAttribute("hidden");
13
+ button.setAttribute("aria-expanded", "true");
14
+
15
+ const items = menu.querySelectorAll(
16
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
17
+ );
18
+ const item = position === "last" ? items[items.length - 1] : items[0];
19
+
20
+ if (item) item.focus();
21
+ };
22
+
23
+ button.addEventListener("keydown", (e) => {
24
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
25
+
26
+ e.preventDefault();
27
+ open(e.key === "ArrowDown" ? "first" : "last");
28
+ });
29
+ }
30
+
31
+ export default {
32
+ mounted() {
33
+ initMenuButton(this.el);
34
+ },
35
+ };
@@ -0,0 +1,100 @@
1
+ // The property the caller's stylesheet reads.
2
+ const POSITION = "--split-pane-position";
3
+
4
+ const STEP = 1;
5
+ const SHIFT_STEP = 10;
6
+
7
+ export function initSplitPane(pane) {
8
+ const separator = pane.querySelector(':scope > [role="separator"]');
9
+
10
+ if (!separator) return { update: () => {} };
11
+
12
+ const numberOf = (name) => Number(separator.getAttribute(name));
13
+ const min = () => numberOf("aria-valuemin");
14
+ const max = () => numberOf("aria-valuemax");
15
+ const isVertical = () =>
16
+ separator.getAttribute("aria-orientation") === "vertical";
17
+
18
+ let position = numberOf("aria-valuenow");
19
+
20
+ // Where Enter goes back to: the last position that was not the minimum.
21
+ let expandedSize = null;
22
+
23
+ // Written through the CSSOM, which is not blocked under a strict content
24
+ // security policy.
25
+ const apply = (next) => {
26
+ position = Math.min(Math.max(Math.round(next), min()), max());
27
+ if (position > min()) expandedSize = position;
28
+
29
+ pane.style.setProperty(POSITION, `${position}%`);
30
+ separator.setAttribute("aria-valuenow", String(position));
31
+ };
32
+
33
+ // A vertical separator stands between two panes side by side, so it moves
34
+ // along the x axis, and a horizontal one along the y axis.
35
+ const fromPointer = (event) => {
36
+ const box = pane.getBoundingClientRect();
37
+
38
+ return isVertical()
39
+ ? ((event.clientX - box.left) / box.width) * 100
40
+ : ((event.clientY - box.top) / box.height) * 100;
41
+ };
42
+
43
+ separator.addEventListener("pointerdown", (e) => {
44
+ // Capture keeps the drag alive while the pointer is outside the
45
+ // separator, and it keeps the drag from selecting text. Preventing the
46
+ // default would take the focus off the click, and the keys have to work
47
+ // right after a drag.
48
+ separator.setPointerCapture(e.pointerId);
49
+ });
50
+
51
+ separator.addEventListener("pointermove", (e) => {
52
+ if (!separator.hasPointerCapture(e.pointerId)) return;
53
+
54
+ apply(fromPointer(e));
55
+ });
56
+
57
+ separator.addEventListener("keydown", (e) => {
58
+ const step = e.shiftKey ? SHIFT_STEP : STEP;
59
+ const [less, more] = isVertical()
60
+ ? ["ArrowLeft", "ArrowRight"]
61
+ : ["ArrowUp", "ArrowDown"];
62
+
63
+ if (e.key === more) {
64
+ apply(position + step);
65
+ } else if (e.key === less) {
66
+ apply(position - step);
67
+ } else if (e.key === "Home") {
68
+ apply(min());
69
+ } else if (e.key === "End") {
70
+ apply(max());
71
+ } else if (e.key === "Enter") {
72
+ if (position > min()) {
73
+ apply(min());
74
+ } else if (expandedSize !== null) {
75
+ apply(expandedSize);
76
+ }
77
+ } else {
78
+ return;
79
+ }
80
+
81
+ e.preventDefault();
82
+ });
83
+
84
+ const restore = () => apply(position);
85
+ restore();
86
+
87
+ return { update: restore };
88
+ }
89
+
90
+ export default {
91
+ mounted() {
92
+ this.instance = initSplitPane(this.el);
93
+ },
94
+
95
+ // A patch rewrites the separator's `aria-valuenow` and can drop the custom
96
+ // property with it, so the position the user dragged to is applied again.
97
+ updated() {
98
+ this.instance.update();
99
+ },
100
+ };
@@ -0,0 +1,80 @@
1
+ import { clamp, selectTab, targetIndex } from "../navigation.js";
2
+
3
+ export function initTabs(tabs) {
4
+ const getTabs = () =>
5
+ Array.from(
6
+ tabs.querySelectorAll(':scope > [role="tablist"] > [role="tab"]'),
7
+ );
8
+
9
+ const getPanels = () =>
10
+ Array.from(tabs.querySelectorAll(':scope > [role="tabpanel"]'));
11
+
12
+ let selectedIdx = 0;
13
+
14
+ const select = (idx) => {
15
+ selectedIdx = idx;
16
+
17
+ selectTab(getTabs(), idx);
18
+
19
+ getPanels().forEach((panel, i) => {
20
+ if (i === idx) {
21
+ panel.removeAttribute("hidden");
22
+ } else {
23
+ panel.setAttribute("hidden", "");
24
+ }
25
+ });
26
+ };
27
+
28
+ tabs.addEventListener("click", (e) => {
29
+ const idx = getTabs().indexOf(e.target.closest('[role="tab"]'));
30
+
31
+ if (idx >= 0) select(idx);
32
+ });
33
+
34
+ // Dispatched by `Doggo.show_tab/3`. One-based index.
35
+ tabs.addEventListener("doggo:show-tab", (e) => {
36
+ const idx = e.detail.index - 1;
37
+
38
+ if (idx >= 0 && idx < getTabs().length) select(idx);
39
+ });
40
+
41
+ tabs.addEventListener("keydown", (e) => {
42
+ const currentIdx = getTabs().indexOf(e.target.closest('[role="tab"]'));
43
+
44
+ if (currentIdx < 0) return;
45
+
46
+ const vertical =
47
+ tabs
48
+ .querySelector('[role="tablist"]')
49
+ .getAttribute("aria-orientation") === "vertical";
50
+
51
+ const nextIdx = targetIndex(e.key, currentIdx, getTabs().length, {
52
+ orientation: vertical ? "vertical" : "horizontal",
53
+ });
54
+
55
+ if (nextIdx === null) return;
56
+
57
+ e.preventDefault();
58
+ select(nextIdx);
59
+ getTabs()[nextIdx].focus();
60
+ });
61
+
62
+ const restoreSelection = () => {
63
+ const total = getTabs().length;
64
+
65
+ if (total > 0) select(clamp(selectedIdx, total));
66
+ };
67
+
68
+ return { update: restoreSelection };
69
+ }
70
+
71
+ export default {
72
+ mounted() {
73
+ this.instance = initTabs(this.el);
74
+ },
75
+
76
+ // Restore selected tab on patch.
77
+ updated() {
78
+ this.instance.update();
79
+ },
80
+ };
@@ -0,0 +1,100 @@
1
+ import { clamp, setRovingTabindex, targetIndex } from "../navigation.js";
2
+
3
+ // Controls the toolbar manages
4
+ const CONTROLS = 'a[href], button, input, select, textarea, [role="button"]';
5
+
6
+ // Controls that use the arrow keys themselves
7
+ const KEEPS_ARROWS =
8
+ 'textarea, select, [role="slider"], [role="spinbutton"], [role="combobox"], [role="listbox"], [role="textbox"]';
9
+
10
+ const TEXT_INPUTS = [
11
+ "text",
12
+ "search",
13
+ "url",
14
+ "tel",
15
+ "email",
16
+ "password",
17
+ "number",
18
+ "date",
19
+ "datetime-local",
20
+ "month",
21
+ "time",
22
+ "week",
23
+ "range",
24
+ ];
25
+
26
+ function keepsArrows(el) {
27
+ if (el.matches(KEEPS_ARROWS)) return true;
28
+
29
+ return el.tagName === "INPUT" && TEXT_INPUTS.includes(el.type);
30
+ }
31
+
32
+ function isDisabled(el) {
33
+ return el.disabled || el.getAttribute("aria-disabled") === "true";
34
+ }
35
+
36
+ export function initToolbar(toolbar) {
37
+ // Read on demand: a patch can add and remove controls.
38
+ const getControls = () =>
39
+ Array.from(toolbar.querySelectorAll(CONTROLS)).filter(
40
+ (control) => !isDisabled(control),
41
+ );
42
+
43
+ const isVertical = () =>
44
+ toolbar.getAttribute("aria-orientation") === "vertical";
45
+
46
+ let activeIdx = 0;
47
+
48
+ const setActive = (idx) => {
49
+ activeIdx = idx;
50
+
51
+ setRovingTabindex(getControls(), idx);
52
+ };
53
+
54
+ toolbar.addEventListener("focusin", (e) => {
55
+ const idx = getControls().indexOf(e.target.closest(CONTROLS));
56
+
57
+ if (idx >= 0 && idx !== activeIdx) setActive(idx);
58
+ });
59
+
60
+ toolbar.addEventListener("keydown", (e) => {
61
+ const control = e.target.closest(CONTROLS);
62
+
63
+ if (!control || keepsArrows(control)) return;
64
+
65
+ const controls = getControls();
66
+ const currentIdx = controls.indexOf(control);
67
+
68
+ if (currentIdx < 0) return;
69
+
70
+ const nextIdx = targetIndex(e.key, currentIdx, controls.length, {
71
+ orientation: isVertical() ? "vertical" : "horizontal",
72
+ });
73
+
74
+ if (nextIdx === null) return;
75
+
76
+ e.preventDefault();
77
+ controls[nextIdx].focus();
78
+ });
79
+
80
+ const restoreTabStop = () => {
81
+ const total = getControls().length;
82
+
83
+ if (total > 0) setActive(clamp(activeIdx, total));
84
+ };
85
+
86
+ restoreTabStop();
87
+
88
+ return { update: restoreTabStop };
89
+ }
90
+
91
+ export default {
92
+ mounted() {
93
+ this.instance = initToolbar(this.el);
94
+ },
95
+
96
+ // A patch renders every control in the tab order again.
97
+ updated() {
98
+ this.instance.update();
99
+ },
100
+ };
@@ -0,0 +1,48 @@
1
+ export function initTooltip(tooltip) {
2
+ const isActive = () =>
3
+ tooltip.matches(":hover") || tooltip.matches(":focus-within");
4
+
5
+ const isShown = () => isActive() && !tooltip.hasAttribute("data-dismissed");
6
+
7
+ // A dismissed tooltip stays dismissed until the pointer and the focus have
8
+ // both left.
9
+ const clearWhenLeft = () =>
10
+ setTimeout(() => {
11
+ if (!isActive()) tooltip.removeAttribute("data-dismissed");
12
+ }, 0);
13
+
14
+ const dismiss = (e) => {
15
+ if (e.key !== "Escape" || !isShown()) return;
16
+
17
+ tooltip.setAttribute("data-dismissed", "");
18
+
19
+ // Stop propagation; a tooltip inside a dialog must not close the dialog
20
+ // as well.
21
+ e.stopPropagation();
22
+ };
23
+
24
+ // The listener sits on the window rather than on the tooltip, because a
25
+ // tooltip shown by hovering has the focus somewhere else entirely, and a
26
+ // key press goes to the focused element. WCAG 2.2 1.4.13 asks for that case
27
+ // to be dismissible too.
28
+ window.addEventListener("keydown", dismiss, true);
29
+
30
+ tooltip.addEventListener("focusout", clearWhenLeft);
31
+ tooltip.addEventListener("pointerleave", clearWhenLeft);
32
+
33
+ return {
34
+ destroy() {
35
+ window.removeEventListener("keydown", dismiss, true);
36
+ },
37
+ };
38
+ }
39
+
40
+ export default {
41
+ mounted() {
42
+ this.instance = initTooltip(this.el);
43
+ },
44
+
45
+ destroyed() {
46
+ this.instance.destroy();
47
+ },
48
+ };