@xoong/controls 0.0.36 → 0.0.41
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/package.json +10 -2
- package/src/accordion.js +51 -0
- package/src/carousel.js +2 -1
- package/src/chart.js +85 -0
- package/src/command.js +99 -0
- package/src/consent.js +64 -0
- package/src/controls.css +12 -0
- package/src/file.js +115 -0
- package/src/image.js +84 -0
- package/src/index.js +8 -0
- package/src/layer.js +14 -8
- package/src/popover.js +72 -0
- package/src/switch.js +19 -4
- package/src/theme-toggle.js +68 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xoong/controls",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
4
4
|
"description": "xoong form and page controls: tabs, popover, tooltip, menu, combobox, chips, tag input, carousel. Light DOM, accessible, zero dependencies besides @xoong/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -8,10 +8,17 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"default": "./src/index.js"
|
|
10
10
|
},
|
|
11
|
+
"./accordion": "./src/accordion.js",
|
|
11
12
|
"./carousel": "./src/carousel.js",
|
|
13
|
+
"./chart": "./src/chart.js",
|
|
14
|
+
"./command": "./src/command.js",
|
|
15
|
+
"./consent": "./src/consent.js",
|
|
16
|
+
"./file": "./src/file.js",
|
|
17
|
+
"./popover": "./src/popover.js",
|
|
12
18
|
"./combobox": "./src/combobox.js",
|
|
13
19
|
"./copy": "./src/copy.js",
|
|
14
20
|
"./date": "./src/date.js",
|
|
21
|
+
"./image": "./src/image.js",
|
|
15
22
|
"./password": "./src/password.js",
|
|
16
23
|
"./slider": "./src/slider.js",
|
|
17
24
|
"./stepper": "./src/stepper.js",
|
|
@@ -19,6 +26,7 @@
|
|
|
19
26
|
"./menu": "./src/menu.js",
|
|
20
27
|
"./switch": "./src/switch.js",
|
|
21
28
|
"./tabs": "./src/tabs.js",
|
|
29
|
+
"./theme-toggle": "./src/theme-toggle.js",
|
|
22
30
|
"./time": "./src/time.js",
|
|
23
31
|
"./tooltip": "./src/tooltip.js"
|
|
24
32
|
},
|
|
@@ -47,7 +55,7 @@
|
|
|
47
55
|
},
|
|
48
56
|
"homepage": "https://github.com/fpt-duongtx/xoong#readme",
|
|
49
57
|
"dependencies": {
|
|
50
|
-
"@xoong/core": "0.0.
|
|
58
|
+
"@xoong/core": "0.0.41"
|
|
51
59
|
},
|
|
52
60
|
"publishConfig": {
|
|
53
61
|
"access": "public"
|
package/src/accordion.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-accordion> turns a stack of native <details> into an accordion.
|
|
4
|
+
*
|
|
5
|
+
* <xo-accordion single>
|
|
6
|
+
* <details><summary>What is xoong?</summary><div class="xo-accordion__body">A small framework.</div></details>
|
|
7
|
+
* <details open><summary>Does it need a build?</summary><div class="xo-accordion__body">No.</div></details>
|
|
8
|
+
* </xo-accordion>
|
|
9
|
+
*
|
|
10
|
+
* - The children stay native <details>: Enter and Space on the summary work as the browser does. `single` allows one open at a time
|
|
11
|
+
* (the native `name` attribute of <details>). ArrowUp, ArrowDown, Home and End move between the summaries.
|
|
12
|
+
* - Each <details> gets the class `xo-accordion` and a body class if you leave it out is up to you; `xo-accordion-change` fires with `{ index, open }`.
|
|
13
|
+
*/
|
|
14
|
+
import { XoElement, define } from '@xoong/core';
|
|
15
|
+
|
|
16
|
+
let counter = 0;
|
|
17
|
+
|
|
18
|
+
class XoAccordion extends XoElement {
|
|
19
|
+
static props = { single: Boolean };
|
|
20
|
+
#name = `xo-accordion-${++counter}`;
|
|
21
|
+
|
|
22
|
+
connected() {
|
|
23
|
+
this.addEventListener('toggle', (e) => {
|
|
24
|
+
const d = e.target;
|
|
25
|
+
if (d instanceof HTMLDetailsElement && d.parentElement === this) this.dispatchEvent(new CustomEvent('xo-accordion-change', { bubbles: true, detail: { index: this.#items.indexOf(d), open: d.open } }));
|
|
26
|
+
}, true);
|
|
27
|
+
this.addEventListener('keydown', (e) => this.#key(e));
|
|
28
|
+
this.#setup();
|
|
29
|
+
new MutationObserver(() => this.#setup()).observe(this, { childList: true });
|
|
30
|
+
}
|
|
31
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#setup(); }
|
|
32
|
+
|
|
33
|
+
get #items() { return /** @type {HTMLDetailsElement[]} */ ([...this.children].filter((c) => c instanceof HTMLDetailsElement)); }
|
|
34
|
+
#setup() {
|
|
35
|
+
for (const d of this.#items) {
|
|
36
|
+
d.classList.add('xo-accordion');
|
|
37
|
+
if (this.single) d.setAttribute('name', this.#name); else if (d.getAttribute('name') === this.#name) d.removeAttribute('name');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
#key(/** @type {KeyboardEvent} */ e) {
|
|
41
|
+
const t = e.target;
|
|
42
|
+
if (!(t instanceof HTMLElement) || t.localName !== 'summary') return;
|
|
43
|
+
const sums = this.#items.map((d) => /** @type {HTMLElement} */ (d.querySelector(':scope > summary')));
|
|
44
|
+
const i = sums.indexOf(t);
|
|
45
|
+
const to = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: sums.length - 1 }[e.key];
|
|
46
|
+
if (to === undefined || i < 0) return;
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
sums[(to + sums.length) % sums.length]?.focus();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
define('xo-accordion', XoAccordion);
|
package/src/carousel.js
CHANGED
|
@@ -153,7 +153,8 @@ class XoCarousel extends XoElement {
|
|
|
153
153
|
const index = this.#index;
|
|
154
154
|
slides.forEach((slide, i) => {
|
|
155
155
|
if (!slide.hasAttribute('aria-label') || slide.hasAttribute('data-xo-label')) { slide.setAttribute('aria-label', text('xoong.carousel.slide', '{n} of {count}', { n: i + 1, count: n })); slide.setAttribute('data-xo-label', ''); }
|
|
156
|
-
slide.
|
|
156
|
+
if (slide.localName === 'article') slide.removeAttribute('role'); // an <article> already has a role, and group is not allowed on it (axe aria-allowed-role); the roledescription and label stay
|
|
157
|
+
else slide.setAttribute('role', 'group');
|
|
157
158
|
slide.setAttribute('aria-roledescription', 'slide');
|
|
158
159
|
if (laidOut) { // a slide completely out of view is not reachable by Tab or by a reader's browse mode; a peeking one is
|
|
159
160
|
const a = slide.getBoundingClientRect(), b = track.getBoundingClientRect();
|
package/src/chart.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-chart> draws a small bar or line chart from data, with the same numbers as a table for everyone who prefers or needs it.
|
|
4
|
+
*
|
|
5
|
+
* <xo-chart type="bar" label="Visits per period" unit="k" data='[{"label":"Mon","value":12},{"label":"Tue","value":18}]'></xo-chart>
|
|
6
|
+
* el.rows = [{ label: 'Mon', value: 12 }, ...] // or numbers: [12, 18], labelled 1, 2, ...
|
|
7
|
+
*
|
|
8
|
+
* - `type` is `bar` (default) or `line`. The SVG has role=img and the label; in a bar chart each bar is focusable and named ("Mon: 12k").
|
|
9
|
+
* - "View as table" (a native <details>) lists the same data. `unit` is appended to the values; `heading` and `value-heading` name the columns.
|
|
10
|
+
* - Colours come from the theme (classes `grid`, `axis`, `bar`, `line`, `area`, `dot` inside `.xo-chart`); `rows` can be set again at any time.
|
|
11
|
+
*/
|
|
12
|
+
import { XoElement, define, text } from '@xoong/core';
|
|
13
|
+
|
|
14
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
15
|
+
const W = 640, H = 220, L = 36, B = 24, T = 8;
|
|
16
|
+
/** A round number at or above `v`, for the top of the axis. */
|
|
17
|
+
const nice = (/** @type {number} */ v) => { if (v <= 0) return 1; const p = 10 ** Math.floor(Math.log10(v)); const f = v / p; return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * p; };
|
|
18
|
+
|
|
19
|
+
class XoChart extends XoElement {
|
|
20
|
+
static props = { type: String, label: String, unit: String, heading: String, valueHeading: String, data: String };
|
|
21
|
+
|
|
22
|
+
/** @type {{ label: string, value: number }[]} */ #rows = [];
|
|
23
|
+
#built = false;
|
|
24
|
+
|
|
25
|
+
get rows() { return this.#rows; }
|
|
26
|
+
set rows(v) { this.#rows = (Array.isArray(v) ? v : []).map((r, i) => (typeof r === 'number' ? { label: String(i + 1), value: r } : { label: String(r.label), value: Number(r.value) })); if (this.#built) this.#draw(); }
|
|
27
|
+
|
|
28
|
+
connected() {
|
|
29
|
+
if (!this.#rows.length && this.data) { try { this.rows = JSON.parse(this.data); } catch { /* not JSON: no data */ } }
|
|
30
|
+
this.#built = true;
|
|
31
|
+
this.#draw();
|
|
32
|
+
}
|
|
33
|
+
attributeChangedCallback(attr, old, value) {
|
|
34
|
+
super.attributeChangedCallback(attr, old, value);
|
|
35
|
+
if (attr === 'data' && this.#built) { try { this.rows = JSON.parse(value ?? '[]'); } catch { this.rows = []; } } else if (this.#built) this.#draw();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#draw() {
|
|
39
|
+
const rows = this.#rows;
|
|
40
|
+
const unit = this.unit ?? '';
|
|
41
|
+
const el = (/** @type {string} */ tag, /** @type {Record<string, string | number>} */ attrs = {}) => { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v)); return e; };
|
|
42
|
+
const svg = /** @type {SVGSVGElement} */ (el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': this.label ?? '' }));
|
|
43
|
+
const top = nice(Math.max(0, ...rows.map((r) => r.value)));
|
|
44
|
+
const y = (/** @type {number} */ v) => T + (H - B - T) * (1 - v / top);
|
|
45
|
+
for (let i = 0; i <= 4; i++) {
|
|
46
|
+
const v = (top / 4) * i;
|
|
47
|
+
svg.append(el('line', { class: 'grid', x1: L, x2: W, y1: y(v), y2: y(v) }));
|
|
48
|
+
const t = el('text', { class: 'axis', x: L - 6, y: y(v) + 4, 'text-anchor': 'end' }); t.textContent = `${+v.toFixed(2)}${unit}`; svg.append(t);
|
|
49
|
+
}
|
|
50
|
+
const step = (W - L) / Math.max(1, rows.length);
|
|
51
|
+
const cx = (/** @type {number} */ i) => L + step * (i + 0.5);
|
|
52
|
+
const every = Math.ceil(rows.length / 12); // at most about twelve labels on the axis
|
|
53
|
+
rows.forEach((r, i) => { if (i % every) return; const t = el('text', { class: 'axis', x: cx(i), y: H - 6, 'text-anchor': 'middle' }); t.textContent = r.label; svg.append(t); });
|
|
54
|
+
if (this.type === 'line' && rows.length) {
|
|
55
|
+
const pts = rows.map((r, i) => `${cx(i)},${y(r.value)}`);
|
|
56
|
+
svg.append(el('polygon', { class: 'area', points: `${cx(0)},${y(0)} ${pts.join(' ')} ${cx(rows.length - 1)},${y(0)}` }), el('polyline', { class: 'line', points: pts.join(' ') }));
|
|
57
|
+
rows.forEach((r, i) => svg.append(el('circle', { class: 'dot', cx: cx(i), cy: y(r.value), r: 3.5 })));
|
|
58
|
+
} else {
|
|
59
|
+
const w = Math.min(48, step * 0.6);
|
|
60
|
+
rows.forEach((r, i) => {
|
|
61
|
+
const bar = el('rect', { class: 'bar', x: cx(i) - w / 2, y: y(r.value), width: w, height: Math.max(0, y(0) - y(r.value)), rx: 3, tabindex: 0, role: 'img', 'aria-label': `${r.label}: ${r.value}${unit}` });
|
|
62
|
+
const title = el('title'); title.textContent = `${r.label}: ${r.value}${unit}`; bar.append(title);
|
|
63
|
+
svg.append(bar);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const fig = document.createElement('figure');
|
|
67
|
+
fig.className = 'xo-chart';
|
|
68
|
+
fig.append(svg);
|
|
69
|
+
const d = document.createElement('details');
|
|
70
|
+
const s = document.createElement('summary');
|
|
71
|
+
s.className = 'button ghost sm'; s.textContent = text('xoong.chart.table', 'View as table');
|
|
72
|
+
const table = document.createElement('table');
|
|
73
|
+
table.className = 'std';
|
|
74
|
+
const cell = (/** @type {string} */ tag, /** @type {string} */ t) => { const c = document.createElement(tag); c.textContent = t; return c; };
|
|
75
|
+
const head = document.createElement('tr'); head.append(cell('th', this.heading || text('xoong.chart.label', 'Label')), cell('th', this.valueHeading || text('xoong.chart.value', 'Value')));
|
|
76
|
+
const thead = document.createElement('thead'); thead.append(head);
|
|
77
|
+
const tbody = document.createElement('tbody');
|
|
78
|
+
for (const r of rows) { const tr = document.createElement('tr'); tr.append(cell('td', r.label), cell('td', `${r.value}${unit}`)); tbody.append(tr); }
|
|
79
|
+
table.append(thead, tbody);
|
|
80
|
+
d.append(s, table);
|
|
81
|
+
fig.append(d);
|
|
82
|
+
this.replaceChildren(fig);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
define('xo-chart', XoChart);
|
package/src/command.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-command> is a command palette: press Ctrl/Cmd+K, type, pick with the arrows, press Enter.
|
|
4
|
+
*
|
|
5
|
+
* <xo-command shortcut="k" label="Search commands">
|
|
6
|
+
* <a href="/articles" data-group="Go to">Articles</a>
|
|
7
|
+
* <button data-group="Actions" data-value="new-article">New article</button>
|
|
8
|
+
* </xo-command>
|
|
9
|
+
*
|
|
10
|
+
* - The children are the commands (links or buttons; `data-group` names the group; an icon inside is kept). They are listed in a dialog;
|
|
11
|
+
* typing filters them by their text. ArrowUp/ArrowDown (wrapping) move the highlight (`aria-activedescendant`), Home/End jump, Enter runs it, Escape closes.
|
|
12
|
+
* - Running a command clicks its original element (a link navigates, a button runs its own handler or `xo-click`) and fires
|
|
13
|
+
* `xo-command-run` with `{ value, label }` (`value` is `data-value`, or the href).
|
|
14
|
+
* - `open()` and `close()`; the shortcut is Ctrl/Cmd plus the key of `shortcut` (default `k`).
|
|
15
|
+
*/
|
|
16
|
+
import { XoElement, define, text } from '@xoong/core';
|
|
17
|
+
|
|
18
|
+
let counter = 0;
|
|
19
|
+
|
|
20
|
+
class XoCommand extends XoElement {
|
|
21
|
+
static props = { shortcut: String, label: String };
|
|
22
|
+
|
|
23
|
+
#id = `xo-command-${++counter}`;
|
|
24
|
+
/** @type {HTMLDialogElement | null} */ #dialog = null;
|
|
25
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
26
|
+
/** @type {HTMLElement | null} */ #list = null;
|
|
27
|
+
/** @type {HTMLElement[]} */ #items = [];
|
|
28
|
+
/** @type {{ el: HTMLElement, label: string, group: string, html: string }[]} */ #commands = [];
|
|
29
|
+
#active = 0;
|
|
30
|
+
#onKey = (/** @type {KeyboardEvent} */ e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === (this.shortcut || 'k').toLowerCase()) { e.preventDefault(); this.open(); } };
|
|
31
|
+
|
|
32
|
+
connected() { void this.ready().then(() => this.#init()); document.addEventListener('keydown', this.#onKey); } // the commands are the children: wait until the parser has them
|
|
33
|
+
|
|
34
|
+
#init() {
|
|
35
|
+
if (this.#dialog) return;
|
|
36
|
+
this.#commands = [...this.children].filter((c) => c instanceof HTMLElement).map((el) => ({ el: /** @type {HTMLElement} */ (el), label: (el.textContent ?? '').trim(), group: el.dataset.group ?? '', html: el.innerHTML }));
|
|
37
|
+
for (const c of this.#commands) c.el.hidden = true;
|
|
38
|
+
const dialog = document.createElement('dialog');
|
|
39
|
+
dialog.className = 'xo-command-dialog';
|
|
40
|
+
dialog.setAttribute('aria-label', this.label || text('xoong.command.label', 'Search commands'));
|
|
41
|
+
dialog.innerHTML = `<div class="xo-float xo-command"><div class="xo-command__input"><span class="ic" data-ic="search" aria-hidden="true"></span><input role="combobox" aria-expanded="true" aria-autocomplete="list" autocomplete="off"><span class="xo-kbd" aria-hidden="true">Esc</span></div><div class="xo-listbox" role="listbox"></div></div>`;
|
|
42
|
+
const input = /** @type {HTMLInputElement} */ (dialog.querySelector('input'));
|
|
43
|
+
const list = /** @type {HTMLElement} */ (dialog.querySelector('.xo-listbox'));
|
|
44
|
+
list.id = `${this.#id}-list`; list.setAttribute('aria-label', text('xoong.command.results', 'Results'));
|
|
45
|
+
input.setAttribute('aria-controls', list.id);
|
|
46
|
+
input.setAttribute('aria-label', this.label || text('xoong.command.label', 'Search commands'));
|
|
47
|
+
input.placeholder = text('xoong.command.placeholder', 'Type a command or page');
|
|
48
|
+
input.addEventListener('input', () => { this.#active = 0; this.#render(); });
|
|
49
|
+
input.addEventListener('keydown', (e) => this.#key(e));
|
|
50
|
+
dialog.addEventListener('click', (e) => { if (e.target === dialog) this.close(); }); // the backdrop
|
|
51
|
+
dialog.addEventListener('close', () => { input.value = ''; });
|
|
52
|
+
this.#dialog = dialog; this.#input = input; this.#list = list;
|
|
53
|
+
this.append(dialog);
|
|
54
|
+
}
|
|
55
|
+
disconnected() { document.removeEventListener('keydown', this.#onKey); }
|
|
56
|
+
|
|
57
|
+
open() { if (!this.#dialog || this.#dialog.open) return; this.#active = 0; this.#render(); this.#dialog.showModal(); this.#input?.focus(); }
|
|
58
|
+
close() { this.#dialog?.close(); }
|
|
59
|
+
|
|
60
|
+
#render() {
|
|
61
|
+
const q = (this.#input?.value ?? '').trim().toLowerCase();
|
|
62
|
+
const shown = this.#commands.filter((c) => !q || c.label.toLowerCase().includes(q));
|
|
63
|
+
const list = /** @type {HTMLElement} */ (this.#list);
|
|
64
|
+
list.replaceChildren();
|
|
65
|
+
this.#items = [];
|
|
66
|
+
if (!shown.length) { const p = document.createElement('p'); p.className = 'xo-option__group'; p.textContent = text('xoong.controls.noResults', 'No results'); list.append(p); }
|
|
67
|
+
let group = null;
|
|
68
|
+
shown.forEach((c, i) => {
|
|
69
|
+
if (c.group && c.group !== group) { group = c.group; const g = document.createElement('div'); g.className = 'xo-option__group'; g.setAttribute('role', 'presentation'); g.textContent = c.group; list.append(g); }
|
|
70
|
+
const o = document.createElement('button');
|
|
71
|
+
o.type = 'button'; o.className = 'xo-option'; o.setAttribute('role', 'option'); o.tabIndex = -1; o.id = `${this.#id}-o${i}`; o.innerHTML = c.html;
|
|
72
|
+
o.addEventListener('click', () => this.#run(c));
|
|
73
|
+
o.addEventListener('pointermove', () => { this.#active = i; this.#mark(); });
|
|
74
|
+
list.append(o);
|
|
75
|
+
this.#items.push(o);
|
|
76
|
+
});
|
|
77
|
+
this.#shown = shown;
|
|
78
|
+
this.#mark();
|
|
79
|
+
}
|
|
80
|
+
/** @type {typeof this.#commands} */ #shown = [];
|
|
81
|
+
#mark() {
|
|
82
|
+
this.#items.forEach((o, i) => { const on = i === this.#active; o.classList.toggle('xo-option--active', on); o.setAttribute('aria-selected', String(on)); if (on) o.scrollIntoView?.({ block: 'nearest' }); });
|
|
83
|
+
const on = this.#items[this.#active];
|
|
84
|
+
if (on) this.#input?.setAttribute('aria-activedescendant', on.id); else this.#input?.removeAttribute('aria-activedescendant');
|
|
85
|
+
}
|
|
86
|
+
#key(/** @type {KeyboardEvent} */ e) {
|
|
87
|
+
const n = this.#items.length;
|
|
88
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) { this.#active = (this.#active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n; this.#mark(); } }
|
|
89
|
+
else if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); this.#active = e.key === 'Home' ? 0 : Math.max(0, n - 1); this.#mark(); }
|
|
90
|
+
else if (e.key === 'Enter') { e.preventDefault(); const c = this.#shown[this.#active]; if (c) this.#run(c); }
|
|
91
|
+
}
|
|
92
|
+
#run(/** @type {{ el: HTMLElement, label: string }} */ c) {
|
|
93
|
+
this.close();
|
|
94
|
+
const value = c.el.dataset.value ?? c.el.getAttribute('href') ?? c.label;
|
|
95
|
+
this.dispatchEvent(new CustomEvent('xo-command-run', { bubbles: true, detail: { value, label: c.label } }));
|
|
96
|
+
c.el.hidden = false; c.el.click(); c.el.hidden = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
define('xo-command', XoCommand);
|
package/src/consent.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-consent> is a cookie (or tracking) consent bar that remembers the choice.
|
|
4
|
+
*
|
|
5
|
+
* <xo-consent categories="analytics,marketing" label="Cookie consent">
|
|
6
|
+
* <b>We use two cookies.</b> One keeps you signed in, one measures anonymous visits. You choose.
|
|
7
|
+
* </xo-consent>
|
|
8
|
+
* <button xo-consent-open>Cookie settings</button>
|
|
9
|
+
*
|
|
10
|
+
* - The text inside is the message. Two buttons: "Essential only" (every category false) and "Accept all" (every category true).
|
|
11
|
+
* - The choice is saved in localStorage (`xoong-consent`, or `storage`) as `{ analytics: true, ... }`; the bar stays hidden until
|
|
12
|
+
* `open()` or a click on any element with `xo-consent-open`. `xo-consent` fires with the choices when the person chooses.
|
|
13
|
+
* - Read it anywhere with `el.choices` (null until chosen). Nothing is loaded or blocked by this element: your code decides what a choice allows.
|
|
14
|
+
*/
|
|
15
|
+
import { XoElement, define, text } from '@xoong/core';
|
|
16
|
+
|
|
17
|
+
class XoConsent extends XoElement {
|
|
18
|
+
static props = { categories: String, storage: String, label: String };
|
|
19
|
+
|
|
20
|
+
/** @type {HTMLElement | null} */ #bar = null;
|
|
21
|
+
#onOpen = (/** @type {Event} */ e) => { if (/** @type {Element} */ (e.target).closest?.('[xo-consent-open]')) this.open(); };
|
|
22
|
+
get #key() { return this.storage || 'xoong-consent'; }
|
|
23
|
+
get #names() { return (this.categories ?? '').split(',').map((s) => s.trim()).filter(Boolean); }
|
|
24
|
+
|
|
25
|
+
/** The saved choices, or null when the visitor has not chosen. */
|
|
26
|
+
get choices() { try { return JSON.parse(localStorage.getItem(this.#key) ?? 'null'); } catch { return null; } }
|
|
27
|
+
|
|
28
|
+
connected() { document.addEventListener('click', this.#onOpen); void this.ready().then(() => this.#init()); } // the message is the children: wait until the parser has them
|
|
29
|
+
|
|
30
|
+
#init() {
|
|
31
|
+
if (this.#bar) return;
|
|
32
|
+
const message = document.createElement('p');
|
|
33
|
+
message.append(...this.childNodes);
|
|
34
|
+
const row = document.createElement('div');
|
|
35
|
+
row.className = 'row-inline';
|
|
36
|
+
const make = (/** @type {string} */ label, /** @type {boolean} */ all, /** @type {boolean} */ primary) => {
|
|
37
|
+
const b = document.createElement('button');
|
|
38
|
+
b.type = 'button'; b.className = `button${primary ? ' primary' : ''}`; b.textContent = label;
|
|
39
|
+
b.addEventListener('click', () => this.#choose(all));
|
|
40
|
+
return b;
|
|
41
|
+
};
|
|
42
|
+
row.append(make(text('xoong.consent.essential', 'Essential only'), false, false), make(text('xoong.consent.all', 'Accept all'), true, true));
|
|
43
|
+
const bar = document.createElement('div');
|
|
44
|
+
bar.className = 'xo-consent';
|
|
45
|
+
bar.setAttribute('role', 'region');
|
|
46
|
+
bar.setAttribute('aria-label', this.label || text('xoong.consent.label', 'Cookie consent'));
|
|
47
|
+
bar.append(message, row);
|
|
48
|
+
this.#bar = bar;
|
|
49
|
+
this.replaceChildren(bar);
|
|
50
|
+
bar.hidden = this.choices !== null;
|
|
51
|
+
}
|
|
52
|
+
disconnected() { document.removeEventListener('click', this.#onOpen); }
|
|
53
|
+
|
|
54
|
+
/** Show the bar again (cookie settings). */
|
|
55
|
+
open() { if (this.#bar) { this.#bar.hidden = false; /** @type {HTMLElement | null} */ (this.#bar.querySelector('button.primary'))?.focus(); } }
|
|
56
|
+
|
|
57
|
+
#choose(/** @type {boolean} */ all) {
|
|
58
|
+
const choices = Object.fromEntries(this.#names.map((n) => [n, all]));
|
|
59
|
+
try { localStorage.setItem(this.#key, JSON.stringify(choices)); } catch { /* not saved */ }
|
|
60
|
+
if (this.#bar) this.#bar.hidden = true;
|
|
61
|
+
this.dispatchEvent(new CustomEvent('xo-consent', { bubbles: true, detail: choices }));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
define('xo-consent', XoConsent);
|
package/src/controls.css
CHANGED
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
xo-tabs > [role="tabpanel"] { padding-top: var(--xo-space-3, .75rem); }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
@layer xoong-controls {
|
|
12
|
+
xo-image { display: block; position: relative; overflow: hidden; max-width: 100%; }
|
|
13
|
+
xo-image > img { display: block; width: 100%; height: 100%; object-fit: var(--xo-image-fit, cover); object-position: var(--xo-image-position, center); }
|
|
14
|
+
xo-image[data-state="loading"] { background: var(--xo-color-subtle, #eef0f3); }
|
|
15
|
+
xo-image[data-state="loading"] > img { opacity: 0; }
|
|
16
|
+
xo-image > img { transition: opacity .2s; }
|
|
17
|
+
@media (prefers-reduced-motion: reduce) { xo-image > img { transition: none; } }
|
|
18
|
+
xo-image[data-state="empty"] > img, xo-image[data-state="error"] > img { display: none; }
|
|
19
|
+
xo-image[data-state="empty"], xo-image[data-state="error"] { background: var(--xo-color-subtle, #eef0f3); min-height: 3rem; }
|
|
20
|
+
xo-image .xo-image-note { position: absolute; inset: 0; display: grid; place-items: center; padding: .5rem; text-align: center; font-size: .85rem; color: var(--xo-color-muted, #5b6470); }
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
@layer xoong-controls {
|
|
12
24
|
xo-menu { display: inline-block; }
|
|
13
25
|
xo-menu [role="menu"] { min-width: 10rem; padding: var(--xo-space-1, .25rem); background: var(--xo-color-surface, #fff); color: inherit; border: 1px solid var(--xo-color-border, #d8dee4); border-radius: var(--xo-radius, 6px); box-shadow: 0 6px 18px rgb(0 0 0 / .15); }
|
package/src/file.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-file> is a file field with a drop zone: drag a file onto it, or click to browse.
|
|
4
|
+
*
|
|
5
|
+
* <xo-file name="logo" label="Logo" accept="image/*" max-size="2MB" hint="PNG or SVG, up to 2 MB"></xo-file>
|
|
6
|
+
* <xo-file name="attachments" multiple></xo-file>
|
|
7
|
+
*
|
|
8
|
+
* - The value is a `File` (with `multiple`: an array of files); a form sends it as multipart. `accept` takes the native syntax (`image/*`, `.pdf`);
|
|
9
|
+
* `max-size` is bytes or `2MB`, `500KB`. A refused file shows a message and fires `xo-file-reject` with `{ file, reason }` (`type` or `size`).
|
|
10
|
+
* - Images get a thumbnail. Each file has a Remove button. `el.progress = 0.4` shows an upload progress bar (`null` hides it).
|
|
11
|
+
* - The real <input type=file> stays in the zone, visually hidden but focusable: Enter and Space open the file dialog.
|
|
12
|
+
*/
|
|
13
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
14
|
+
|
|
15
|
+
const UNITS = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 };
|
|
16
|
+
const bytes = (/** @type {string | undefined} */ v) => { const m = /^\s*([\d.]+)\s*([kmg]?b)?\s*$/i.exec(v ?? ''); return m ? Math.round(Number(m[1]) * UNITS[/** @type {keyof typeof UNITS} */ ((m[2] ?? 'b').toLowerCase())]) : 0; };
|
|
17
|
+
const matches = (/** @type {File} */ f, /** @type {string | undefined} */ accept) => !accept || accept.split(',').map((s) => s.trim().toLowerCase()).some((a) => a.startsWith('.') ? f.name.toLowerCase().endsWith(a) : a.endsWith('/*') ? f.type.startsWith(a.slice(0, -1)) : f.type === a);
|
|
18
|
+
|
|
19
|
+
class XoFile extends XoControl {
|
|
20
|
+
static props = { ...XoControl.props, label: String, accept: String, maxSize: String, multiple: Boolean, hint: String };
|
|
21
|
+
|
|
22
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
23
|
+
/** @type {HTMLElement | null} */ #zone = null;
|
|
24
|
+
/** @type {HTMLElement | null} */ #list = null;
|
|
25
|
+
/** @type {HTMLElement | null} */ #error = null;
|
|
26
|
+
/** @type {HTMLProgressElement | null} */ #bar = null;
|
|
27
|
+
/** @type {string[]} */ #urls = [];
|
|
28
|
+
|
|
29
|
+
get #files() { const v = super.value; return /** @type {File[]} */ (Array.isArray(v) ? v : v ? [v] : []); }
|
|
30
|
+
setValue(v) { super.setValue(this.multiple ? (Array.isArray(v) ? v : v ? [v] : []) : Array.isArray(v) ? v[0] ?? '' : v ?? ''); this.#paintList(); }
|
|
31
|
+
serialize(v) {
|
|
32
|
+
if (!Array.isArray(v)) return super.serialize(v);
|
|
33
|
+
const data = new FormData();
|
|
34
|
+
for (const f of v) data.append(this.name, f);
|
|
35
|
+
return v.length ? data : null;
|
|
36
|
+
}
|
|
37
|
+
isEmpty(v) { return !v || (Array.isArray(v) && !v.length); }
|
|
38
|
+
get focusTarget() { return this.#input; }
|
|
39
|
+
|
|
40
|
+
/** Upload progress from 0 to 1, or null to hide the bar. */
|
|
41
|
+
get progress() { return this.#bar && !this.#bar.hidden ? this.#bar.value : null; }
|
|
42
|
+
set progress(p) { if (!this.#bar) return; this.#bar.hidden = p == null; if (p != null) this.#bar.value = Math.min(1, Math.max(0, p)); }
|
|
43
|
+
|
|
44
|
+
connected() {
|
|
45
|
+
const zone = document.createElement('label');
|
|
46
|
+
zone.className = 'xo-dropzone';
|
|
47
|
+
const input = document.createElement('input');
|
|
48
|
+
input.type = 'file';
|
|
49
|
+
input.accept = this.accept ?? '';
|
|
50
|
+
input.multiple = this.multiple;
|
|
51
|
+
input.setAttribute('aria-label', this.label || this.name || text('xoong.controls.file', 'File'));
|
|
52
|
+
zone.innerHTML = `<span class="ic" data-ic="upload" aria-hidden="true"></span><span><b>${this.multiple ? text('xoong.file.dropMany', 'Drop files') : text('xoong.file.drop', 'Drop a file')}</b> ${text('xoong.file.browse', 'or click to browse')}</span>`;
|
|
53
|
+
if (this.hint) { const h = document.createElement('span'); h.textContent = this.hint; zone.append(h); }
|
|
54
|
+
zone.append(input);
|
|
55
|
+
input.addEventListener('change', () => { this.#take([...(input.files ?? [])]); input.value = ''; });
|
|
56
|
+
for (const type of ['dragenter', 'dragover']) zone.addEventListener(type, (e) => { e.preventDefault(); zone.toggleAttribute('data-over', true); });
|
|
57
|
+
for (const type of ['dragleave', 'drop']) zone.addEventListener(type, () => zone.removeAttribute('data-over'));
|
|
58
|
+
zone.addEventListener('drop', (e) => { e.preventDefault(); this.#take([...(/** @type {DragEvent} */ (e).dataTransfer?.files ?? [])]); });
|
|
59
|
+
const error = document.createElement('p');
|
|
60
|
+
error.className = 'error'; error.setAttribute('role', 'alert'); error.hidden = true;
|
|
61
|
+
const bar = document.createElement('progress');
|
|
62
|
+
bar.max = 1; bar.hidden = true; bar.setAttribute('aria-label', text('xoong.file.progress', 'Upload progress'));
|
|
63
|
+
const list = document.createElement('ul');
|
|
64
|
+
list.className = 'xo-file-list';
|
|
65
|
+
this.#input = input; this.#zone = zone; this.#error = error; this.#list = list; this.#bar = bar;
|
|
66
|
+
this.replaceChildren(zone, error, bar, list);
|
|
67
|
+
queueMicrotask(() => this.#paintList());
|
|
68
|
+
}
|
|
69
|
+
disconnected() { this.#revoke(); }
|
|
70
|
+
attributeChangedCallback(attr, old, value) {
|
|
71
|
+
super.attributeChangedCallback(attr, old, value);
|
|
72
|
+
if (this.#input) { this.#input.accept = this.accept ?? ''; this.#input.multiple = this.multiple; this.#input.disabled = this.isDisabled || this.readonly; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#take(/** @type {File[]} */ files) {
|
|
76
|
+
const limit = bytes(this.maxSize);
|
|
77
|
+
const ok = [];
|
|
78
|
+
const refuse = (/** @type {File} */ file, /** @type {'type' | 'size'} */ reason, /** @type {string} */ message) => {
|
|
79
|
+
this.#say(message);
|
|
80
|
+
this.dispatchEvent(new CustomEvent('xo-file-reject', { bubbles: true, detail: { file, reason } }));
|
|
81
|
+
};
|
|
82
|
+
this.#say('');
|
|
83
|
+
for (const f of this.multiple ? files : files.slice(0, 1)) {
|
|
84
|
+
if (!matches(f, this.accept)) refuse(f, 'type', text('xoong.file.type', '{name} is not an allowed file type.', { name: f.name }));
|
|
85
|
+
else if (limit && f.size > limit) refuse(f, 'size', text('xoong.file.size', '{name} is too large (at most {max}).', { name: f.name, max: this.maxSize }));
|
|
86
|
+
else ok.push(f);
|
|
87
|
+
}
|
|
88
|
+
if (!ok.length) return;
|
|
89
|
+
this.userInput(this.multiple ? [...this.#files, ...ok] : ok[0]);
|
|
90
|
+
this.userChange();
|
|
91
|
+
}
|
|
92
|
+
#say(/** @type {string} */ m) { if (this.#error) { this.#error.textContent = m; this.#error.hidden = !m; } }
|
|
93
|
+
#revoke() { for (const u of this.#urls.splice(0)) URL.revokeObjectURL?.(u); }
|
|
94
|
+
|
|
95
|
+
#paintList() {
|
|
96
|
+
const list = this.#list;
|
|
97
|
+
if (!list) return;
|
|
98
|
+
this.#revoke();
|
|
99
|
+
list.replaceChildren(...this.#files.map((f) => {
|
|
100
|
+
const li = document.createElement('li');
|
|
101
|
+
if (f.type.startsWith('image/') && typeof URL.createObjectURL === 'function') {
|
|
102
|
+
const url = URL.createObjectURL(f); this.#urls.push(url);
|
|
103
|
+
const img = document.createElement('img'); img.src = url; img.alt = ''; img.width = 40; img.height = 40;
|
|
104
|
+
li.append(img);
|
|
105
|
+
}
|
|
106
|
+
const name = document.createElement('span'); name.textContent = f.name;
|
|
107
|
+
const rm = document.createElement('button');
|
|
108
|
+
rm.type = 'button'; rm.className = 'ghost sm'; rm.textContent = text('xoong.file.remove', 'Remove'); rm.setAttribute('aria-label', text('xoong.controls.remove', 'Remove {label}', { label: f.name }));
|
|
109
|
+
rm.addEventListener('click', () => { this.userInput(this.multiple ? this.#files.filter((x) => x !== f) : ''); this.userChange(); this.#input?.focus(); });
|
|
110
|
+
li.append(name, rm);
|
|
111
|
+
return li;
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
define('xo-file', XoFile);
|
package/src/image.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-image> is an image that behaves well on a page: it loads lazily, keeps its space (no layout jump), shows a placeholder while it
|
|
4
|
+
* loads and something useful when it fails.
|
|
5
|
+
*
|
|
6
|
+
* <xo-image src="/img/cover.jpg" alt="The harbour at dusk" width="1200" height="800"></xo-image>
|
|
7
|
+
* <xo-image src="{cover.url}" alt="{cover.alt}" ratio="16/9" fit="cover" srcset="/s.jpg 480w, /l.jpg 1200w" sizes="(min-width: 60rem) 50vw, 100vw"></xo-image>
|
|
8
|
+
* <xo-image src="/hero.jpg" alt="" priority></xo-image>
|
|
9
|
+
*
|
|
10
|
+
* - `alt` is required: text for the image, or `alt=""` for a decorative one. `xoong check` warns when it is missing.
|
|
11
|
+
* - Lazy by default (`loading="lazy"`, `decoding="async"`). `priority` is for the one image above the fold: eager, high fetch priority.
|
|
12
|
+
* - The space is reserved from `width` and `height`, or from `ratio` (`16/9`, `1.5`). `fit` (cover by default, or contain) and `position` set how
|
|
13
|
+
* the picture fills it (the custom properties --xo-image-fit and --xo-image-position, set through the CSS object model: no style attribute).
|
|
14
|
+
* - `srcset` and `sizes` go to the <img> as they are. A `src` that changes (in an <xo-view>, with `src="{path}"`) loads again.
|
|
15
|
+
* - `data-state` on the element: `empty` (no src), `loading` (skeleton), `loaded`, `error`. On an error the `fallback` src is tried once;
|
|
16
|
+
* without one the alt text is shown in the box. `load` and `error` events are re-fired on the element (they do not bubble).
|
|
17
|
+
*/
|
|
18
|
+
import { XoElement, define } from '@xoong/core';
|
|
19
|
+
|
|
20
|
+
class XoImage extends XoElement {
|
|
21
|
+
static props = { src: String, srcset: String, sizes: String, alt: String, width: Number, height: Number, ratio: String, fit: String, position: String, priority: Boolean, loading: String, fallback: String };
|
|
22
|
+
|
|
23
|
+
/** @type {HTMLImageElement | null} */ #img = null;
|
|
24
|
+
/** @type {HTMLElement | null} */ #note = null;
|
|
25
|
+
#fellBack = false;
|
|
26
|
+
#warned = false;
|
|
27
|
+
|
|
28
|
+
connected() {
|
|
29
|
+
const img = document.createElement('img');
|
|
30
|
+
img.decoding = 'async';
|
|
31
|
+
img.addEventListener('load', () => { this.#state('loaded'); this.dispatchEvent(new Event('load')); });
|
|
32
|
+
img.addEventListener('error', () => this.#failed());
|
|
33
|
+
this.#img = img;
|
|
34
|
+
this.replaceChildren(img);
|
|
35
|
+
this.#paint();
|
|
36
|
+
}
|
|
37
|
+
attributeChangedCallback(attr, old, value) {
|
|
38
|
+
super.attributeChangedCallback(attr, old, value);
|
|
39
|
+
if (attr === 'src' || attr === 'srcset') this.#fellBack = false;
|
|
40
|
+
this.#paint();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#state(/** @type {string} */ s) {
|
|
44
|
+
this.dataset.state = s;
|
|
45
|
+
if (s !== 'error') { this.#note?.remove(); this.#note = null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#paint() {
|
|
49
|
+
const img = this.#img;
|
|
50
|
+
if (!img) return;
|
|
51
|
+
if (!this.hasAttribute('alt') && !this.#warned) { this.#warned = true; console.warn('xoong: <xo-image> needs an alt attribute (alt="" for a decorative image)', this.getAttribute('src')); }
|
|
52
|
+
img.alt = this.alt ?? '';
|
|
53
|
+
img.loading = this.priority ? 'eager' : this.loading === 'eager' ? 'eager' : 'lazy';
|
|
54
|
+
img.fetchPriority = this.priority ? 'high' : 'auto';
|
|
55
|
+
for (const k of /** @type {const} */ (['width', 'height'])) { if (this[k]) img.setAttribute(k, String(this[k])); else img.removeAttribute(k); }
|
|
56
|
+
if (this.sizes) img.sizes = this.sizes; else img.removeAttribute('sizes');
|
|
57
|
+
const ratio = this.ratio ? this.ratio.replace(/\s/g, '') : this.width && this.height ? `${this.width}/${this.height}` : '';
|
|
58
|
+
const set = (/** @type {string} */ k, /** @type {string | null | undefined} */ v) => (v ? this.style.setProperty(k, v) : this.style.removeProperty(k));
|
|
59
|
+
set('aspect-ratio', ratio);
|
|
60
|
+
set('--xo-image-fit', this.fit);
|
|
61
|
+
set('--xo-image-position', this.position);
|
|
62
|
+
const src = this.#fellBack ? this.fallback : this.src;
|
|
63
|
+
const srcset = this.#fellBack ? '' : this.srcset;
|
|
64
|
+
if (!src && !srcset) { img.removeAttribute('src'); img.removeAttribute('srcset'); this.#state('empty'); return; }
|
|
65
|
+
if (srcset) img.srcset = srcset; else img.removeAttribute('srcset');
|
|
66
|
+
if (src && img.getAttribute('src') !== src) { this.#state('loading'); img.src = src; }
|
|
67
|
+
else if (!src) this.#state('loading');
|
|
68
|
+
if (img.complete && img.naturalWidth > 0) this.#state('loaded'); // from the cache: there may be no load event
|
|
69
|
+
else if (!this.dataset.state || this.dataset.state === 'empty') this.#state('loading');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#failed() {
|
|
73
|
+
if (this.fallback && !this.#fellBack) { this.#fellBack = true; this.#paint(); return; }
|
|
74
|
+
this.#state('error');
|
|
75
|
+
if (!this.#note) {
|
|
76
|
+
this.#note = document.createElement('span');
|
|
77
|
+
this.#note.className = 'xo-image-note';
|
|
78
|
+
this.append(this.#note);
|
|
79
|
+
}
|
|
80
|
+
this.#note.textContent = this.alt ?? '';
|
|
81
|
+
this.dispatchEvent(new Event('error'));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
define('xo-image', XoImage);
|
package/src/index.js
CHANGED
|
@@ -11,3 +11,11 @@ import './slider.js';
|
|
|
11
11
|
import './stepper.js';
|
|
12
12
|
import './password.js';
|
|
13
13
|
import './copy.js';
|
|
14
|
+
import './image.js';
|
|
15
|
+
import './theme-toggle.js';
|
|
16
|
+
import './accordion.js';
|
|
17
|
+
import './popover.js';
|
|
18
|
+
import './consent.js';
|
|
19
|
+
import './file.js';
|
|
20
|
+
import './command.js';
|
|
21
|
+
import './chart.js';
|
package/src/layer.js
CHANGED
|
@@ -21,17 +21,23 @@ export function showLayer(panel, anchor, { matchWidth = false } = {}) {
|
|
|
21
21
|
panel.hidden = false;
|
|
22
22
|
const top = panel.hasAttribute('popover');
|
|
23
23
|
if (top && !panel.matches(':popover-open')) panel.showPopover();
|
|
24
|
+
const MARGIN = 8;
|
|
24
25
|
const place = () => {
|
|
25
26
|
if (!top) return;
|
|
26
27
|
const r = anchor.getBoundingClientRect();
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
Object.assign(panel.style, {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
const vw = globalThis.innerWidth || 1024;
|
|
29
|
+
const vh = globalThis.innerHeight || 768;
|
|
30
|
+
// measure at the natural size, then fit: first the side with room, then a height limit when neither side has enough
|
|
31
|
+
Object.assign(panel.style, { position: 'fixed', inset: 'auto', margin: '0', maxHeight: '', ...(matchWidth ? { width: `${r.width}px`, boxSizing: 'border-box' } : {}) });
|
|
32
|
+
const size = panel.getBoundingClientRect();
|
|
33
|
+
const below = vh - r.bottom - MARGIN;
|
|
34
|
+
const above = r.top - MARGIN;
|
|
35
|
+
const up = below < size.height && above > below; // not enough room below and more above: open upwards
|
|
36
|
+
const room = Math.max(up ? above : below, 80);
|
|
37
|
+
const height = Math.min(size.height, room);
|
|
38
|
+
if (size.height > room) panel.style.maxHeight = `${room}px`; // the panel scrolls (the lists already have overflow)
|
|
39
|
+
const left = Math.min(Math.max(r.left, MARGIN), Math.max(MARGIN, vw - size.width - MARGIN)); // keep it inside the viewport sideways
|
|
40
|
+
Object.assign(panel.style, { left: `${left}px`, top: up ? `${Math.max(MARGIN, r.top - height)}px` : `${r.bottom}px` });
|
|
35
41
|
};
|
|
36
42
|
place();
|
|
37
43
|
if (!top) return () => {};
|
package/src/popover.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-popover> is a small panel that opens from a button (notifications, a menu of settings, a mini form).
|
|
4
|
+
*
|
|
5
|
+
* <div class="xo-notify">
|
|
6
|
+
* <button aria-label="Notifications">...</button>
|
|
7
|
+
* <xo-popover label="Notifications">...</xo-popover>
|
|
8
|
+
* </div>
|
|
9
|
+
*
|
|
10
|
+
* - The trigger is the element named by `anchor` (a selector), or the element just before the popover. It gets `aria-expanded` and `aria-controls`.
|
|
11
|
+
* - A click on the trigger toggles it. Escape closes it and gives the focus back to the trigger; a click outside, or Tab out of the panel, closes it.
|
|
12
|
+
* - `open()`, `close()` and `toggle()`; the property/attribute `open`; `xo-popover-open` and `xo-popover-close` fire on the element.
|
|
13
|
+
* - The panel has the class `xo-popover` (the theme draws it) and `role="region"` named by `label`. With the Popover API it is drawn in the top layer under the
|
|
14
|
+
* trigger and kept inside the viewport (flips up when there is more room above, scrolls when neither side has enough).
|
|
15
|
+
*/
|
|
16
|
+
import { XoElement, define } from '@xoong/core';
|
|
17
|
+
import { prepareLayer, showLayer, hideLayer } from './layer.js';
|
|
18
|
+
|
|
19
|
+
let counter = 0;
|
|
20
|
+
|
|
21
|
+
class XoPopover extends XoElement {
|
|
22
|
+
static props = { anchor: String, label: String, open: Boolean };
|
|
23
|
+
|
|
24
|
+
#id = `xo-popover-${++counter}`;
|
|
25
|
+
/** @type {HTMLElement | null} */ #trigger = null;
|
|
26
|
+
#onClick = () => this.toggle();
|
|
27
|
+
#onDocument = (/** @type {Event} */ e) => { const t = /** @type {Node} */ (e.target); if (!this.contains(t) && !this.#trigger?.contains(t)) this.close(); };
|
|
28
|
+
|
|
29
|
+
connected() {
|
|
30
|
+
this.classList.add('xo-popover');
|
|
31
|
+
prepareLayer(this);
|
|
32
|
+
this.setAttribute('role', 'region');
|
|
33
|
+
if (!this.id) this.id = this.#id;
|
|
34
|
+
this.#trigger = this.anchor ? document.querySelector(this.anchor) : /** @type {HTMLElement | null} */ (this.previousElementSibling);
|
|
35
|
+
const t = this.#trigger;
|
|
36
|
+
if (t) { t.setAttribute('aria-controls', this.id); t.setAttribute('aria-haspopup', 'true'); t.addEventListener('click', this.#onClick); }
|
|
37
|
+
this.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); this.close(true); } });
|
|
38
|
+
this.addEventListener('focusout', (e) => { if (this.open && e.relatedTarget && !this.contains(/** @type {Node} */ (e.relatedTarget)) && e.relatedTarget !== this.#trigger) this.close(); });
|
|
39
|
+
this.#paint();
|
|
40
|
+
}
|
|
41
|
+
disconnected() { this.#trigger?.removeEventListener('click', this.#onClick); document.removeEventListener('pointerdown', this.#onDocument); this.#stop(); }
|
|
42
|
+
#stop = () => {};
|
|
43
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
|
|
44
|
+
|
|
45
|
+
/** Open it; the focus moves to the first focusable element of the panel. */
|
|
46
|
+
show() { this.setAttribute('open', ''); }
|
|
47
|
+
toggle() { this.open ? this.close() : this.show(); }
|
|
48
|
+
/** @param {boolean} [refocus] give the focus back to the trigger */
|
|
49
|
+
close(refocus = false) { if (!this.open) return; this.removeAttribute('open'); if (refocus) this.#trigger?.focus(); }
|
|
50
|
+
|
|
51
|
+
#paint() {
|
|
52
|
+
if (!this.isConnected) return;
|
|
53
|
+
if (this.label) this.setAttribute('aria-label', this.label); else this.removeAttribute('aria-label');
|
|
54
|
+
this.#trigger?.setAttribute('aria-expanded', String(this.open));
|
|
55
|
+
if (this.open === this.#wasOpen) return;
|
|
56
|
+
if (this.open) {
|
|
57
|
+
this.#stop();
|
|
58
|
+
this.#stop = showLayer(this, this.#trigger ?? this);
|
|
59
|
+
document.addEventListener('pointerdown', this.#onDocument);
|
|
60
|
+
this.dispatchEvent(new Event('xo-popover-open', { bubbles: true }));
|
|
61
|
+
/** @type {HTMLElement | null} */ (this.querySelector('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))?.focus();
|
|
62
|
+
} else {
|
|
63
|
+
this.#stop();
|
|
64
|
+
hideLayer(this);
|
|
65
|
+
document.removeEventListener('pointerdown', this.#onDocument);
|
|
66
|
+
this.dispatchEvent(new Event('xo-popover-close', { bubbles: true }));
|
|
67
|
+
}
|
|
68
|
+
this.#wasOpen = this.open;
|
|
69
|
+
}
|
|
70
|
+
#wasOpen = false;
|
|
71
|
+
}
|
|
72
|
+
define('xo-popover', XoPopover);
|
package/src/switch.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - `value` is a boolean (`checked` is the same thing as an attribute/property). The form model gets true or false; a plain <form> submits "on" when
|
|
10
10
|
* it is on and nothing when it is off, like a checkbox.
|
|
11
11
|
* - `change` and `input` fire when the person toggles it. `busy` sets aria-busy and ignores clicks while a save is running.
|
|
12
|
-
* -
|
|
12
|
+
* - Text inside the element is the visible label: `<xo-switch name="n">Email me about replies</xo-switch>` draws `label.xo-switch-row > button + span.xo-switch-text`; a click on the text toggles the switch and the text is its accessible name.
|
|
13
|
+
* - `label` is the accessible name when there is no visible text (an icon-only row, a switch in a table).
|
|
13
14
|
*/
|
|
14
15
|
import { XoControl, define } from '@xoong/core';
|
|
15
16
|
|
|
@@ -19,6 +20,7 @@ class XoSwitch extends XoControl {
|
|
|
19
20
|
static props = { ...XoControl.props, label: String, checked: Boolean, busy: Boolean, size: String };
|
|
20
21
|
|
|
21
22
|
/** @type {HTMLButtonElement | null} */ #button = null;
|
|
23
|
+
#words = false;
|
|
22
24
|
|
|
23
25
|
get value() { return truthy(super.value); }
|
|
24
26
|
set value(v) { super.value = truthy(v); }
|
|
@@ -31,15 +33,28 @@ class XoSwitch extends XoControl {
|
|
|
31
33
|
isEmpty(v) { return !truthy(v); }
|
|
32
34
|
get focusTarget() { return this.#button; }
|
|
33
35
|
|
|
34
|
-
connected() {
|
|
36
|
+
connected() { void this.ready().then(() => this.#build()); } // the visible text is the children: wait until the parser has them
|
|
37
|
+
|
|
38
|
+
#build() {
|
|
39
|
+
if (this.#button) return;
|
|
35
40
|
if (this.hasAttribute('checked') && !this.value) this.setValue(true);
|
|
41
|
+
const words = (this.textContent ?? '').trim();
|
|
36
42
|
const button = document.createElement('button');
|
|
37
43
|
button.type = 'button';
|
|
38
44
|
button.className = 'xo-switch';
|
|
39
45
|
button.setAttribute('role', 'switch');
|
|
40
46
|
button.addEventListener('click', () => this.#toggle());
|
|
41
47
|
this.#button = button;
|
|
42
|
-
|
|
48
|
+
if (words) {
|
|
49
|
+
const row = document.createElement('label');
|
|
50
|
+
row.className = 'xo-switch-row';
|
|
51
|
+
const span = document.createElement('span');
|
|
52
|
+
span.className = 'xo-switch-text';
|
|
53
|
+
span.textContent = words;
|
|
54
|
+
row.append(button, span);
|
|
55
|
+
this.replaceChildren(row);
|
|
56
|
+
} else this.replaceChildren(button);
|
|
57
|
+
this.#words = !!words;
|
|
43
58
|
if (this.size === 'sm') button.classList.add('xo-switch--sm');
|
|
44
59
|
this.#paint();
|
|
45
60
|
}
|
|
@@ -63,7 +78,7 @@ class XoSwitch extends XoControl {
|
|
|
63
78
|
b.setAttribute('aria-checked', String(this.value));
|
|
64
79
|
b.disabled = this.isDisabled;
|
|
65
80
|
b.classList.toggle('xo-switch--sm', this.size === 'sm');
|
|
66
|
-
if (this.label) b.setAttribute('aria-label', this.label); else b.removeAttribute('aria-label');
|
|
81
|
+
if (this.label && !this.#words) b.setAttribute('aria-label', this.label); else b.removeAttribute('aria-label'); // visible text names it through its <label>
|
|
67
82
|
if (this.busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
|
|
68
83
|
if (this.readonly) b.setAttribute('aria-readonly', 'true'); else b.removeAttribute('aria-readonly');
|
|
69
84
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-theme-toggle> lets the visitor choose the light or the dark theme.
|
|
4
|
+
*
|
|
5
|
+
* <xo-theme-toggle></xo-theme-toggle>
|
|
6
|
+
*
|
|
7
|
+
* - A toggle button (`aria-pressed` = dark is on) that sets `data-theme="light" | "dark"` on <html>, which the themes read; the choice is saved
|
|
8
|
+
* in localStorage (`xoong-theme`, or the `storage` attribute). Until the visitor chooses, nothing is set and the system setting
|
|
9
|
+
* (prefers-color-scheme) decides, and the button follows it when it changes.
|
|
10
|
+
* - To avoid a flash of the wrong theme, apply the saved value in a script in the <head> before the first paint:
|
|
11
|
+
* `try { const t = localStorage.getItem('xoong-theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; } catch {}`
|
|
12
|
+
* - `xo-theme-change` fires with `{ theme }` ('light' | 'dark'); `reset()` forgets the choice and follows the system again.
|
|
13
|
+
*/
|
|
14
|
+
import { XoElement, define, text } from '@xoong/core';
|
|
15
|
+
|
|
16
|
+
class XoThemeToggle extends XoElement {
|
|
17
|
+
static props = { storage: String };
|
|
18
|
+
|
|
19
|
+
/** @type {HTMLButtonElement | null} */ #button = null;
|
|
20
|
+
#query = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;
|
|
21
|
+
#onSystem = () => this.#paint();
|
|
22
|
+
get #key() { return this.storage || 'xoong-theme'; }
|
|
23
|
+
|
|
24
|
+
/** The theme in effect: the chosen one, else the system's. */
|
|
25
|
+
get theme() {
|
|
26
|
+
const set = document.documentElement.dataset.theme;
|
|
27
|
+
return set === 'dark' || set === 'light' ? set : this.#query?.matches ? 'dark' : 'light';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connected() {
|
|
31
|
+
try { const saved = localStorage.getItem(this.#key); if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved; } catch { /* not saved */ }
|
|
32
|
+
const b = document.createElement('button');
|
|
33
|
+
b.type = 'button';
|
|
34
|
+
b.className = 'icon-only ghost';
|
|
35
|
+
b.innerHTML = '<span class="ic" aria-hidden="true"></span>';
|
|
36
|
+
b.addEventListener('click', () => this.#set(this.theme === 'dark' ? 'light' : 'dark'));
|
|
37
|
+
this.#button = b;
|
|
38
|
+
this.replaceChildren(b);
|
|
39
|
+
this.#query?.addEventListener?.('change', this.#onSystem);
|
|
40
|
+
this.#paint();
|
|
41
|
+
}
|
|
42
|
+
disconnected() { this.#query?.removeEventListener?.('change', this.#onSystem); }
|
|
43
|
+
|
|
44
|
+
#set(/** @type {'light' | 'dark'} */ theme) {
|
|
45
|
+
document.documentElement.dataset.theme = theme;
|
|
46
|
+
try { localStorage.setItem(this.#key, theme); } catch { /* not saved */ }
|
|
47
|
+
this.#paint();
|
|
48
|
+
this.dispatchEvent(new CustomEvent('xo-theme-change', { bubbles: true, detail: { theme } }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Forget the choice: the system setting decides again. */
|
|
52
|
+
reset() {
|
|
53
|
+
delete document.documentElement.dataset.theme;
|
|
54
|
+
try { localStorage.removeItem(this.#key); } catch { /* not saved */ }
|
|
55
|
+
this.#paint();
|
|
56
|
+
this.dispatchEvent(new CustomEvent('xo-theme-change', { bubbles: true, detail: { theme: this.theme } }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#paint() {
|
|
60
|
+
const b = this.#button;
|
|
61
|
+
if (!b) return;
|
|
62
|
+
const dark = this.theme === 'dark';
|
|
63
|
+
b.setAttribute('aria-pressed', String(dark));
|
|
64
|
+
b.setAttribute('aria-label', text('xoong.controls.darkTheme', 'Dark theme'));
|
|
65
|
+
/** @type {HTMLElement} */ (b.firstElementChild).dataset.ic = dark ? 'sun' : 'moon';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
define('xo-theme-toggle', XoThemeToggle);
|