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