@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 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.16.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 dismissable = () => dialog.getAttribute("closedby") === "any";
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 (!dismissable()) e.preventDefault();
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 (dismissable() && e.target === dialog) dialog.close();
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
+ };
@@ -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() {
@@ -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.16.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",