@woylie/doggo 0.16.0 → 0.17.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/README.md +49 -1
- package/assets/js/hooks/dialog.js +3 -3
- package/assets/js/hooks/toggletip.js +82 -0
- package/assets/js/hooks/tree.js +33 -39
- package/assets/js/index.js +1 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -21,11 +21,21 @@ 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.
|
|
24
|
+
{:doggo, "~> 0.17.0"}
|
|
25
25
|
]
|
|
26
26
|
end
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
Enable diagnostic checks at render time in `config/dev.exs` and
|
|
30
|
+
`config/test.exs` :
|
|
31
|
+
|
|
32
|
+
```elixir
|
|
33
|
+
config :doggo, :diagnostics, true
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
These checks raise errors if components are not wired up correctly, for example
|
|
37
|
+
if an accessible name is not set. If disabled, the checks are not compiled.
|
|
38
|
+
|
|
29
39
|
### Compatibility
|
|
30
40
|
|
|
31
41
|
This package is tested against the Elixir and OTP versions that are still
|
|
@@ -172,6 +182,7 @@ import {
|
|
|
172
182
|
MenuButton,
|
|
173
183
|
SplitPane,
|
|
174
184
|
Tabs,
|
|
185
|
+
Toggletip,
|
|
175
186
|
Toolbar,
|
|
176
187
|
Tooltip,
|
|
177
188
|
Tree,
|
|
@@ -186,6 +197,7 @@ const hooks = {
|
|
|
186
197
|
"Doggo.MenuButton": MenuButton,
|
|
187
198
|
"Doggo.SplitPane": SplitPane,
|
|
188
199
|
"Doggo.Tabs": Tabs,
|
|
200
|
+
"Doggo.Toggletip": Toggletip,
|
|
189
201
|
"Doggo.Toolbar": Toolbar,
|
|
190
202
|
"Doggo.Tooltip": Tooltip,
|
|
191
203
|
"Doggo.Tree": Tree,
|
|
@@ -215,6 +227,7 @@ import {
|
|
|
215
227
|
MenuButton,
|
|
216
228
|
SplitPane,
|
|
217
229
|
Tabs,
|
|
230
|
+
Toggletip,
|
|
218
231
|
Toolbar,
|
|
219
232
|
Tooltip,
|
|
220
233
|
Tree,
|
|
@@ -231,6 +244,7 @@ import {
|
|
|
231
244
|
"Doggo.MenuButton": MenuButton,
|
|
232
245
|
"Doggo.SplitPane": SplitPane,
|
|
233
246
|
"Doggo.Tabs": Tabs,
|
|
247
|
+
"Doggo.Toggletip": Toggletip,
|
|
234
248
|
"Doggo.Toolbar": Toolbar,
|
|
235
249
|
"Doggo.Tooltip": Tooltip,
|
|
236
250
|
"Doggo.Tree": Tree,
|
|
@@ -328,6 +342,35 @@ The `data-invalid` attribute is set on the field wrapper whenever the field has
|
|
|
328
342
|
errors. The `:empty` selector does not work here, because the rendered list
|
|
329
343
|
contains whitespace.
|
|
330
344
|
|
|
345
|
+
### Text direction
|
|
346
|
+
|
|
347
|
+
Some components render text you pass in an attribute next to text of their
|
|
348
|
+
own, such as a switch label beside its state text. These elements have
|
|
349
|
+
`dir="auto"`. The browser takes their direction from the text itself, and
|
|
350
|
+
right-to-left text in a left-to-right page does not reorder the text around
|
|
351
|
+
it.
|
|
352
|
+
|
|
353
|
+
Text you pass in a slot is not automatically isolated. If it can hold text in
|
|
354
|
+
another direction, wrap it in `<bdi>`.
|
|
355
|
+
|
|
356
|
+
```heex
|
|
357
|
+
<.steps current_step={1}>
|
|
358
|
+
<:step><bdi>{@first_step_name}</bdi></:step>
|
|
359
|
+
<:step><bdi>{@second_step_name}</bdi></:step>
|
|
360
|
+
</.steps>
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The `field` label holds your label text next to the required or optional
|
|
364
|
+
mark, without an element of its own. If your labels can be in a different
|
|
365
|
+
direction than the page, set `unicode-bidi: plaintext` on the label. The
|
|
366
|
+
browser then takes the label's direction from its content.
|
|
367
|
+
|
|
368
|
+
```css
|
|
369
|
+
.field label {
|
|
370
|
+
unicode-bidi: plaintext;
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
331
374
|
## Design decisions
|
|
332
375
|
|
|
333
376
|
- Favor semantic HTML elements over CSS classes for structure and clarity.
|
|
@@ -338,6 +381,8 @@ contains whitespace.
|
|
|
338
381
|
attributes.
|
|
339
382
|
- The library is designed without default styles and does not prefer any
|
|
340
383
|
particular CSS framework.
|
|
384
|
+
- Don't render a container if it doesn't have any children, for example a
|
|
385
|
+
navigation without links.
|
|
341
386
|
|
|
342
387
|
## Demo app
|
|
343
388
|
|
|
@@ -400,6 +445,7 @@ component at any level:
|
|
|
400
445
|
|
|
401
446
|
- Adding an attribute.
|
|
402
447
|
- Adding a class.
|
|
448
|
+
- Changing the whitespace between elements.
|
|
403
449
|
- Removing an attribute that normally no CSS styles are attached to.
|
|
404
450
|
|
|
405
451
|
These changes **are** breaking:
|
|
@@ -413,6 +459,8 @@ These changes **are** breaking:
|
|
|
413
459
|
- Reordering elements.
|
|
414
460
|
- Renaming a JavaScript hook.
|
|
415
461
|
|
|
462
|
+
The output format of `mix dog.safelist` only changes in a major release.
|
|
463
|
+
|
|
416
464
|
## Feedback
|
|
417
465
|
|
|
418
466
|
If you encounter any issues with a component, have suggestions for improvements,
|
|
@@ -9,7 +9,7 @@ const hasClosedBy = () => "closedBy" in HTMLDialogElement.prototype;
|
|
|
9
9
|
export function initDialog(dialog, { execJS = () => {} } = {}) {
|
|
10
10
|
let invoke;
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const closedBy = () => dialog.getAttribute("closedby");
|
|
13
13
|
|
|
14
14
|
// Dispatched by `Doggo.show_modal/2`.
|
|
15
15
|
dialog.addEventListener("doggo:open", () => {
|
|
@@ -47,13 +47,13 @@ export function initDialog(dialog, { execJS = () => {} } = {}) {
|
|
|
47
47
|
// `closedby="none"` has to hold Escape as well, which is the one part of
|
|
48
48
|
// the attribute that native `<dialog>` does not give us anyway.
|
|
49
49
|
dialog.addEventListener("cancel", (e) => {
|
|
50
|
-
if (
|
|
50
|
+
if (closedBy() === "none") e.preventDefault();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
// A click on the backdrop reaches the dialog itself, never a child, so
|
|
54
54
|
// the target is the test for being outside.
|
|
55
55
|
dialog.addEventListener("click", (e) => {
|
|
56
|
-
if (
|
|
56
|
+
if (closedBy() === "any" && e.target === dialog) dialog.close();
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// A long announcement cannot be interrupted, so the text is cut. The region is
|
|
2
|
+
// emptied afterwards, since the open panel holds the same text.
|
|
3
|
+
const maxLength = 250;
|
|
4
|
+
const clearAfter = 1000;
|
|
5
|
+
|
|
6
|
+
const announcement = (panel) => {
|
|
7
|
+
const text = panel.textContent.replace(/\s+/g, " ").trim();
|
|
8
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Remove the positioning once CSS anchor positioning is widely baseline
|
|
12
|
+
// available; the example CSS places the panel where it is supported.
|
|
13
|
+
const anchored = () => window.CSS?.supports?.("position-area: block-end");
|
|
14
|
+
|
|
15
|
+
export function initToggletip(toggletip) {
|
|
16
|
+
const button = toggletip.querySelector(":scope > button");
|
|
17
|
+
const panel = toggletip.querySelector(":scope > [popover]");
|
|
18
|
+
const status = toggletip.querySelector(':scope > [role="status"]');
|
|
19
|
+
let timer;
|
|
20
|
+
|
|
21
|
+
const place = () => {
|
|
22
|
+
const rect = button.getBoundingClientRect();
|
|
23
|
+
const rtl = getComputedStyle(toggletip).direction === "rtl";
|
|
24
|
+
const start = rtl ? window.innerWidth - rect.right : rect.left;
|
|
25
|
+
|
|
26
|
+
const style = {
|
|
27
|
+
inset: "auto",
|
|
28
|
+
margin: "0",
|
|
29
|
+
"margin-block-start": "var(--toggletip-offset)",
|
|
30
|
+
"inset-block-start": `${rect.bottom}px`,
|
|
31
|
+
"inset-inline-start": `${start}px`,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const [name, value] of Object.entries(style)) {
|
|
35
|
+
panel.style.setProperty(name, value);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const position = () => {
|
|
40
|
+
if (!anchored() && panel.matches(":popover-open")) place();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const beforeToggle = (e) => {
|
|
44
|
+
if (!anchored() && e.newState === "open") place();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const announce = (e) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
status.textContent = e.newState === "open" ? announcement(panel) : "";
|
|
50
|
+
timer = setTimeout(() => (status.textContent = ""), clearAfter);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
panel.addEventListener("beforetoggle", beforeToggle);
|
|
54
|
+
panel.addEventListener("toggle", announce);
|
|
55
|
+
window.addEventListener("scroll", position, true);
|
|
56
|
+
window.addEventListener("resize", position);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
update: position,
|
|
60
|
+
destroy() {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
panel.removeEventListener("beforetoggle", beforeToggle);
|
|
63
|
+
panel.removeEventListener("toggle", announce);
|
|
64
|
+
window.removeEventListener("scroll", position, true);
|
|
65
|
+
window.removeEventListener("resize", position);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default {
|
|
71
|
+
mounted() {
|
|
72
|
+
this.instance = initToggletip(this.el);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
updated() {
|
|
76
|
+
this.instance.update();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
destroyed() {
|
|
80
|
+
this.instance.destroy();
|
|
81
|
+
},
|
|
82
|
+
};
|
package/assets/js/hooks/tree.js
CHANGED
|
@@ -10,7 +10,26 @@ const labelOf = (item) =>
|
|
|
10
10
|
const parentOf = (item) =>
|
|
11
11
|
item.parentElement.closest('[role="treeitem"]') || null;
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
writer.removeAttribute(group, "hidden");
|
|
66
69
|
} else {
|
|
67
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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/assets/js/index.js
CHANGED
|
@@ -8,4 +8,5 @@ export { default as SplitPane, initSplitPane } from "./hooks/split_pane.js";
|
|
|
8
8
|
export { default as Tabs, initTabs } from "./hooks/tabs.js";
|
|
9
9
|
export { default as Tree, initTree } from "./hooks/tree.js";
|
|
10
10
|
export { default as Toolbar, initToolbar } from "./hooks/toolbar.js";
|
|
11
|
+
export { default as Toggletip, initToggletip } from "./hooks/toggletip.js";
|
|
11
12
|
export { default as Tooltip, initTooltip } from "./hooks/tooltip.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@woylie/doggo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "JavaScript hooks for the Doggo component library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"phoenix",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./combobox": "./assets/js/hooks/combobox.js",
|
|
30
30
|
"./tabs": "./assets/js/hooks/tabs.js",
|
|
31
31
|
"./toolbar": "./assets/js/hooks/toolbar.js",
|
|
32
|
+
"./toggletip": "./assets/js/hooks/toggletip.js",
|
|
32
33
|
"./tooltip": "./assets/js/hooks/tooltip.js",
|
|
33
34
|
"./menu": "./assets/js/hooks/menu.js",
|
|
34
35
|
"./menu_button": "./assets/js/hooks/menu_button.js",
|