@xoong/controls 0.0.39 → 0.0.42
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 +124 -16
- package/src/accordion.js +51 -0
- package/src/avatar.js +142 -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 +7 -0
- package/src/date.js +4 -4
- package/src/file.js +115 -0
- package/src/index.d.ts +436 -0
- package/src/index.js +17 -0
- package/src/inline-edit.js +99 -0
- package/src/layer.js +14 -8
- package/src/otp.js +152 -0
- package/src/popover.js +72 -0
- package/src/progress.js +98 -0
- package/src/range-slider.js +83 -0
- package/src/segmented.js +73 -0
- package/src/slider.js +2 -1
- package/src/split-button.js +42 -0
- package/src/stat.js +58 -0
- package/src/stepper.js +9 -6
- package/src/steps.js +73 -0
- package/src/switch.js +19 -4
- package/src/textarea.js +57 -0
- package/src/theme-toggle.js +68 -0
package/package.json
CHANGED
|
@@ -1,27 +1,135 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xoong/controls",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
9
10
|
"default": "./src/index.js"
|
|
10
11
|
},
|
|
11
|
-
"./
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"./
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
"./accordion": {
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
14
|
+
"default": "./src/accordion.js"
|
|
15
|
+
},
|
|
16
|
+
"./avatar": {
|
|
17
|
+
"types": "./src/index.d.ts",
|
|
18
|
+
"default": "./src/avatar.js"
|
|
19
|
+
},
|
|
20
|
+
"./carousel": {
|
|
21
|
+
"types": "./src/index.d.ts",
|
|
22
|
+
"default": "./src/carousel.js"
|
|
23
|
+
},
|
|
24
|
+
"./chart": {
|
|
25
|
+
"types": "./src/index.d.ts",
|
|
26
|
+
"default": "./src/chart.js"
|
|
27
|
+
},
|
|
28
|
+
"./combobox": {
|
|
29
|
+
"types": "./src/index.d.ts",
|
|
30
|
+
"default": "./src/combobox.js"
|
|
31
|
+
},
|
|
32
|
+
"./command": {
|
|
33
|
+
"types": "./src/index.d.ts",
|
|
34
|
+
"default": "./src/command.js"
|
|
35
|
+
},
|
|
36
|
+
"./consent": {
|
|
37
|
+
"types": "./src/index.d.ts",
|
|
38
|
+
"default": "./src/consent.js"
|
|
39
|
+
},
|
|
19
40
|
"./controls.css": "./src/controls.css",
|
|
20
|
-
"./
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"./
|
|
41
|
+
"./copy": {
|
|
42
|
+
"types": "./src/index.d.ts",
|
|
43
|
+
"default": "./src/copy.js"
|
|
44
|
+
},
|
|
45
|
+
"./date": {
|
|
46
|
+
"types": "./src/index.d.ts",
|
|
47
|
+
"default": "./src/date.js"
|
|
48
|
+
},
|
|
49
|
+
"./file": {
|
|
50
|
+
"types": "./src/index.d.ts",
|
|
51
|
+
"default": "./src/file.js"
|
|
52
|
+
},
|
|
53
|
+
"./image": {
|
|
54
|
+
"types": "./src/index.d.ts",
|
|
55
|
+
"default": "./src/image.js"
|
|
56
|
+
},
|
|
57
|
+
"./inline-edit": {
|
|
58
|
+
"types": "./src/index.d.ts",
|
|
59
|
+
"default": "./src/inline-edit.js"
|
|
60
|
+
},
|
|
61
|
+
"./menu": {
|
|
62
|
+
"types": "./src/index.d.ts",
|
|
63
|
+
"default": "./src/menu.js"
|
|
64
|
+
},
|
|
65
|
+
"./otp": {
|
|
66
|
+
"types": "./src/index.d.ts",
|
|
67
|
+
"default": "./src/otp.js"
|
|
68
|
+
},
|
|
69
|
+
"./password": {
|
|
70
|
+
"types": "./src/index.d.ts",
|
|
71
|
+
"default": "./src/password.js"
|
|
72
|
+
},
|
|
73
|
+
"./popover": {
|
|
74
|
+
"types": "./src/index.d.ts",
|
|
75
|
+
"default": "./src/popover.js"
|
|
76
|
+
},
|
|
77
|
+
"./progress": {
|
|
78
|
+
"types": "./src/index.d.ts",
|
|
79
|
+
"default": "./src/progress.js"
|
|
80
|
+
},
|
|
81
|
+
"./range-slider": {
|
|
82
|
+
"types": "./src/index.d.ts",
|
|
83
|
+
"default": "./src/range-slider.js"
|
|
84
|
+
},
|
|
85
|
+
"./segmented": {
|
|
86
|
+
"types": "./src/index.d.ts",
|
|
87
|
+
"default": "./src/segmented.js"
|
|
88
|
+
},
|
|
89
|
+
"./slider": {
|
|
90
|
+
"types": "./src/index.d.ts",
|
|
91
|
+
"default": "./src/slider.js"
|
|
92
|
+
},
|
|
93
|
+
"./split-button": {
|
|
94
|
+
"types": "./src/index.d.ts",
|
|
95
|
+
"default": "./src/split-button.js"
|
|
96
|
+
},
|
|
97
|
+
"./stat": {
|
|
98
|
+
"types": "./src/index.d.ts",
|
|
99
|
+
"default": "./src/stat.js"
|
|
100
|
+
},
|
|
101
|
+
"./stepper": {
|
|
102
|
+
"types": "./src/index.d.ts",
|
|
103
|
+
"default": "./src/stepper.js"
|
|
104
|
+
},
|
|
105
|
+
"./steps": {
|
|
106
|
+
"types": "./src/index.d.ts",
|
|
107
|
+
"default": "./src/steps.js"
|
|
108
|
+
},
|
|
109
|
+
"./switch": {
|
|
110
|
+
"types": "./src/index.d.ts",
|
|
111
|
+
"default": "./src/switch.js"
|
|
112
|
+
},
|
|
113
|
+
"./tabs": {
|
|
114
|
+
"types": "./src/index.d.ts",
|
|
115
|
+
"default": "./src/tabs.js"
|
|
116
|
+
},
|
|
117
|
+
"./textarea": {
|
|
118
|
+
"types": "./src/index.d.ts",
|
|
119
|
+
"default": "./src/textarea.js"
|
|
120
|
+
},
|
|
121
|
+
"./theme-toggle": {
|
|
122
|
+
"types": "./src/index.d.ts",
|
|
123
|
+
"default": "./src/theme-toggle.js"
|
|
124
|
+
},
|
|
125
|
+
"./time": {
|
|
126
|
+
"types": "./src/index.d.ts",
|
|
127
|
+
"default": "./src/time.js"
|
|
128
|
+
},
|
|
129
|
+
"./tooltip": {
|
|
130
|
+
"types": "./src/index.d.ts",
|
|
131
|
+
"default": "./src/tooltip.js"
|
|
132
|
+
}
|
|
25
133
|
},
|
|
26
134
|
"sideEffects": true,
|
|
27
135
|
"files": [
|
|
@@ -46,9 +154,9 @@
|
|
|
46
154
|
"bugs": {
|
|
47
155
|
"url": "https://github.com/fpt-duongtx/xoong/issues"
|
|
48
156
|
},
|
|
49
|
-
"homepage": "https://
|
|
157
|
+
"homepage": "https://fpt-duongtx.github.io/xoong/",
|
|
50
158
|
"dependencies": {
|
|
51
|
-
"@xoong/core": "0.0.
|
|
159
|
+
"@xoong/core": "0.0.42"
|
|
52
160
|
},
|
|
53
161
|
"publishConfig": {
|
|
54
162
|
"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/avatar.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-avatar> is a person (or a team) as a round badge: their picture when there is one, their initials when there is not.
|
|
4
|
+
* <xo-avatars> stacks several of them.
|
|
5
|
+
*
|
|
6
|
+
* <xo-avatar name="Nguyễn Văn An" src="/img/an.jpg" size="lg" status="ok"></xo-avatar>
|
|
7
|
+
* <xo-avatars max="3"><xo-avatar name="An Dao"></xo-avatar> <xo-avatar name="Linh Tran"></xo-avatar> ... </xo-avatars>
|
|
8
|
+
*
|
|
9
|
+
* - The element itself is the `.xo-avatar` badge (it gets the classes `xo-avatar`, `xo-avatar--sm|lg` from `size`, and `xo-avatar--ok|warn|muted` from
|
|
10
|
+
* `status`). Colour is never the only signal: the status is added to the accessible name ("An Dao, online").
|
|
11
|
+
* - Initials: the first letter of the first and of the last word ("Nguyễn Văn An" is "NA"); a single word gives its first two letters. Without a
|
|
12
|
+
* name the user icon is drawn.
|
|
13
|
+
* - `src` is tried first: the image replaces the initials once it loads and the initials stay when it fails (`data-state` is `loaded` or `error`).
|
|
14
|
+
* - The accessible name is the full name: `role=img` with `aria-label`; inside <xo-avatars> it is a `role=listitem` with the same label.
|
|
15
|
+
* - <xo-avatars max="3"> is a `role=list` of its avatars. Only `max` are shown; a last badge "+N" (labelled "N more") stands for the rest.
|
|
16
|
+
* Without `max` all are shown.
|
|
17
|
+
*/
|
|
18
|
+
import { XoElement, define, text } from '@xoong/core';
|
|
19
|
+
|
|
20
|
+
/** Initials for a name: first letter of the first and the last word, or the first two letters of a single word. */
|
|
21
|
+
export function initialsOf(/** @type {string} */ name) {
|
|
22
|
+
const words = String(name ?? '').trim().split(/\s+/).map((w) => Array.from(w.replace(/^[^\p{L}\p{N}]+/u, ''))).filter((w) => w.length);
|
|
23
|
+
if (!words.length) return '';
|
|
24
|
+
const letters = words.length === 1 ? words[0].slice(0, 2) : [words[0][0], words[words.length - 1][0]];
|
|
25
|
+
return letters.join('').toLocaleUpperCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STATUS = ['ok', 'warn', 'muted'];
|
|
29
|
+
const SIZE = ['sm', 'lg'];
|
|
30
|
+
|
|
31
|
+
class XoAvatar extends XoElement {
|
|
32
|
+
static props = { name: String, src: String, size: String, status: String };
|
|
33
|
+
|
|
34
|
+
/** @type {HTMLImageElement | null} */ #img = null;
|
|
35
|
+
/** @type {HTMLElement | null} */ #initials = null;
|
|
36
|
+
|
|
37
|
+
connected() {
|
|
38
|
+
const initials = document.createElement('span');
|
|
39
|
+
initials.setAttribute('aria-hidden', 'true');
|
|
40
|
+
this.#initials = initials;
|
|
41
|
+
this.replaceChildren(initials);
|
|
42
|
+
this.#img = null;
|
|
43
|
+
this.#loadImage();
|
|
44
|
+
this.#paint();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
attributeChangedCallback(attr, old, value) {
|
|
48
|
+
super.attributeChangedCallback(attr, old, value);
|
|
49
|
+
if (attr === 'src' && this.#initials) this.#loadImage();
|
|
50
|
+
this.#paint();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The stack (or the page) calls this when the avatar is moved into or out of an <xo-avatars>. */
|
|
54
|
+
refresh() { this.#paint(); }
|
|
55
|
+
|
|
56
|
+
#loadImage() {
|
|
57
|
+
this.#img?.remove();
|
|
58
|
+
this.#img = null;
|
|
59
|
+
delete this.dataset.state;
|
|
60
|
+
this.#initials?.removeAttribute('hidden');
|
|
61
|
+
if (!this.src) return;
|
|
62
|
+
const img = document.createElement('img');
|
|
63
|
+
img.alt = '';
|
|
64
|
+
img.setAttribute('aria-hidden', 'true');
|
|
65
|
+
img.decoding = 'async';
|
|
66
|
+
// The picture fills the badge: set through the CSS object model (no style attribute, strict CSP) so it works with or without a stylesheet rule.
|
|
67
|
+
for (const [k, v] of [['width', '100%'], ['height', '100%'], ['objectFit', 'cover'], ['borderRadius', '50%'], ['display', 'none']]) /** @type {any} */ (img.style)[k] = v;
|
|
68
|
+
img.addEventListener('load', () => { if (this.#img !== img) return; img.style.display = 'block'; this.#initials?.setAttribute('hidden', ''); this.dataset.state = 'loaded'; });
|
|
69
|
+
img.addEventListener('error', () => { if (this.#img !== img) return; img.remove(); this.#img = null; this.#initials?.removeAttribute('hidden'); this.dataset.state = 'error'; });
|
|
70
|
+
this.#img = img;
|
|
71
|
+
this.append(img);
|
|
72
|
+
img.src = this.src;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#paint() {
|
|
76
|
+
const el = this.#initials;
|
|
77
|
+
if (!el) return;
|
|
78
|
+
const name = (this.name ?? '').trim();
|
|
79
|
+
const initials = initialsOf(name);
|
|
80
|
+
if (initials) el.textContent = initials;
|
|
81
|
+
else el.innerHTML = '<span class="ic" data-ic="user" aria-hidden="true"></span>';
|
|
82
|
+
const cls = this.classList;
|
|
83
|
+
cls.add('xo-avatar');
|
|
84
|
+
for (const s of SIZE) cls.toggle(`xo-avatar--${s}`, this.size === s);
|
|
85
|
+
for (const s of STATUS) cls.toggle(`xo-avatar--${s}`, this.status === s);
|
|
86
|
+
const status = this.status === 'ok' ? text('xoong.avatar.ok', 'online') : this.status === 'warn' ? text('xoong.avatar.warn', 'needs attention') : this.status === 'muted' ? text('xoong.avatar.muted', 'inactive') : '';
|
|
87
|
+
const label = name ? (status ? `${name}, ${status}` : name) : text('xoong.avatar.unknown', 'Unknown user');
|
|
88
|
+
this.setAttribute('aria-label', label);
|
|
89
|
+
this.setAttribute('role', this.closest('xo-avatars') ? 'listitem' : 'img');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
define('xo-avatar', XoAvatar);
|
|
93
|
+
|
|
94
|
+
class XoAvatars extends XoElement {
|
|
95
|
+
static props = { max: Number, label: String };
|
|
96
|
+
|
|
97
|
+
/** @type {HTMLElement | null} */ #more = null;
|
|
98
|
+
/** @type {MutationObserver | null} */ #watch = null;
|
|
99
|
+
|
|
100
|
+
connected() {
|
|
101
|
+
this.classList.add('xo-avatars');
|
|
102
|
+
this.setAttribute('role', 'list');
|
|
103
|
+
void this.ready().then(() => {
|
|
104
|
+
this.#layout();
|
|
105
|
+
this.#watch = new MutationObserver(() => this.#layout());
|
|
106
|
+
this.#watch.observe(this, { childList: true });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
disconnected() { this.#watch?.disconnect(); this.#watch = null; }
|
|
110
|
+
|
|
111
|
+
attributeChangedCallback(attr, old, value) {
|
|
112
|
+
super.attributeChangedCallback(attr, old, value);
|
|
113
|
+
if (this.isConnected && this.#watch) this.#layout();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#layout() {
|
|
117
|
+
this.#watch?.disconnect();
|
|
118
|
+
const items = /** @type {HTMLElement[]} */ ([...this.children].filter((c) => c !== this.#more));
|
|
119
|
+
const max = this.max > 0 ? Math.floor(this.max) : items.length;
|
|
120
|
+
const rest = Math.max(0, items.length - max);
|
|
121
|
+
items.forEach((el, i) => {
|
|
122
|
+
if (!el.hasAttribute('role') || el.getAttribute('role') === 'img') el.setAttribute('role', 'listitem');
|
|
123
|
+
if (/** @type {any} */ (el).refresh) /** @type {any} */ (el).refresh();
|
|
124
|
+
const hide = i >= max;
|
|
125
|
+
el.toggleAttribute('hidden', hide);
|
|
126
|
+
if (hide) el.style.display = 'none'; else el.style.removeProperty('display');
|
|
127
|
+
});
|
|
128
|
+
if (rest > 0) {
|
|
129
|
+
if (!this.#more) {
|
|
130
|
+
const more = document.createElement('span');
|
|
131
|
+
more.className = 'xo-avatar xo-avatar--muted';
|
|
132
|
+
more.setAttribute('role', 'listitem');
|
|
133
|
+
this.#more = more;
|
|
134
|
+
}
|
|
135
|
+
this.#more.textContent = `+${rest}`;
|
|
136
|
+
this.#more.setAttribute('aria-label', text('xoong.avatar.more', '{count} more', { count: rest }));
|
|
137
|
+
if (this.lastElementChild !== this.#more) this.append(this.#more);
|
|
138
|
+
} else if (this.#more) { this.#more.remove(); this.#more = null; }
|
|
139
|
+
this.#watch?.observe(this, { childList: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
define('xo-avatars', XoAvatars);
|
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,13 @@
|
|
|
8
8
|
xo-tabs > [role="tabpanel"] { padding-top: var(--xo-space-3, .75rem); }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
@layer xoong-controls {
|
|
12
|
+
/* the hosts of the field and feedback elements: a custom element is inline until told otherwise */
|
|
13
|
+
:is(xo-date, xo-slider, xo-range-slider, xo-password, xo-copy, xo-file, xo-textarea, xo-otp, xo-progress, xo-steps, xo-stat, xo-chart, xo-accordion, xo-consent) { display: block; }
|
|
14
|
+
:is(xo-switch, xo-stepper, xo-time, xo-segmented, xo-split-button, xo-spinner, xo-avatar, xo-avatars, xo-inline-edit, xo-theme-toggle, xo-popover-anchor) { display: inline-block; }
|
|
15
|
+
xo-popover[hidden] { display: none; }
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
@layer xoong-controls {
|
|
12
19
|
xo-image { display: block; position: relative; overflow: hidden; max-width: 100%; }
|
|
13
20
|
xo-image > img { display: block; width: 100%; height: 100%; object-fit: var(--xo-image-fit, cover); object-position: var(--xo-image-position, center); }
|