book-of-elementals 0.1.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 +133 -0
- package/dist/book-of-elementals.css +29 -0
- package/dist/book-of-elementals.css.map +1 -0
- package/dist/book-of-elementals.js +148 -0
- package/dist/book-of-elementals.js.map +7 -0
- package/dist/book-of-elementals.min.css +2 -0
- package/dist/book-of-elementals.min.js +2 -0
- package/dist/elementals/accordion.css +29 -0
- package/dist/elementals/accordion.css.map +1 -0
- package/dist/elementals/accordion.js +148 -0
- package/dist/elementals/accordion.js.map +7 -0
- package/dist/elementals/accordion.min.css +2 -0
- package/dist/elementals/accordion.min.js +2 -0
- package/package.json +66 -0
- package/src/core.js +48 -0
- package/src/elementals/accordion/index.js +180 -0
- package/src/elementals/accordion/index.scss +39 -0
- package/src/iife.js +6 -0
- package/src/index.js +4 -0
- package/src/styles/index.scss +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Nikola Stamatović
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# 📓 Book of Elementals [](https://www.npmjs.com/package/book-of-elementals) [](https://github.com/stamat/book-of-elementals/blob/main/LICENSE)
|
|
2
|
+
|
|
3
|
+
Accessible, dependency-free custom elements. Light DOM, no build step required.
|
|
4
|
+
|
|
5
|
+
Sibling to [book-of-spells](https://github.com/stamat/book-of-spells) — that one
|
|
6
|
+
holds the JavaScript helpers, this one holds the elements.
|
|
7
|
+
|
|
8
|
+
## Principles
|
|
9
|
+
|
|
10
|
+
- **Native first** — an element exists only where the platform leaves a real gap
|
|
11
|
+
- **Light DOM, always** — no shadow roots, your CSS reaches every part
|
|
12
|
+
- **Accessible or it does not ship** — [W3C APG](https://www.w3.org/WAI/ARIA/apg/patterns/) patterns, keyboard and reduced-motion included
|
|
13
|
+
- **No runtime dependencies**
|
|
14
|
+
|
|
15
|
+
## Elements
|
|
16
|
+
|
|
17
|
+
| Element | Pattern |
|
|
18
|
+
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
|
19
|
+
| `<accordion-elemental>` | [APG Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/), over native `<details>` |
|
|
20
|
+
|
|
21
|
+
## Docs
|
|
22
|
+
|
|
23
|
+
[stamat.github.io/book-of-elementals](https://stamat.github.io/book-of-elementals/)
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install book-of-elementals
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Import one element and only that element is registered:
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
import "book-of-elementals/accordion";
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```scss
|
|
38
|
+
@use "book-of-elementals/accordion/style.scss";
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or the whole book:
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
import "book-of-elementals";
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```scss
|
|
48
|
+
@use "book-of-elementals/styles/index.scss";
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or the CDN, no build step. Every element ships its own bundle:
|
|
52
|
+
|
|
53
|
+
```html
|
|
54
|
+
<script src="https://unpkg.com/book-of-elementals/dist/elementals/accordion.min.js"></script>
|
|
55
|
+
<link
|
|
56
|
+
rel="stylesheet"
|
|
57
|
+
href="https://unpkg.com/book-of-elementals/dist/elementals/accordion.min.css"
|
|
58
|
+
/>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
No global, no boot call — including a bundle registers its element and it
|
|
62
|
+
upgrades itself wherever it appears, including markup added later. Swap in
|
|
63
|
+
`book-of-elementals.min.js` for the whole book.
|
|
64
|
+
|
|
65
|
+
## `<accordion-elemental>`
|
|
66
|
+
|
|
67
|
+
Wraps native `<details>`/`<summary>` instead of reimplementing disclosure on
|
|
68
|
+
`<div>`s, so the semantics, Enter/Space activation, screen-reader announcement
|
|
69
|
+
and find-in-page expansion are the browser's.
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<accordion-elemental exclusive>
|
|
73
|
+
<details open>
|
|
74
|
+
<summary>First question</summary>
|
|
75
|
+
<p>First answer.</p>
|
|
76
|
+
</details>
|
|
77
|
+
<details>
|
|
78
|
+
<summary>Second question</summary>
|
|
79
|
+
<p>Second answer.</p>
|
|
80
|
+
</details>
|
|
81
|
+
</accordion-elemental>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
What the element adds on top of native:
|
|
85
|
+
|
|
86
|
+
- `exclusive` — assigns a shared `name` so only one panel stays open
|
|
87
|
+
- <kbd>Up</kbd>/<kbd>Down</kbd>/<kbd>Home</kbd>/<kbd>End</kbd> navigation between headers ([APG accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/))
|
|
88
|
+
- deep links — a URL fragment pointing inside a panel opens it
|
|
89
|
+
- an `accordion-toggle` event on the group, since `toggle` does not bubble
|
|
90
|
+
|
|
91
|
+
| Attribute | Type | Default | Description |
|
|
92
|
+
| ----------- | ------- | ------- | ---------------------------------------------------------- |
|
|
93
|
+
| `exclusive` | boolean | `false` | Only one panel open at a time. |
|
|
94
|
+
| `name` | string | auto | The shared `name` used by `exclusive`. Generated if unset. |
|
|
95
|
+
|
|
96
|
+
Put a heading inside the `<summary>` when the panels are page sections, so
|
|
97
|
+
screen reader users can navigate to them by heading:
|
|
98
|
+
|
|
99
|
+
```html
|
|
100
|
+
<summary><h3>Question</h3></summary>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Styling is yours — there is no shadow DOM. The open/close animation is pure CSS
|
|
104
|
+
on `::details-content` and degrades to native instant toggling where that is
|
|
105
|
+
unsupported:
|
|
106
|
+
|
|
107
|
+
```css
|
|
108
|
+
accordion-elemental {
|
|
109
|
+
--accordion-elemental-duration: 250ms;
|
|
110
|
+
--accordion-elemental-easing: ease;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm install
|
|
118
|
+
npm run dev # docs site on :4040 with livereload
|
|
119
|
+
npm test # unit tests (jest), colocated as src/**/*.test.js
|
|
120
|
+
npm run lint # eslint + stylelint
|
|
121
|
+
npm run build # dist/ (package) and _site/ (docs) — both gitignored
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`src/` is the package, `docs/` is the site's markdown and its skin. Building writes
|
|
125
|
+
`dist/` (rebuilt on `prepack`, so it never has to be committed) and `_site/`, which
|
|
126
|
+
`.github/workflows/pages.yml` deploys to GitHub Pages. `dist/` is copied into `_site/`
|
|
127
|
+
because a Pages artifact is a single directory — nothing above the site root exists once
|
|
128
|
+
deployed, so the live demos need their bundle inside it.
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
Made with ❤️ by @stamat.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
:root {
|
|
3
|
+
--accordion-elemental-duration: 250ms;
|
|
4
|
+
--accordion-elemental-easing: ease;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
accordion-elemental {
|
|
8
|
+
display: block;
|
|
9
|
+
}
|
|
10
|
+
accordion-elemental > details {
|
|
11
|
+
interpolate-size: allow-keywords;
|
|
12
|
+
}
|
|
13
|
+
accordion-elemental > details > summary {
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
}
|
|
16
|
+
accordion-elemental > details::details-content {
|
|
17
|
+
block-size: 0;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
transition: block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing), content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete;
|
|
20
|
+
}
|
|
21
|
+
accordion-elemental > details[open]::details-content {
|
|
22
|
+
block-size: auto;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-reduced-motion: reduce) {
|
|
25
|
+
accordion-elemental > details::details-content {
|
|
26
|
+
transition: none;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/*# sourceMappingURL=book-of-elementals.css.map */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sourceRoot":"","sources":["file:///Users/stamat/Sites/localhost/book-of-elementals/src/elementals/accordion/index.scss"],"names":[],"mappings":";AAAA;EACE;EACA;;;AAGF;EACE;;AAEA;EAIE;;AAGF;EACE;;AAKF;EACE;EACA;EACA,YACE;;AAIJ;EACE;;AAGF;EACE;IACE","sourcesContent":[":root {\n --accordion-elemental-duration: 250ms;\n --accordion-elemental-easing: ease;\n}\n\naccordion-elemental {\n display: block;\n\n > details {\n // Lets `block-size` animate to and from the `auto` keyword. Scoped here\n // rather than on :root so the library does not change how the whole page\n // interpolates sizes.\n interpolate-size: allow-keywords;\n }\n\n > details > summary {\n cursor: pointer;\n }\n\n // Open/close animation. Browsers without ::details-content drop this rule and\n // fall back to native instant toggling, which is correct, just unanimated.\n > details::details-content {\n block-size: 0;\n overflow: hidden;\n transition:\n block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing),\n content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete;\n }\n\n > details[open]::details-content {\n block-size: auto;\n }\n\n @media (prefers-reduced-motion: reduce) {\n > details::details-content {\n transition: none;\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
(() => {
|
|
3
|
+
// src/core.js
|
|
4
|
+
var ElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
|
|
5
|
+
};
|
|
6
|
+
function readOptions(el, schema) {
|
|
7
|
+
const options = {};
|
|
8
|
+
for (const key in schema) {
|
|
9
|
+
const kebab = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
10
|
+
const raw = el.dataset[key] != null ? el.dataset[key] : el.getAttribute(kebab);
|
|
11
|
+
if (raw == null) continue;
|
|
12
|
+
if (schema[key] === "boolean") {
|
|
13
|
+
options[key] = raw !== "false" && raw !== "0";
|
|
14
|
+
} else if (schema[key] === "number") {
|
|
15
|
+
const num = parseFloat(raw);
|
|
16
|
+
if (!Number.isNaN(num)) options[key] = num;
|
|
17
|
+
} else {
|
|
18
|
+
options[key] = raw;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return options;
|
|
22
|
+
}
|
|
23
|
+
function define(tag, ctor) {
|
|
24
|
+
if (typeof customElements === "undefined" || customElements.get(tag)) return;
|
|
25
|
+
customElements.define(tag, ctor);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/elementals/accordion/index.js
|
|
29
|
+
function nextIndex(current, key, length) {
|
|
30
|
+
if (length === 0) return null;
|
|
31
|
+
switch (key) {
|
|
32
|
+
case "ArrowDown":
|
|
33
|
+
return (current + 1) % length;
|
|
34
|
+
case "ArrowUp":
|
|
35
|
+
return (current - 1 + length) % length;
|
|
36
|
+
case "Home":
|
|
37
|
+
return 0;
|
|
38
|
+
case "End":
|
|
39
|
+
return length - 1;
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
var OPTIONS = { exclusive: "boolean" };
|
|
45
|
+
var groupCount = 0;
|
|
46
|
+
var AccordionElemental = class extends ElementBase {
|
|
47
|
+
/** Direct-child panels only, so a nested accordion is not swallowed. */
|
|
48
|
+
get panels() {
|
|
49
|
+
return Array.from(this.querySelectorAll(":scope > details"));
|
|
50
|
+
}
|
|
51
|
+
/** The `<summary>` of each panel, in document order. */
|
|
52
|
+
get headers() {
|
|
53
|
+
return this.panels.map((panel) => panel.querySelector(":scope > summary")).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
connectedCallback() {
|
|
56
|
+
if (this.initialized || !this.panels.length) return;
|
|
57
|
+
this.initialized = true;
|
|
58
|
+
this.options = Object.assign({ exclusive: false }, readOptions(this, OPTIONS));
|
|
59
|
+
this.onKeyDown = this.onKeyDown.bind(this);
|
|
60
|
+
this.onToggle = this.onToggle.bind(this);
|
|
61
|
+
this.onHashChange = this.onHashChange.bind(this);
|
|
62
|
+
if (this.options.exclusive) this.applyExclusive();
|
|
63
|
+
this.addCarets();
|
|
64
|
+
this.addEventListener("keydown", this.onKeyDown);
|
|
65
|
+
this.addEventListener("toggle", this.onToggle, true);
|
|
66
|
+
window.addEventListener("hashchange", this.onHashChange);
|
|
67
|
+
this.openFromHash();
|
|
68
|
+
}
|
|
69
|
+
disconnectedCallback() {
|
|
70
|
+
if (!this.initialized) return;
|
|
71
|
+
this.removeEventListener("keydown", this.onKeyDown);
|
|
72
|
+
this.removeEventListener("toggle", this.onToggle, true);
|
|
73
|
+
window.removeEventListener("hashchange", this.onHashChange);
|
|
74
|
+
this.initialized = false;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Give every panel the same `name`, which is what makes native `<details>`
|
|
78
|
+
* mutually exclusive.
|
|
79
|
+
*/
|
|
80
|
+
applyExclusive() {
|
|
81
|
+
const panels = this.panels;
|
|
82
|
+
let seenOpen = false;
|
|
83
|
+
for (const panel of panels) {
|
|
84
|
+
if (!panel.open) continue;
|
|
85
|
+
if (seenOpen) panel.open = false;
|
|
86
|
+
seenOpen = true;
|
|
87
|
+
}
|
|
88
|
+
if (!this.groupName) {
|
|
89
|
+
this.groupName = this.getAttribute("name") || "accordion-elemental-" + ++groupCount;
|
|
90
|
+
}
|
|
91
|
+
for (const panel of panels) panel.name = this.groupName;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Append the caret to every header that has not got one, so authors write
|
|
95
|
+
* plain `<summary>` and still get a marker. Writing the span yourself is
|
|
96
|
+
* respected and left alone - that is also how you keep the caret without
|
|
97
|
+
* JavaScript.
|
|
98
|
+
*/
|
|
99
|
+
addCarets() {
|
|
100
|
+
for (const header of this.headers) {
|
|
101
|
+
if (header.querySelector(":scope > ." + CARET_CLASS)) continue;
|
|
102
|
+
header.insertAdjacentHTML("beforeend", CARET);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
onKeyDown(e) {
|
|
106
|
+
const summary = e.target.closest && e.target.closest("summary");
|
|
107
|
+
if (!summary) return;
|
|
108
|
+
const headers = this.headers;
|
|
109
|
+
const current = headers.indexOf(summary);
|
|
110
|
+
if (current === -1) return;
|
|
111
|
+
const next = nextIndex(current, e.key, headers.length);
|
|
112
|
+
if (next === null) return;
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
headers[next].focus();
|
|
115
|
+
}
|
|
116
|
+
onToggle(e) {
|
|
117
|
+
const panel = e.target;
|
|
118
|
+
if (!this.panels.includes(panel)) return;
|
|
119
|
+
this.dispatchEvent(new CustomEvent("accordion-toggle", {
|
|
120
|
+
bubbles: true,
|
|
121
|
+
detail: { panel, open: panel.open }
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
onHashChange() {
|
|
125
|
+
this.openFromHash();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Open the panel containing the element the URL fragment points at, so a link
|
|
129
|
+
* to a single question lands on it opened.
|
|
130
|
+
*
|
|
131
|
+
* ponytail: Chrome already auto-expands `<details>` on fragment navigation;
|
|
132
|
+
* this covers the browsers that do not. Drop it once that is everywhere.
|
|
133
|
+
*/
|
|
134
|
+
openFromHash() {
|
|
135
|
+
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
136
|
+
if (!id) return;
|
|
137
|
+
const target = document.getElementById(id);
|
|
138
|
+
if (!target || !this.contains(target)) return;
|
|
139
|
+
let panel = target.closest("details");
|
|
140
|
+
while (panel && this.contains(panel)) {
|
|
141
|
+
panel.open = true;
|
|
142
|
+
panel = panel.parentElement && panel.parentElement.closest("details");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
define("accordion-elemental", AccordionElemental);
|
|
147
|
+
})();
|
|
148
|
+
//# sourceMappingURL=book-of-elementals.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/core.js", "../src/elementals/accordion/index.js"],
|
|
4
|
+
"sourcesContent": ["/**\n * Shared plumbing for every element in the book. Deliberately three small\n * functions and no base class - a base class would have to guess at a lifecycle\n * before there are enough elements to know what the lifecycle is.\n */\n\n// Fall back to a plain base when HTMLElement is absent (e.g. Node under test),\n// so element modules stay importable outside the browser.\nexport const ElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};\n\n/**\n * Read options off an element's attributes, accepting bare, `data-*` and\n * kebab-case forms. `data-*` wins when both are present.\n *\n * @param {HTMLElement} el\n * @param {Object<string, 'boolean'|'number'|'string'>} schema - camelCase option key to type.\n * @returns {Object} Only the options actually present on the element.\n */\nexport function readOptions(el, schema) {\n const options = {};\n for (const key in schema) {\n const kebab = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());\n const raw = el.dataset[key] != null ? el.dataset[key] : el.getAttribute(kebab);\n if (raw == null) continue;\n\n if (schema[key] === 'boolean') {\n // A bare attribute reads as '', which means \"on\".\n options[key] = raw !== 'false' && raw !== '0';\n } else if (schema[key] === 'number') {\n const num = parseFloat(raw);\n if (!Number.isNaN(num)) options[key] = num;\n } else {\n options[key] = raw;\n }\n }\n return options;\n}\n\n/**\n * Register a custom element - in the browser only, and only once, so the module\n * is safe to import twice or to import under Node.\n * @param {string} tag\n * @param {Function} ctor\n */\nexport function define(tag, ctor) {\n if (typeof customElements === 'undefined' || customElements.get(tag)) return;\n customElements.define(tag, ctor);\n}\n", "import { ElementBase, readOptions, define } from '../../core.js';\n\n/**\n * Compute the header to move focus to for a roving-arrow key press.\n * @param {number} current - Index of the currently focused header.\n * @param {string} key - KeyboardEvent.key value.\n * @param {number} length - Number of headers in the group.\n * @returns {number|null} Target index, or null if the key is unhandled.\n */\nexport function nextIndex(current, key, length) {\n if (length === 0) return null;\n switch (key) {\n case 'ArrowDown':\n return (current + 1) % length;\n case 'ArrowUp':\n return (current - 1 + length) % length;\n case 'Home':\n return 0;\n case 'End':\n return length - 1;\n default:\n return null;\n }\n}\n\nconst OPTIONS = { exclusive: 'boolean' };\n\n// Monotonic counter for generating unique `name` values for exclusive groups.\nlet groupCount = 0;\n\n/**\n * `<accordion-elemental>` custom element.\n *\n * A thin coordinator over native `<details>`/`<summary>` rather than a\n * reimplementation of the APG Accordion pattern. Native already provides the\n * disclosure semantics, Enter/Space activation, screen-reader announcement and\n * find-in-page expansion. This element only adds what native leaves out:\n *\n * - `exclusive`: assigns a shared `name` so only one panel stays open\n * - arrow/Home/End navigation between headers (APG accordion, optional but recommended)\n * - opening the panel that contains the current URL fragment\n * - an `accordion-toggle` event on the group, since `toggle` does not bubble\n *\n * Light DOM, no shadow root, so every part stays stylable by the page author.\n *\n * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/\n */\nexport class AccordionElemental extends ElementBase {\n /** Direct-child panels only, so a nested accordion is not swallowed. */\n get panels() {\n return Array.from(this.querySelectorAll(':scope > details'));\n }\n\n /** The `<summary>` of each panel, in document order. */\n get headers() {\n return this.panels\n .map((panel) => panel.querySelector(':scope > summary'))\n .filter(Boolean);\n }\n\n connectedCallback() {\n // Wait until the light-DOM panels have been parsed. The bundle is loaded\n // deferred or at the end of the body, so by upgrade time they are there.\n if (this.initialized || !this.panels.length) return;\n this.initialized = true;\n\n this.options = Object.assign({ exclusive: false }, readOptions(this, OPTIONS));\n\n this.onKeyDown = this.onKeyDown.bind(this);\n this.onToggle = this.onToggle.bind(this);\n this.onHashChange = this.onHashChange.bind(this);\n\n if (this.options.exclusive) this.applyExclusive();\n this.addCarets();\n\n this.addEventListener('keydown', this.onKeyDown);\n // `toggle` does not bubble, but non-bubbling events still run the capture\n // phase through ancestors - so capture is how the group hears its panels.\n this.addEventListener('toggle', this.onToggle, true);\n window.addEventListener('hashchange', this.onHashChange);\n\n this.openFromHash();\n }\n\n disconnectedCallback() {\n if (!this.initialized) return;\n this.removeEventListener('keydown', this.onKeyDown);\n this.removeEventListener('toggle', this.onToggle, true);\n window.removeEventListener('hashchange', this.onHashChange);\n this.initialized = false;\n }\n\n /**\n * Give every panel the same `name`, which is what makes native `<details>`\n * mutually exclusive.\n */\n applyExclusive() {\n const panels = this.panels;\n\n // Close extras *before* naming them: assigning a shared name to several\n // already-open panels leaves it to the browser which one survives. Doing it\n // here means the first authored `open` wins, deterministically.\n let seenOpen = false;\n for (const panel of panels) {\n if (!panel.open) continue;\n if (seenOpen) panel.open = false;\n seenOpen = true;\n }\n\n // Remember the generated name so moving the group in the DOM re-uses it\n // instead of minting a new one on every reconnect.\n if (!this.groupName) {\n this.groupName = this.getAttribute('name') || 'accordion-elemental-' + (++groupCount);\n }\n for (const panel of panels) panel.name = this.groupName;\n }\n\n /**\n * Append the caret to every header that has not got one, so authors write\n * plain `<summary>` and still get a marker. Writing the span yourself is\n * respected and left alone - that is also how you keep the caret without\n * JavaScript.\n */\n addCarets() {\n for (const header of this.headers) {\n if (header.querySelector(':scope > .' + CARET_CLASS)) continue;\n header.insertAdjacentHTML('beforeend', CARET);\n }\n }\n\n onKeyDown(e) {\n const summary = e.target.closest && e.target.closest('summary');\n if (!summary) return;\n const headers = this.headers;\n const current = headers.indexOf(summary);\n // -1 means the summary belongs to a nested accordion; let it handle itself.\n if (current === -1) return;\n\n const next = nextIndex(current, e.key, headers.length);\n if (next === null) return;\n e.preventDefault();\n headers[next].focus();\n }\n\n onToggle(e) {\n const panel = e.target;\n if (!this.panels.includes(panel)) return;\n this.dispatchEvent(new CustomEvent('accordion-toggle', {\n bubbles: true,\n detail: { panel: panel, open: panel.open }\n }));\n }\n\n onHashChange() {\n this.openFromHash();\n }\n\n /**\n * Open the panel containing the element the URL fragment points at, so a link\n * to a single question lands on it opened.\n *\n * ponytail: Chrome already auto-expands `<details>` on fragment navigation;\n * this covers the browsers that do not. Drop it once that is everywhere.\n */\n openFromHash() {\n const id = decodeURIComponent(window.location.hash.slice(1));\n if (!id) return;\n const target = document.getElementById(id);\n if (!target || !this.contains(target)) return;\n\n // Walk up so a nested panel opens its ancestors too.\n let panel = target.closest('details');\n while (panel && this.contains(panel)) {\n panel.open = true;\n panel = panel.parentElement && panel.parentElement.closest('details');\n }\n }\n}\n\ndefine('accordion-elemental', AccordionElemental);\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
:root{--accordion-elemental-duration: .25s;--accordion-elemental-easing: ease}accordion-elemental{display:block}accordion-elemental>details{interpolate-size:allow-keywords}accordion-elemental>details>summary{cursor:pointer}accordion-elemental>details::details-content{block-size:0;overflow:hidden;transition:block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing),content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete}accordion-elemental>details[open]::details-content{block-size:auto}@media(prefers-reduced-motion:reduce){accordion-elemental>details::details-content{transition:none}}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
(()=>{var r=typeof HTMLElement<"u"?HTMLElement:class{};function l(e,n){const t={};for(const s in n){const i=s.replace(/[A-Z]/g,a=>"-"+a.toLowerCase()),o=e.dataset[s]!=null?e.dataset[s]:e.getAttribute(i);if(o!=null)if(n[s]==="boolean")t[s]=o!=="false"&&o!=="0";else if(n[s]==="number"){const a=parseFloat(o);Number.isNaN(a)||(t[s]=a)}else t[s]=o}return t}function c(e,n){typeof customElements>"u"||customElements.get(e)||customElements.define(e,n)}function h(e,n,t){if(t===0)return null;switch(n){case"ArrowDown":return(e+1)%t;case"ArrowUp":return(e-1+t)%t;case"Home":return 0;case"End":return t-1;default:return null}}var u={exclusive:"boolean"},d=0,f=class extends r{get panels(){return Array.from(this.querySelectorAll(":scope > details"))}get headers(){return this.panels.map(e=>e.querySelector(":scope > summary")).filter(Boolean)}connectedCallback(){this.initialized||!this.panels.length||(this.initialized=!0,this.options=Object.assign({exclusive:!1},l(this,u)),this.onKeyDown=this.onKeyDown.bind(this),this.onToggle=this.onToggle.bind(this),this.onHashChange=this.onHashChange.bind(this),this.options.exclusive&&this.applyExclusive(),this.addCarets(),this.addEventListener("keydown",this.onKeyDown),this.addEventListener("toggle",this.onToggle,!0),window.addEventListener("hashchange",this.onHashChange),this.openFromHash())}disconnectedCallback(){this.initialized&&(this.removeEventListener("keydown",this.onKeyDown),this.removeEventListener("toggle",this.onToggle,!0),window.removeEventListener("hashchange",this.onHashChange),this.initialized=!1)}applyExclusive(){const e=this.panels;let n=!1;for(const t of e)t.open&&(n&&(t.open=!1),n=!0);this.groupName||(this.groupName=this.getAttribute("name")||"accordion-elemental-"+ ++d);for(const t of e)t.name=this.groupName}addCarets(){for(const e of this.headers)e.querySelector(":scope > ."+CARET_CLASS)||e.insertAdjacentHTML("beforeend",CARET)}onKeyDown(e){const n=e.target.closest&&e.target.closest("summary");if(!n)return;const t=this.headers,s=t.indexOf(n);if(s===-1)return;const i=h(s,e.key,t.length);i!==null&&(e.preventDefault(),t[i].focus())}onToggle(e){const n=e.target;this.panels.includes(n)&&this.dispatchEvent(new CustomEvent("accordion-toggle",{bubbles:!0,detail:{panel:n,open:n.open}}))}onHashChange(){this.openFromHash()}openFromHash(){const e=decodeURIComponent(window.location.hash.slice(1));if(!e)return;const n=document.getElementById(e);if(!n||!this.contains(n))return;let t=n.closest("details");for(;t&&this.contains(t);)t.open=!0,t=t.parentElement&&t.parentElement.closest("details")}};c("accordion-elemental",f)})();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
:root {
|
|
3
|
+
--accordion-elemental-duration: 250ms;
|
|
4
|
+
--accordion-elemental-easing: ease;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
accordion-elemental {
|
|
8
|
+
display: block;
|
|
9
|
+
}
|
|
10
|
+
accordion-elemental > details {
|
|
11
|
+
interpolate-size: allow-keywords;
|
|
12
|
+
}
|
|
13
|
+
accordion-elemental > details > summary {
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
}
|
|
16
|
+
accordion-elemental > details::details-content {
|
|
17
|
+
block-size: 0;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
transition: block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing), content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete;
|
|
20
|
+
}
|
|
21
|
+
accordion-elemental > details[open]::details-content {
|
|
22
|
+
block-size: auto;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-reduced-motion: reduce) {
|
|
25
|
+
accordion-elemental > details::details-content {
|
|
26
|
+
transition: none;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/*# sourceMappingURL=accordion.css.map */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sourceRoot":"","sources":["file:///Users/stamat/Sites/localhost/book-of-elementals/src/elementals/accordion/index.scss"],"names":[],"mappings":";AAAA;EACE;EACA;;;AAGF;EACE;;AAEA;EAIE;;AAGF;EACE;;AAKF;EACE;EACA;EACA,YACE;;AAIJ;EACE;;AAGF;EACE;IACE","sourcesContent":[":root {\n --accordion-elemental-duration: 250ms;\n --accordion-elemental-easing: ease;\n}\n\naccordion-elemental {\n display: block;\n\n > details {\n // Lets `block-size` animate to and from the `auto` keyword. Scoped here\n // rather than on :root so the library does not change how the whole page\n // interpolates sizes.\n interpolate-size: allow-keywords;\n }\n\n > details > summary {\n cursor: pointer;\n }\n\n // Open/close animation. Browsers without ::details-content drop this rule and\n // fall back to native instant toggling, which is correct, just unanimated.\n > details::details-content {\n block-size: 0;\n overflow: hidden;\n transition:\n block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing),\n content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete;\n }\n\n > details[open]::details-content {\n block-size: auto;\n }\n\n @media (prefers-reduced-motion: reduce) {\n > details::details-content {\n transition: none;\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
(() => {
|
|
3
|
+
// src/core.js
|
|
4
|
+
var ElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
|
|
5
|
+
};
|
|
6
|
+
function readOptions(el, schema) {
|
|
7
|
+
const options = {};
|
|
8
|
+
for (const key in schema) {
|
|
9
|
+
const kebab = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
10
|
+
const raw = el.dataset[key] != null ? el.dataset[key] : el.getAttribute(kebab);
|
|
11
|
+
if (raw == null) continue;
|
|
12
|
+
if (schema[key] === "boolean") {
|
|
13
|
+
options[key] = raw !== "false" && raw !== "0";
|
|
14
|
+
} else if (schema[key] === "number") {
|
|
15
|
+
const num = parseFloat(raw);
|
|
16
|
+
if (!Number.isNaN(num)) options[key] = num;
|
|
17
|
+
} else {
|
|
18
|
+
options[key] = raw;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return options;
|
|
22
|
+
}
|
|
23
|
+
function define(tag, ctor) {
|
|
24
|
+
if (typeof customElements === "undefined" || customElements.get(tag)) return;
|
|
25
|
+
customElements.define(tag, ctor);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/elementals/accordion/index.js
|
|
29
|
+
function nextIndex(current, key, length) {
|
|
30
|
+
if (length === 0) return null;
|
|
31
|
+
switch (key) {
|
|
32
|
+
case "ArrowDown":
|
|
33
|
+
return (current + 1) % length;
|
|
34
|
+
case "ArrowUp":
|
|
35
|
+
return (current - 1 + length) % length;
|
|
36
|
+
case "Home":
|
|
37
|
+
return 0;
|
|
38
|
+
case "End":
|
|
39
|
+
return length - 1;
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
var OPTIONS = { exclusive: "boolean" };
|
|
45
|
+
var groupCount = 0;
|
|
46
|
+
var AccordionElemental = class extends ElementBase {
|
|
47
|
+
/** Direct-child panels only, so a nested accordion is not swallowed. */
|
|
48
|
+
get panels() {
|
|
49
|
+
return Array.from(this.querySelectorAll(":scope > details"));
|
|
50
|
+
}
|
|
51
|
+
/** The `<summary>` of each panel, in document order. */
|
|
52
|
+
get headers() {
|
|
53
|
+
return this.panels.map((panel) => panel.querySelector(":scope > summary")).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
connectedCallback() {
|
|
56
|
+
if (this.initialized || !this.panels.length) return;
|
|
57
|
+
this.initialized = true;
|
|
58
|
+
this.options = Object.assign({ exclusive: false }, readOptions(this, OPTIONS));
|
|
59
|
+
this.onKeyDown = this.onKeyDown.bind(this);
|
|
60
|
+
this.onToggle = this.onToggle.bind(this);
|
|
61
|
+
this.onHashChange = this.onHashChange.bind(this);
|
|
62
|
+
if (this.options.exclusive) this.applyExclusive();
|
|
63
|
+
this.addCarets();
|
|
64
|
+
this.addEventListener("keydown", this.onKeyDown);
|
|
65
|
+
this.addEventListener("toggle", this.onToggle, true);
|
|
66
|
+
window.addEventListener("hashchange", this.onHashChange);
|
|
67
|
+
this.openFromHash();
|
|
68
|
+
}
|
|
69
|
+
disconnectedCallback() {
|
|
70
|
+
if (!this.initialized) return;
|
|
71
|
+
this.removeEventListener("keydown", this.onKeyDown);
|
|
72
|
+
this.removeEventListener("toggle", this.onToggle, true);
|
|
73
|
+
window.removeEventListener("hashchange", this.onHashChange);
|
|
74
|
+
this.initialized = false;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Give every panel the same `name`, which is what makes native `<details>`
|
|
78
|
+
* mutually exclusive.
|
|
79
|
+
*/
|
|
80
|
+
applyExclusive() {
|
|
81
|
+
const panels = this.panels;
|
|
82
|
+
let seenOpen = false;
|
|
83
|
+
for (const panel of panels) {
|
|
84
|
+
if (!panel.open) continue;
|
|
85
|
+
if (seenOpen) panel.open = false;
|
|
86
|
+
seenOpen = true;
|
|
87
|
+
}
|
|
88
|
+
if (!this.groupName) {
|
|
89
|
+
this.groupName = this.getAttribute("name") || "accordion-elemental-" + ++groupCount;
|
|
90
|
+
}
|
|
91
|
+
for (const panel of panels) panel.name = this.groupName;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Append the caret to every header that has not got one, so authors write
|
|
95
|
+
* plain `<summary>` and still get a marker. Writing the span yourself is
|
|
96
|
+
* respected and left alone - that is also how you keep the caret without
|
|
97
|
+
* JavaScript.
|
|
98
|
+
*/
|
|
99
|
+
addCarets() {
|
|
100
|
+
for (const header of this.headers) {
|
|
101
|
+
if (header.querySelector(":scope > ." + CARET_CLASS)) continue;
|
|
102
|
+
header.insertAdjacentHTML("beforeend", CARET);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
onKeyDown(e) {
|
|
106
|
+
const summary = e.target.closest && e.target.closest("summary");
|
|
107
|
+
if (!summary) return;
|
|
108
|
+
const headers = this.headers;
|
|
109
|
+
const current = headers.indexOf(summary);
|
|
110
|
+
if (current === -1) return;
|
|
111
|
+
const next = nextIndex(current, e.key, headers.length);
|
|
112
|
+
if (next === null) return;
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
headers[next].focus();
|
|
115
|
+
}
|
|
116
|
+
onToggle(e) {
|
|
117
|
+
const panel = e.target;
|
|
118
|
+
if (!this.panels.includes(panel)) return;
|
|
119
|
+
this.dispatchEvent(new CustomEvent("accordion-toggle", {
|
|
120
|
+
bubbles: true,
|
|
121
|
+
detail: { panel, open: panel.open }
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
onHashChange() {
|
|
125
|
+
this.openFromHash();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Open the panel containing the element the URL fragment points at, so a link
|
|
129
|
+
* to a single question lands on it opened.
|
|
130
|
+
*
|
|
131
|
+
* ponytail: Chrome already auto-expands `<details>` on fragment navigation;
|
|
132
|
+
* this covers the browsers that do not. Drop it once that is everywhere.
|
|
133
|
+
*/
|
|
134
|
+
openFromHash() {
|
|
135
|
+
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
136
|
+
if (!id) return;
|
|
137
|
+
const target = document.getElementById(id);
|
|
138
|
+
if (!target || !this.contains(target)) return;
|
|
139
|
+
let panel = target.closest("details");
|
|
140
|
+
while (panel && this.contains(panel)) {
|
|
141
|
+
panel.open = true;
|
|
142
|
+
panel = panel.parentElement && panel.parentElement.closest("details");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
define("accordion-elemental", AccordionElemental);
|
|
147
|
+
})();
|
|
148
|
+
//# sourceMappingURL=accordion.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/core.js", "../../src/elementals/accordion/index.js"],
|
|
4
|
+
"sourcesContent": ["/**\n * Shared plumbing for every element in the book. Deliberately three small\n * functions and no base class - a base class would have to guess at a lifecycle\n * before there are enough elements to know what the lifecycle is.\n */\n\n// Fall back to a plain base when HTMLElement is absent (e.g. Node under test),\n// so element modules stay importable outside the browser.\nexport const ElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};\n\n/**\n * Read options off an element's attributes, accepting bare, `data-*` and\n * kebab-case forms. `data-*` wins when both are present.\n *\n * @param {HTMLElement} el\n * @param {Object<string, 'boolean'|'number'|'string'>} schema - camelCase option key to type.\n * @returns {Object} Only the options actually present on the element.\n */\nexport function readOptions(el, schema) {\n const options = {};\n for (const key in schema) {\n const kebab = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());\n const raw = el.dataset[key] != null ? el.dataset[key] : el.getAttribute(kebab);\n if (raw == null) continue;\n\n if (schema[key] === 'boolean') {\n // A bare attribute reads as '', which means \"on\".\n options[key] = raw !== 'false' && raw !== '0';\n } else if (schema[key] === 'number') {\n const num = parseFloat(raw);\n if (!Number.isNaN(num)) options[key] = num;\n } else {\n options[key] = raw;\n }\n }\n return options;\n}\n\n/**\n * Register a custom element - in the browser only, and only once, so the module\n * is safe to import twice or to import under Node.\n * @param {string} tag\n * @param {Function} ctor\n */\nexport function define(tag, ctor) {\n if (typeof customElements === 'undefined' || customElements.get(tag)) return;\n customElements.define(tag, ctor);\n}\n", "import { ElementBase, readOptions, define } from '../../core.js';\n\n/**\n * Compute the header to move focus to for a roving-arrow key press.\n * @param {number} current - Index of the currently focused header.\n * @param {string} key - KeyboardEvent.key value.\n * @param {number} length - Number of headers in the group.\n * @returns {number|null} Target index, or null if the key is unhandled.\n */\nexport function nextIndex(current, key, length) {\n if (length === 0) return null;\n switch (key) {\n case 'ArrowDown':\n return (current + 1) % length;\n case 'ArrowUp':\n return (current - 1 + length) % length;\n case 'Home':\n return 0;\n case 'End':\n return length - 1;\n default:\n return null;\n }\n}\n\nconst OPTIONS = { exclusive: 'boolean' };\n\n// Monotonic counter for generating unique `name` values for exclusive groups.\nlet groupCount = 0;\n\n/**\n * `<accordion-elemental>` custom element.\n *\n * A thin coordinator over native `<details>`/`<summary>` rather than a\n * reimplementation of the APG Accordion pattern. Native already provides the\n * disclosure semantics, Enter/Space activation, screen-reader announcement and\n * find-in-page expansion. This element only adds what native leaves out:\n *\n * - `exclusive`: assigns a shared `name` so only one panel stays open\n * - arrow/Home/End navigation between headers (APG accordion, optional but recommended)\n * - opening the panel that contains the current URL fragment\n * - an `accordion-toggle` event on the group, since `toggle` does not bubble\n *\n * Light DOM, no shadow root, so every part stays stylable by the page author.\n *\n * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/\n */\nexport class AccordionElemental extends ElementBase {\n /** Direct-child panels only, so a nested accordion is not swallowed. */\n get panels() {\n return Array.from(this.querySelectorAll(':scope > details'));\n }\n\n /** The `<summary>` of each panel, in document order. */\n get headers() {\n return this.panels\n .map((panel) => panel.querySelector(':scope > summary'))\n .filter(Boolean);\n }\n\n connectedCallback() {\n // Wait until the light-DOM panels have been parsed. The bundle is loaded\n // deferred or at the end of the body, so by upgrade time they are there.\n if (this.initialized || !this.panels.length) return;\n this.initialized = true;\n\n this.options = Object.assign({ exclusive: false }, readOptions(this, OPTIONS));\n\n this.onKeyDown = this.onKeyDown.bind(this);\n this.onToggle = this.onToggle.bind(this);\n this.onHashChange = this.onHashChange.bind(this);\n\n if (this.options.exclusive) this.applyExclusive();\n this.addCarets();\n\n this.addEventListener('keydown', this.onKeyDown);\n // `toggle` does not bubble, but non-bubbling events still run the capture\n // phase through ancestors - so capture is how the group hears its panels.\n this.addEventListener('toggle', this.onToggle, true);\n window.addEventListener('hashchange', this.onHashChange);\n\n this.openFromHash();\n }\n\n disconnectedCallback() {\n if (!this.initialized) return;\n this.removeEventListener('keydown', this.onKeyDown);\n this.removeEventListener('toggle', this.onToggle, true);\n window.removeEventListener('hashchange', this.onHashChange);\n this.initialized = false;\n }\n\n /**\n * Give every panel the same `name`, which is what makes native `<details>`\n * mutually exclusive.\n */\n applyExclusive() {\n const panels = this.panels;\n\n // Close extras *before* naming them: assigning a shared name to several\n // already-open panels leaves it to the browser which one survives. Doing it\n // here means the first authored `open` wins, deterministically.\n let seenOpen = false;\n for (const panel of panels) {\n if (!panel.open) continue;\n if (seenOpen) panel.open = false;\n seenOpen = true;\n }\n\n // Remember the generated name so moving the group in the DOM re-uses it\n // instead of minting a new one on every reconnect.\n if (!this.groupName) {\n this.groupName = this.getAttribute('name') || 'accordion-elemental-' + (++groupCount);\n }\n for (const panel of panels) panel.name = this.groupName;\n }\n\n /**\n * Append the caret to every header that has not got one, so authors write\n * plain `<summary>` and still get a marker. Writing the span yourself is\n * respected and left alone - that is also how you keep the caret without\n * JavaScript.\n */\n addCarets() {\n for (const header of this.headers) {\n if (header.querySelector(':scope > .' + CARET_CLASS)) continue;\n header.insertAdjacentHTML('beforeend', CARET);\n }\n }\n\n onKeyDown(e) {\n const summary = e.target.closest && e.target.closest('summary');\n if (!summary) return;\n const headers = this.headers;\n const current = headers.indexOf(summary);\n // -1 means the summary belongs to a nested accordion; let it handle itself.\n if (current === -1) return;\n\n const next = nextIndex(current, e.key, headers.length);\n if (next === null) return;\n e.preventDefault();\n headers[next].focus();\n }\n\n onToggle(e) {\n const panel = e.target;\n if (!this.panels.includes(panel)) return;\n this.dispatchEvent(new CustomEvent('accordion-toggle', {\n bubbles: true,\n detail: { panel: panel, open: panel.open }\n }));\n }\n\n onHashChange() {\n this.openFromHash();\n }\n\n /**\n * Open the panel containing the element the URL fragment points at, so a link\n * to a single question lands on it opened.\n *\n * ponytail: Chrome already auto-expands `<details>` on fragment navigation;\n * this covers the browsers that do not. Drop it once that is everywhere.\n */\n openFromHash() {\n const id = decodeURIComponent(window.location.hash.slice(1));\n if (!id) return;\n const target = document.getElementById(id);\n if (!target || !this.contains(target)) return;\n\n // Walk up so a nested panel opens its ancestors too.\n let panel = target.closest('details');\n while (panel && this.contains(panel)) {\n panel.open = true;\n panel = panel.parentElement && panel.parentElement.closest('details');\n }\n }\n}\n\ndefine('accordion-elemental', AccordionElemental);\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
:root{--accordion-elemental-duration: .25s;--accordion-elemental-easing: ease}accordion-elemental{display:block}accordion-elemental>details{interpolate-size:allow-keywords}accordion-elemental>details>summary{cursor:pointer}accordion-elemental>details::details-content{block-size:0;overflow:hidden;transition:block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing),content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete}accordion-elemental>details[open]::details-content{block-size:auto}@media(prefers-reduced-motion:reduce){accordion-elemental>details::details-content{transition:none}}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* book-of-elementals v0.1.0 | https://stamat.github.io/book-of-elementals/ | MIT License */
|
|
2
|
+
(()=>{var r=typeof HTMLElement<"u"?HTMLElement:class{};function l(e,n){const t={};for(const s in n){const i=s.replace(/[A-Z]/g,a=>"-"+a.toLowerCase()),o=e.dataset[s]!=null?e.dataset[s]:e.getAttribute(i);if(o!=null)if(n[s]==="boolean")t[s]=o!=="false"&&o!=="0";else if(n[s]==="number"){const a=parseFloat(o);Number.isNaN(a)||(t[s]=a)}else t[s]=o}return t}function c(e,n){typeof customElements>"u"||customElements.get(e)||customElements.define(e,n)}function h(e,n,t){if(t===0)return null;switch(n){case"ArrowDown":return(e+1)%t;case"ArrowUp":return(e-1+t)%t;case"Home":return 0;case"End":return t-1;default:return null}}var u={exclusive:"boolean"},d=0,f=class extends r{get panels(){return Array.from(this.querySelectorAll(":scope > details"))}get headers(){return this.panels.map(e=>e.querySelector(":scope > summary")).filter(Boolean)}connectedCallback(){this.initialized||!this.panels.length||(this.initialized=!0,this.options=Object.assign({exclusive:!1},l(this,u)),this.onKeyDown=this.onKeyDown.bind(this),this.onToggle=this.onToggle.bind(this),this.onHashChange=this.onHashChange.bind(this),this.options.exclusive&&this.applyExclusive(),this.addCarets(),this.addEventListener("keydown",this.onKeyDown),this.addEventListener("toggle",this.onToggle,!0),window.addEventListener("hashchange",this.onHashChange),this.openFromHash())}disconnectedCallback(){this.initialized&&(this.removeEventListener("keydown",this.onKeyDown),this.removeEventListener("toggle",this.onToggle,!0),window.removeEventListener("hashchange",this.onHashChange),this.initialized=!1)}applyExclusive(){const e=this.panels;let n=!1;for(const t of e)t.open&&(n&&(t.open=!1),n=!0);this.groupName||(this.groupName=this.getAttribute("name")||"accordion-elemental-"+ ++d);for(const t of e)t.name=this.groupName}addCarets(){for(const e of this.headers)e.querySelector(":scope > ."+CARET_CLASS)||e.insertAdjacentHTML("beforeend",CARET)}onKeyDown(e){const n=e.target.closest&&e.target.closest("summary");if(!n)return;const t=this.headers,s=t.indexOf(n);if(s===-1)return;const i=h(s,e.key,t.length);i!==null&&(e.preventDefault(),t[i].focus())}onToggle(e){const n=e.target;this.panels.includes(n)&&this.dispatchEvent(new CustomEvent("accordion-toggle",{bubbles:!0,detail:{panel:n,open:n.open}}))}onHashChange(){this.openFromHash()}openFromHash(){const e=decodeURIComponent(window.location.hash.slice(1));if(!e)return;const n=document.getElementById(e);if(!n||!this.contains(n))return;let t=n.closest("details");for(;t&&this.contains(t);)t.open=!0,t=t.parentElement&&t.parentElement.closest("details")}};c("accordion-elemental",f)})();
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "book-of-elementals",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "📓 Stamat's Book of Elementals — custom elements, accessible, dependency-free, light DOM.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"style": "src/styles/index.scss",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./core": "./src/core.js",
|
|
11
|
+
"./accordion": "./src/elementals/accordion/index.js",
|
|
12
|
+
"./accordion/style.scss": "./src/elementals/accordion/index.scss",
|
|
13
|
+
"./accordion/style.css": "./dist/elementals/accordion.css",
|
|
14
|
+
"./styles/*": "./src/styles/*",
|
|
15
|
+
"./dist/*": "./dist/*",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src/core.js",
|
|
20
|
+
"src/index.js",
|
|
21
|
+
"src/iife.js",
|
|
22
|
+
"src/elementals",
|
|
23
|
+
"src/styles/index.scss",
|
|
24
|
+
"dist",
|
|
25
|
+
"!src/**/*.test.js"
|
|
26
|
+
],
|
|
27
|
+
"homepage": "https://stamat.github.io/book-of-elementals/",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/stamat/book-of-elementals.git"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"custom-elements",
|
|
34
|
+
"web-components",
|
|
35
|
+
"accessibility",
|
|
36
|
+
"a11y",
|
|
37
|
+
"aria",
|
|
38
|
+
"apg",
|
|
39
|
+
"accordion",
|
|
40
|
+
"details",
|
|
41
|
+
"javascript",
|
|
42
|
+
"esm"
|
|
43
|
+
],
|
|
44
|
+
"author": "Nikola Stamatovic <@stamat>",
|
|
45
|
+
"contributors": [],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/stamat/book-of-elementals/issues"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"dev": "poops",
|
|
52
|
+
"build": "poops -b",
|
|
53
|
+
"prepack": "poops -b",
|
|
54
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
55
|
+
"lint": "eslint . && stylelint \"{src,docs}/**/*.scss\""
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@eslint/js": "^10.0.1",
|
|
59
|
+
"eslint": "^10.7.0",
|
|
60
|
+
"jest": "^30.4.2",
|
|
61
|
+
"poops": "^1.9.5",
|
|
62
|
+
"poops-docs-theme": "^1.1.2",
|
|
63
|
+
"stylelint": "^17.14.1",
|
|
64
|
+
"stylelint-config-standard-scss": "^17.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for every element in the book. Deliberately three small
|
|
3
|
+
* functions and no base class - a base class would have to guess at a lifecycle
|
|
4
|
+
* before there are enough elements to know what the lifecycle is.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Fall back to a plain base when HTMLElement is absent (e.g. Node under test),
|
|
8
|
+
// so element modules stay importable outside the browser.
|
|
9
|
+
export const ElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read options off an element's attributes, accepting bare, `data-*` and
|
|
13
|
+
* kebab-case forms. `data-*` wins when both are present.
|
|
14
|
+
*
|
|
15
|
+
* @param {HTMLElement} el
|
|
16
|
+
* @param {Object<string, 'boolean'|'number'|'string'>} schema - camelCase option key to type.
|
|
17
|
+
* @returns {Object} Only the options actually present on the element.
|
|
18
|
+
*/
|
|
19
|
+
export function readOptions(el, schema) {
|
|
20
|
+
const options = {};
|
|
21
|
+
for (const key in schema) {
|
|
22
|
+
const kebab = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
23
|
+
const raw = el.dataset[key] != null ? el.dataset[key] : el.getAttribute(kebab);
|
|
24
|
+
if (raw == null) continue;
|
|
25
|
+
|
|
26
|
+
if (schema[key] === 'boolean') {
|
|
27
|
+
// A bare attribute reads as '', which means "on".
|
|
28
|
+
options[key] = raw !== 'false' && raw !== '0';
|
|
29
|
+
} else if (schema[key] === 'number') {
|
|
30
|
+
const num = parseFloat(raw);
|
|
31
|
+
if (!Number.isNaN(num)) options[key] = num;
|
|
32
|
+
} else {
|
|
33
|
+
options[key] = raw;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return options;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a custom element - in the browser only, and only once, so the module
|
|
41
|
+
* is safe to import twice or to import under Node.
|
|
42
|
+
* @param {string} tag
|
|
43
|
+
* @param {Function} ctor
|
|
44
|
+
*/
|
|
45
|
+
export function define(tag, ctor) {
|
|
46
|
+
if (typeof customElements === 'undefined' || customElements.get(tag)) return;
|
|
47
|
+
customElements.define(tag, ctor);
|
|
48
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ElementBase, readOptions, define } from '../../core.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute the header to move focus to for a roving-arrow key press.
|
|
5
|
+
* @param {number} current - Index of the currently focused header.
|
|
6
|
+
* @param {string} key - KeyboardEvent.key value.
|
|
7
|
+
* @param {number} length - Number of headers in the group.
|
|
8
|
+
* @returns {number|null} Target index, or null if the key is unhandled.
|
|
9
|
+
*/
|
|
10
|
+
export function nextIndex(current, key, length) {
|
|
11
|
+
if (length === 0) return null;
|
|
12
|
+
switch (key) {
|
|
13
|
+
case 'ArrowDown':
|
|
14
|
+
return (current + 1) % length;
|
|
15
|
+
case 'ArrowUp':
|
|
16
|
+
return (current - 1 + length) % length;
|
|
17
|
+
case 'Home':
|
|
18
|
+
return 0;
|
|
19
|
+
case 'End':
|
|
20
|
+
return length - 1;
|
|
21
|
+
default:
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const OPTIONS = { exclusive: 'boolean' };
|
|
27
|
+
|
|
28
|
+
// Monotonic counter for generating unique `name` values for exclusive groups.
|
|
29
|
+
let groupCount = 0;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `<accordion-elemental>` custom element.
|
|
33
|
+
*
|
|
34
|
+
* A thin coordinator over native `<details>`/`<summary>` rather than a
|
|
35
|
+
* reimplementation of the APG Accordion pattern. Native already provides the
|
|
36
|
+
* disclosure semantics, Enter/Space activation, screen-reader announcement and
|
|
37
|
+
* find-in-page expansion. This element only adds what native leaves out:
|
|
38
|
+
*
|
|
39
|
+
* - `exclusive`: assigns a shared `name` so only one panel stays open
|
|
40
|
+
* - arrow/Home/End navigation between headers (APG accordion, optional but recommended)
|
|
41
|
+
* - opening the panel that contains the current URL fragment
|
|
42
|
+
* - an `accordion-toggle` event on the group, since `toggle` does not bubble
|
|
43
|
+
*
|
|
44
|
+
* Light DOM, no shadow root, so every part stays stylable by the page author.
|
|
45
|
+
*
|
|
46
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
|
|
47
|
+
*/
|
|
48
|
+
export class AccordionElemental extends ElementBase {
|
|
49
|
+
/** Direct-child panels only, so a nested accordion is not swallowed. */
|
|
50
|
+
get panels() {
|
|
51
|
+
return Array.from(this.querySelectorAll(':scope > details'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `<summary>` of each panel, in document order. */
|
|
55
|
+
get headers() {
|
|
56
|
+
return this.panels
|
|
57
|
+
.map((panel) => panel.querySelector(':scope > summary'))
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
connectedCallback() {
|
|
62
|
+
// Wait until the light-DOM panels have been parsed. The bundle is loaded
|
|
63
|
+
// deferred or at the end of the body, so by upgrade time they are there.
|
|
64
|
+
if (this.initialized || !this.panels.length) return;
|
|
65
|
+
this.initialized = true;
|
|
66
|
+
|
|
67
|
+
this.options = Object.assign({ exclusive: false }, readOptions(this, OPTIONS));
|
|
68
|
+
|
|
69
|
+
this.onKeyDown = this.onKeyDown.bind(this);
|
|
70
|
+
this.onToggle = this.onToggle.bind(this);
|
|
71
|
+
this.onHashChange = this.onHashChange.bind(this);
|
|
72
|
+
|
|
73
|
+
if (this.options.exclusive) this.applyExclusive();
|
|
74
|
+
this.addCarets();
|
|
75
|
+
|
|
76
|
+
this.addEventListener('keydown', this.onKeyDown);
|
|
77
|
+
// `toggle` does not bubble, but non-bubbling events still run the capture
|
|
78
|
+
// phase through ancestors - so capture is how the group hears its panels.
|
|
79
|
+
this.addEventListener('toggle', this.onToggle, true);
|
|
80
|
+
window.addEventListener('hashchange', this.onHashChange);
|
|
81
|
+
|
|
82
|
+
this.openFromHash();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
disconnectedCallback() {
|
|
86
|
+
if (!this.initialized) return;
|
|
87
|
+
this.removeEventListener('keydown', this.onKeyDown);
|
|
88
|
+
this.removeEventListener('toggle', this.onToggle, true);
|
|
89
|
+
window.removeEventListener('hashchange', this.onHashChange);
|
|
90
|
+
this.initialized = false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Give every panel the same `name`, which is what makes native `<details>`
|
|
95
|
+
* mutually exclusive.
|
|
96
|
+
*/
|
|
97
|
+
applyExclusive() {
|
|
98
|
+
const panels = this.panels;
|
|
99
|
+
|
|
100
|
+
// Close extras *before* naming them: assigning a shared name to several
|
|
101
|
+
// already-open panels leaves it to the browser which one survives. Doing it
|
|
102
|
+
// here means the first authored `open` wins, deterministically.
|
|
103
|
+
let seenOpen = false;
|
|
104
|
+
for (const panel of panels) {
|
|
105
|
+
if (!panel.open) continue;
|
|
106
|
+
if (seenOpen) panel.open = false;
|
|
107
|
+
seenOpen = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Remember the generated name so moving the group in the DOM re-uses it
|
|
111
|
+
// instead of minting a new one on every reconnect.
|
|
112
|
+
if (!this.groupName) {
|
|
113
|
+
this.groupName = this.getAttribute('name') || 'accordion-elemental-' + (++groupCount);
|
|
114
|
+
}
|
|
115
|
+
for (const panel of panels) panel.name = this.groupName;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Append the caret to every header that has not got one, so authors write
|
|
120
|
+
* plain `<summary>` and still get a marker. Writing the span yourself is
|
|
121
|
+
* respected and left alone - that is also how you keep the caret without
|
|
122
|
+
* JavaScript.
|
|
123
|
+
*/
|
|
124
|
+
addCarets() {
|
|
125
|
+
for (const header of this.headers) {
|
|
126
|
+
if (header.querySelector(':scope > .' + CARET_CLASS)) continue;
|
|
127
|
+
header.insertAdjacentHTML('beforeend', CARET);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onKeyDown(e) {
|
|
132
|
+
const summary = e.target.closest && e.target.closest('summary');
|
|
133
|
+
if (!summary) return;
|
|
134
|
+
const headers = this.headers;
|
|
135
|
+
const current = headers.indexOf(summary);
|
|
136
|
+
// -1 means the summary belongs to a nested accordion; let it handle itself.
|
|
137
|
+
if (current === -1) return;
|
|
138
|
+
|
|
139
|
+
const next = nextIndex(current, e.key, headers.length);
|
|
140
|
+
if (next === null) return;
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
headers[next].focus();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
onToggle(e) {
|
|
146
|
+
const panel = e.target;
|
|
147
|
+
if (!this.panels.includes(panel)) return;
|
|
148
|
+
this.dispatchEvent(new CustomEvent('accordion-toggle', {
|
|
149
|
+
bubbles: true,
|
|
150
|
+
detail: { panel: panel, open: panel.open }
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onHashChange() {
|
|
155
|
+
this.openFromHash();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Open the panel containing the element the URL fragment points at, so a link
|
|
160
|
+
* to a single question lands on it opened.
|
|
161
|
+
*
|
|
162
|
+
* ponytail: Chrome already auto-expands `<details>` on fragment navigation;
|
|
163
|
+
* this covers the browsers that do not. Drop it once that is everywhere.
|
|
164
|
+
*/
|
|
165
|
+
openFromHash() {
|
|
166
|
+
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
167
|
+
if (!id) return;
|
|
168
|
+
const target = document.getElementById(id);
|
|
169
|
+
if (!target || !this.contains(target)) return;
|
|
170
|
+
|
|
171
|
+
// Walk up so a nested panel opens its ancestors too.
|
|
172
|
+
let panel = target.closest('details');
|
|
173
|
+
while (panel && this.contains(panel)) {
|
|
174
|
+
panel.open = true;
|
|
175
|
+
panel = panel.parentElement && panel.parentElement.closest('details');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
define('accordion-elemental', AccordionElemental);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--accordion-elemental-duration: 250ms;
|
|
3
|
+
--accordion-elemental-easing: ease;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
accordion-elemental {
|
|
7
|
+
display: block;
|
|
8
|
+
|
|
9
|
+
> details {
|
|
10
|
+
// Lets `block-size` animate to and from the `auto` keyword. Scoped here
|
|
11
|
+
// rather than on :root so the library does not change how the whole page
|
|
12
|
+
// interpolates sizes.
|
|
13
|
+
interpolate-size: allow-keywords;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
> details > summary {
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Open/close animation. Browsers without ::details-content drop this rule and
|
|
21
|
+
// fall back to native instant toggling, which is correct, just unanimated.
|
|
22
|
+
> details::details-content {
|
|
23
|
+
block-size: 0;
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
transition:
|
|
26
|
+
block-size var(--accordion-elemental-duration) var(--accordion-elemental-easing),
|
|
27
|
+
content-visibility var(--accordion-elemental-duration) var(--accordion-elemental-easing) allow-discrete;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
> details[open]::details-content {
|
|
31
|
+
block-size: auto;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@media (prefers-reduced-motion: reduce) {
|
|
35
|
+
> details::details-content {
|
|
36
|
+
transition: none;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/iife.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The "whole book" script-tag build. Importing an element module registers it,
|
|
2
|
+
// so including this file is all there is to it - nothing lands on `window` and
|
|
3
|
+
// there is no instantiation call, because there is no API to call.
|
|
4
|
+
//
|
|
5
|
+
// For one element, include its own bundle instead: dist/elementals/accordion.min.js
|
|
6
|
+
import './elementals/accordion/index.js';
|
package/src/index.js
ADDED