@woylie/doggo 0.15.1 → 0.16.1

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/README.md CHANGED
@@ -21,7 +21,7 @@ The package can be installed by adding `doggo` to your list of dependencies in
21
21
  ```elixir
22
22
  def deps do
23
23
  [
24
- {:doggo, "~> 0.15.1"}
24
+ {:doggo, "~> 0.16.1"}
25
25
  ]
26
26
  end
27
27
  ```
@@ -419,3 +419,10 @@ If you encounter any issues with a component, have suggestions for improvements,
419
419
  or need a component for a specific use case that isn't currently available,
420
420
  please don't hesitate to open a
421
421
  [Github issue](https://github.com/woylie/doggo/issues).
422
+
423
+ ## Related libraries
424
+
425
+ - [ex_icon](https://github.com/woylie/ex_icon) - Generic icon library for
426
+ Phoenix LiveView.
427
+ - [flop_phoenix](https://github.com/woylie/flop_phoenix) - Pagination, sortable
428
+ table and filter components for Flop.
@@ -112,6 +112,8 @@ export function initCarousel(carousel) {
112
112
 
113
113
  // Auto rotation
114
114
  let isPaused = false;
115
+ let isHovered = false;
116
+ let isFocused = false;
115
117
 
116
118
  // Stop auto rotation if the last items does not wrap.
117
119
  const hasShowEnded = () =>
@@ -123,8 +125,11 @@ export function initCarousel(carousel) {
123
125
  syncPauseControl();
124
126
  };
125
127
 
128
+ const canRotate = () =>
129
+ isAutoRotationEnabled() && !isPaused && !isHovered && !isFocused;
130
+
126
131
  const startAutoRotation = () => {
127
- if (!isAutoRotationEnabled() || isPaused) return;
132
+ if (!canRotate()) return;
128
133
  if (autoRotationTimer != null) return;
129
134
 
130
135
  if (hasShowEnded()) {
@@ -260,12 +265,8 @@ export function initCarousel(carousel) {
260
265
  // Pause when hovering or focusing, resume when leaving, unless
261
266
  // rotation is paused with the pause button.
262
267
 
263
- let isHovered = false;
264
- let isFocused = false;
265
-
266
- const resume = () => {
267
- if (!isHovered && !isFocused) startAutoRotation();
268
- };
268
+ const isPauseControl = (el) =>
269
+ el != null && el.closest?.(`.${baseClass}-pause`) != null;
269
270
 
270
271
  carousel.addEventListener("pointerenter", () => {
271
272
  isHovered = true;
@@ -274,12 +275,17 @@ export function initCarousel(carousel) {
274
275
 
275
276
  carousel.addEventListener("pointerleave", () => {
276
277
  isHovered = false;
277
- resume();
278
+ startAutoRotation();
278
279
  });
279
280
 
280
- carousel.addEventListener("focusin", () => {
281
- isFocused = true;
282
- stopAutoRotation();
281
+ carousel.addEventListener("focusin", (e) => {
282
+ isFocused = !isPauseControl(e.target);
283
+
284
+ if (isFocused) {
285
+ stopAutoRotation();
286
+ } else {
287
+ startAutoRotation();
288
+ }
283
289
  });
284
290
 
285
291
  carousel.addEventListener("focusout", (e) => {
@@ -288,7 +294,7 @@ export function initCarousel(carousel) {
288
294
  if (carousel.contains(e.relatedTarget)) return;
289
295
 
290
296
  isFocused = false;
291
- resume();
297
+ startAutoRotation();
292
298
  });
293
299
 
294
300
  // Initialize
@@ -303,7 +309,7 @@ export function initCarousel(carousel) {
303
309
  // A patch can add or remove the pause button, and with it the
304
310
  // rotation. Starting is a no-op while it runs or is paused.
305
311
  if (isAutoRotationEnabled()) {
306
- resume();
312
+ startAutoRotation();
307
313
  } else {
308
314
  stopAutoRotation();
309
315
  }
@@ -64,20 +64,52 @@ export function initMenu(menu) {
64
64
  // through the `aria-controls` pointing at it. Compared rather than put in a
65
65
  // selector, because the id is the caller's and may hold characters that
66
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);
67
+ const getButton = () =>
68
+ Array.from(document.querySelectorAll("[aria-controls]")).find(
69
+ (el) => el.getAttribute("aria-controls") === menu.id,
70
+ );
71
+
72
+ const close = ({ focusButton } = { focusButton: true }) => {
73
+ const button = getButton();
71
74
 
72
75
  if (!button) return false;
73
76
 
74
77
  menu.setAttribute("hidden", "");
75
78
  button.setAttribute("aria-expanded", "false");
76
- button.focus();
79
+
80
+ if (focusButton) button.focus();
77
81
 
78
82
  return true;
79
83
  };
80
84
 
85
+ const closeOnClickOutside = (e) => {
86
+ if (menu.hidden) return;
87
+
88
+ const button = getButton();
89
+
90
+ if (!button) return;
91
+ if (menu.contains(e.target) || button.contains(e.target)) return;
92
+
93
+ close({ focusButton: false });
94
+ };
95
+
96
+ document.addEventListener("click", closeOnClickOutside);
97
+
98
+ const closeOnButtonEscape = (e) => {
99
+ if (e.key !== "Escape" || menu.hidden) return;
100
+
101
+ const button = getButton();
102
+
103
+ if (!button || !button.contains(e.target)) return;
104
+
105
+ if (close()) {
106
+ e.preventDefault();
107
+ e.stopPropagation();
108
+ }
109
+ };
110
+
111
+ document.addEventListener("keydown", closeOnButtonEscape, true);
112
+
81
113
  menu.addEventListener("keydown", (e) => {
82
114
  const items = getItems();
83
115
  const currentIdx = items.indexOf(e.target.closest(ITEMS));
@@ -124,7 +156,12 @@ export function initMenu(menu) {
124
156
 
125
157
  restoreTabStop();
126
158
 
127
- return { update: restoreTabStop };
159
+ const destroy = () => {
160
+ document.removeEventListener("click", closeOnClickOutside);
161
+ document.removeEventListener("keydown", closeOnButtonEscape, true);
162
+ };
163
+
164
+ return { update: restoreTabStop, destroy };
128
165
  }
129
166
 
130
167
  export default {
@@ -135,4 +172,8 @@ export default {
135
172
  updated() {
136
173
  this.instance.update();
137
174
  },
175
+
176
+ destroyed() {
177
+ this.instance.destroy();
178
+ },
138
179
  };
@@ -10,7 +10,26 @@ const labelOf = (item) =>
10
10
  const parentOf = (item) =>
11
11
  item.parentElement.closest('[role="treeitem"]') || null;
12
12
 
13
- export function initTree(tree) {
13
+ const labelText = (item) => {
14
+ const label = labelOf(item);
15
+
16
+ if (!label) return "";
17
+
18
+ const copy = label.cloneNode(true);
19
+
20
+ for (const node of copy.querySelectorAll('[aria-hidden="true"], svg')) {
21
+ node.remove();
22
+ }
23
+
24
+ return copy.textContent;
25
+ };
26
+
27
+ const domWriter = {
28
+ setAttribute: (el, attr, value) => el.setAttribute(attr, value),
29
+ removeAttribute: (el, attr) => el.removeAttribute(attr),
30
+ };
31
+
32
+ export function initTree(tree, writer = domWriter) {
14
33
  // Visible items only. A collapsed branch takes its descendants out of the
15
34
  // sequence.
16
35
  const getItems = () =>
@@ -18,22 +37,6 @@ export function initTree(tree) {
18
37
  (item) => !item.closest('[role="group"][hidden]'),
19
38
  );
20
39
 
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
40
  let activeIdx = 0;
38
41
  let search = "";
39
42
  let searchTimeout;
@@ -46,7 +49,7 @@ export function initTree(tree) {
46
49
  const active = getItems()[idx];
47
50
 
48
51
  for (const item of tree.querySelectorAll('[role="treeitem"]')) {
49
- item.setAttribute("tabindex", item === active ? "0" : "-1");
52
+ writer.setAttribute(item, "tabindex", item === active ? "0" : "-1");
50
53
  }
51
54
  };
52
55
 
@@ -56,25 +59,17 @@ export function initTree(tree) {
56
59
  };
57
60
 
58
61
  const setExpanded = (item, expanded) => {
59
- item.setAttribute("aria-expanded", expanded ? "true" : "false");
62
+ writer.setAttribute(item, "aria-expanded", expanded ? "true" : "false");
60
63
 
61
64
  const group = groupOf(item);
62
65
 
63
66
  if (group) {
64
67
  if (expanded) {
65
- group.removeAttribute("hidden");
68
+ writer.removeAttribute(group, "hidden");
66
69
  } else {
67
- group.setAttribute("hidden", "");
70
+ writer.setAttribute(group, "hidden", "");
68
71
  }
69
72
  }
70
-
71
- const path = pathOf(item);
72
-
73
- if (expanded) {
74
- expandedPaths.add(path);
75
- } else {
76
- expandedPaths.delete(path);
77
- }
78
73
  };
79
74
 
80
75
  // Only the caret toggles. A click on the label is the caller's, for
@@ -106,8 +101,7 @@ export function initTree(tree) {
106
101
 
107
102
  const repeated = [...search].every((char) => char === search[0]);
108
103
  const items = getItems();
109
- // The item's own label, but not nested items.
110
- const labels = items.map((item) => labelOf(item)?.textContent ?? "");
104
+ const labels = items.map(labelText);
111
105
  const idx = searchIndex(
112
106
  labels,
113
107
  repeated ? search[0] : search,
@@ -131,7 +125,7 @@ export function initTree(tree) {
131
125
  if (isBranch(item) && !isExpanded(item)) {
132
126
  setExpanded(item, true);
133
127
  } else if (isBranch(item)) {
134
- const child = groupOf(item).querySelector('[role="treeitem"]');
128
+ const child = groupOf(item)?.querySelector('[role="treeitem"]');
135
129
 
136
130
  if (child) moveTo(getItems().indexOf(child));
137
131
  }
@@ -155,6 +149,7 @@ export function initTree(tree) {
155
149
 
156
150
  const nextIdx = targetIndex(e.key, currentIdx, items.length, {
157
151
  orientation: "vertical",
152
+ wrap: false,
158
153
  });
159
154
 
160
155
  if (nextIdx !== null) {
@@ -170,12 +165,6 @@ export function initTree(tree) {
170
165
  });
171
166
 
172
167
  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
168
  const items = getItems();
180
169
 
181
170
  if (items.length > 0) setActive(clamp(activeIdx, items.length));
@@ -188,7 +177,12 @@ export function initTree(tree) {
188
177
 
189
178
  export default {
190
179
  mounted() {
191
- this.instance = initTree(this.el);
180
+ const js = this.js();
181
+
182
+ this.instance = initTree(this.el, {
183
+ setAttribute: (el, attr, value) => js.setAttribute(el, attr, value),
184
+ removeAttribute: (el, attr) => js.removeAttribute(el, attr),
185
+ });
192
186
  },
193
187
 
194
188
  updated() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@woylie/doggo",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "JavaScript hooks for the Doggo component library",
5
5
  "keywords": [
6
6
  "phoenix",