@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,197 @@
1
+ import { clamp, searchIndex, targetIndex } from "../navigation.js";
2
+
3
+ const SEARCH_MS = 500;
4
+
5
+ const isBranch = (item) => item.hasAttribute("aria-expanded");
6
+ const isExpanded = (item) => item.getAttribute("aria-expanded") === "true";
7
+ const groupOf = (item) => item.querySelector(':scope > [role="group"]');
8
+ const labelOf = (item) =>
9
+ item.querySelector(':scope > :not(button):not([role="group"])');
10
+ const parentOf = (item) =>
11
+ item.parentElement.closest('[role="treeitem"]') || null;
12
+
13
+ export function initTree(tree) {
14
+ // Visible items only. A collapsed branch takes its descendants out of the
15
+ // sequence.
16
+ const getItems = () =>
17
+ Array.from(tree.querySelectorAll('[role="treeitem"]')).filter(
18
+ (item) => !item.closest('[role="group"][hidden]'),
19
+ );
20
+
21
+ // The hook owns the expanded state. A patch re-renders the server's
22
+ // version, so it is remembered by position and applied again.
23
+ const pathOf = (item) => {
24
+ const path = [];
25
+
26
+ for (let node = item; node; node = parentOf(node)) {
27
+ const siblings = Array.from(
28
+ node.parentElement.querySelectorAll(':scope > [role="treeitem"]'),
29
+ );
30
+ path.unshift(siblings.indexOf(node));
31
+ }
32
+
33
+ return path.join("-");
34
+ };
35
+
36
+ const expandedPaths = new Set();
37
+ let activeIdx = 0;
38
+ let search = "";
39
+ let searchTimeout;
40
+
41
+ // Every item, not just the visible ones: a hidden item would keep the tab
42
+ // index it had before the collapse.
43
+ const setActive = (idx) => {
44
+ activeIdx = idx;
45
+
46
+ const active = getItems()[idx];
47
+
48
+ for (const item of tree.querySelectorAll('[role="treeitem"]')) {
49
+ item.setAttribute("tabindex", item === active ? "0" : "-1");
50
+ }
51
+ };
52
+
53
+ const moveTo = (idx) => {
54
+ setActive(idx);
55
+ getItems()[idx].focus();
56
+ };
57
+
58
+ const setExpanded = (item, expanded) => {
59
+ item.setAttribute("aria-expanded", expanded ? "true" : "false");
60
+
61
+ const group = groupOf(item);
62
+
63
+ if (group) {
64
+ if (expanded) {
65
+ group.removeAttribute("hidden");
66
+ } else {
67
+ group.setAttribute("hidden", "");
68
+ }
69
+ }
70
+
71
+ const path = pathOf(item);
72
+
73
+ if (expanded) {
74
+ expandedPaths.add(path);
75
+ } else {
76
+ expandedPaths.delete(path);
77
+ }
78
+ };
79
+
80
+ // Only the caret toggles. A click on the label is the caller's, for
81
+ // selection. The caret is `aria-hidden`: the arrow keys already do this.
82
+ tree.addEventListener("click", (e) => {
83
+ const item = e.target.closest('[role="treeitem"]');
84
+
85
+ if (!item || !isBranch(item)) return;
86
+
87
+ // The item's own caret, not one a caller put in the label.
88
+ const caret = item.querySelector(":scope > button");
89
+
90
+ if (!caret || !caret.contains(e.target)) return;
91
+
92
+ setExpanded(item, !isExpanded(item));
93
+ item.focus();
94
+ });
95
+
96
+ tree.addEventListener("focusin", (e) => {
97
+ const idx = getItems().indexOf(e.target.closest('[role="treeitem"]'));
98
+
99
+ if (idx >= 0 && idx !== activeIdx) setActive(idx);
100
+ });
101
+
102
+ const typeAhead = (key) => {
103
+ clearTimeout(searchTimeout);
104
+ search += key;
105
+ searchTimeout = setTimeout(() => (search = ""), SEARCH_MS);
106
+
107
+ const repeated = [...search].every((char) => char === search[0]);
108
+ const items = getItems();
109
+ // The item's own label, but not nested items.
110
+ const labels = items.map((item) => labelOf(item)?.textContent ?? "");
111
+ const idx = searchIndex(
112
+ labels,
113
+ repeated ? search[0] : search,
114
+ repeated ? activeIdx : activeIdx - 1,
115
+ );
116
+
117
+ if (idx !== null) moveTo(idx);
118
+ };
119
+
120
+ tree.addEventListener("keydown", (e) => {
121
+ const items = getItems();
122
+ const item = e.target.closest('[role="treeitem"]');
123
+ const currentIdx = items.indexOf(item);
124
+
125
+ if (currentIdx < 0) return;
126
+
127
+ // Right opens a branch, then walks into it. Left closes it, then walks out.
128
+ if (e.key === "ArrowRight") {
129
+ e.preventDefault();
130
+
131
+ if (isBranch(item) && !isExpanded(item)) {
132
+ setExpanded(item, true);
133
+ } else if (isBranch(item)) {
134
+ const child = groupOf(item).querySelector('[role="treeitem"]');
135
+
136
+ if (child) moveTo(getItems().indexOf(child));
137
+ }
138
+
139
+ return;
140
+ }
141
+
142
+ if (e.key === "ArrowLeft") {
143
+ e.preventDefault();
144
+
145
+ if (isBranch(item) && isExpanded(item)) {
146
+ setExpanded(item, false);
147
+ } else {
148
+ const parent = parentOf(item);
149
+
150
+ if (parent) moveTo(getItems().indexOf(parent));
151
+ }
152
+
153
+ return;
154
+ }
155
+
156
+ const nextIdx = targetIndex(e.key, currentIdx, items.length, {
157
+ orientation: "vertical",
158
+ });
159
+
160
+ if (nextIdx !== null) {
161
+ e.preventDefault();
162
+ moveTo(nextIdx);
163
+ return;
164
+ }
165
+
166
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
167
+ e.preventDefault();
168
+ typeAhead(e.key);
169
+ }
170
+ });
171
+
172
+ const restore = () => {
173
+ for (const item of tree.querySelectorAll('[role="treeitem"]')) {
174
+ if (isBranch(item) && expandedPaths.has(pathOf(item))) {
175
+ setExpanded(item, true);
176
+ }
177
+ }
178
+
179
+ const items = getItems();
180
+
181
+ if (items.length > 0) setActive(clamp(activeIdx, items.length));
182
+ };
183
+
184
+ restore();
185
+
186
+ return { update: restore };
187
+ }
188
+
189
+ export default {
190
+ mounted() {
191
+ this.instance = initTree(this.el);
192
+ },
193
+
194
+ updated() {
195
+ this.instance.update();
196
+ },
197
+ };
@@ -0,0 +1,11 @@
1
+ export { default as Accordion, initAccordion } from "./hooks/accordion.js";
2
+ export { default as Carousel, initCarousel } from "./hooks/carousel.js";
3
+ export { default as Combobox, initCombobox } from "./hooks/combobox.js";
4
+ export { default as Dialog, initDialog } from "./hooks/dialog.js";
5
+ export { default as Menu, initMenu } from "./hooks/menu.js";
6
+ export { default as MenuButton, initMenuButton } from "./hooks/menu_button.js";
7
+ export { default as SplitPane, initSplitPane } from "./hooks/split_pane.js";
8
+ export { default as Tabs, initTabs } from "./hooks/tabs.js";
9
+ export { default as Tree, initTree } from "./hooks/tree.js";
10
+ export { default as Toolbar, initToolbar } from "./hooks/toolbar.js";
11
+ export { default as Tooltip, initTooltip } from "./hooks/tooltip.js";
@@ -0,0 +1,69 @@
1
+ // Keeps an index within a list of `total` elements.
2
+ export function clamp(idx, total) {
3
+ return Math.min(Math.max(idx, 0), total - 1);
4
+ }
5
+
6
+ // Adds `offset` to `idx` while staying within the total range. Wraps around at
7
+ // the ends if `wrap` is true.
8
+ export function stepIndex(idx, offset, total, { wrap = true } = {}) {
9
+ if (wrap) return (idx + offset + total) % total;
10
+
11
+ return clamp(idx + offset, total);
12
+ }
13
+
14
+ // Returns the index the key moves to, or `null` if the key is not an arrow,
15
+ // Home or End. The caller passes the current index, because each hook gets it
16
+ // from somewhere else: the carousel from the slide in view, tabs and toolbars
17
+ // from the focused element.
18
+ export function targetIndex(
19
+ key,
20
+ currentIdx,
21
+ total,
22
+ { orientation = "horizontal", wrap = true } = {},
23
+ ) {
24
+ const [previous, next] =
25
+ orientation === "vertical"
26
+ ? ["ArrowUp", "ArrowDown"]
27
+ : ["ArrowLeft", "ArrowRight"];
28
+
29
+ if (key === next) return stepIndex(currentIdx, 1, total, { wrap });
30
+ if (key === previous) return stepIndex(currentIdx, -1, total, { wrap });
31
+ if (key === "Home") return 0;
32
+ if (key === "End") return total - 1;
33
+
34
+ return null;
35
+ }
36
+
37
+ // Sets `tabindex` to 0 on the element at `activeIdx` and to -1 on the rest, so
38
+ // that the list is one tab stop.
39
+ export function setRovingTabindex(elements, activeIdx) {
40
+ elements.forEach((el, idx) => {
41
+ el.setAttribute("tabindex", idx === activeIdx ? "0" : "-1");
42
+ });
43
+ }
44
+
45
+ // Sets `aria-selected` to true on the tab at `activeIdx` and to false on the
46
+ // rest, and sets `tabindex` the same way as above. Used by `tabs` and by the
47
+ // carousel pagination, which is also a tab list.
48
+ export function selectTab(tabs, activeIdx) {
49
+ tabs.forEach((tab, idx) => {
50
+ tab.setAttribute("aria-selected", idx === activeIdx ? "true" : "false");
51
+ });
52
+
53
+ setRovingTabindex(tabs, activeIdx);
54
+ }
55
+
56
+ // Returns the index of the next label starting with `search`, after `fromIdx`,
57
+ // wrapping around, or `null` if none matches. Used for type-ahead in menus.
58
+ export function searchIndex(labels, search, fromIdx) {
59
+ const needle = search.toLowerCase();
60
+ const total = labels.length;
61
+
62
+ for (let offset = 1; offset <= total; offset++) {
63
+ const idx = (fromIdx + offset) % total;
64
+
65
+ if (labels[idx].trim().toLowerCase().startsWith(needle)) return idx;
66
+ }
67
+
68
+ return null;
69
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@woylie/doggo",
3
+ "version": "0.15.0",
4
+ "description": "JavaScript hooks for the Doggo component library",
5
+ "keywords": [
6
+ "phoenix",
7
+ "liveview",
8
+ "hooks",
9
+ "components",
10
+ "accessibility"
11
+ ],
12
+ "homepage": "https://hexdocs.pm/doggo",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/woylie/doggo.git"
16
+ },
17
+ "bugs": "https://github.com/woylie/doggo/issues",
18
+ "author": "Mathias Polligkeit",
19
+ "license": "MIT",
20
+ "type": "module",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "main": "assets/js/index.js",
25
+ "exports": {
26
+ ".": "./assets/js/index.js",
27
+ "./accordion": "./assets/js/hooks/accordion.js",
28
+ "./carousel": "./assets/js/hooks/carousel.js",
29
+ "./combobox": "./assets/js/hooks/combobox.js",
30
+ "./tabs": "./assets/js/hooks/tabs.js",
31
+ "./toolbar": "./assets/js/hooks/toolbar.js",
32
+ "./tooltip": "./assets/js/hooks/tooltip.js",
33
+ "./menu": "./assets/js/hooks/menu.js",
34
+ "./menu_button": "./assets/js/hooks/menu_button.js",
35
+ "./tree": "./assets/js/hooks/tree.js",
36
+ "./dialog": "./assets/js/hooks/dialog.js",
37
+ "./split_pane": "./assets/js/hooks/split_pane.js"
38
+ },
39
+ "files": [
40
+ "assets/js"
41
+ ],
42
+ "sideEffects": false,
43
+ "devDependencies": {
44
+ "@eslint/js": "^10.0.1",
45
+ "eslint": "^10.9.1",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "globals": "^17.11.0",
48
+ "happy-dom": "^20.11.15",
49
+ "prettier": "^3.9.6",
50
+ "vitest": "^4.1.11"
51
+ },
52
+ "scripts": {
53
+ "lint": "pnpm run lint:prettier && pnpm run lint:eslint",
54
+ "lint:eslint": "eslint assets",
55
+ "lint:eslint:fix": "eslint assets --fix",
56
+ "lint:fix": "pnpm run lint:prettier:fix && pnpm run lint:eslint:fix",
57
+ "lint:prettier": "prettier assets --check",
58
+ "lint:prettier:fix": "prettier assets --write",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest"
61
+ }
62
+ }