@xoong/controls 0.0.0-stage → 0.0.34
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 +28 -2
- package/package.json +56 -4
- package/src/carousel.js +248 -0
- package/src/combobox.js +364 -0
- package/src/controls.css +68 -0
- package/src/copy.js +60 -0
- package/src/date.js +254 -0
- package/src/index.js +13 -0
- package/src/layer.js +46 -0
- package/src/menu.js +150 -0
- package/src/password.js +53 -0
- package/src/slider.js +47 -0
- package/src/stepper.js +85 -0
- package/src/switch.js +71 -0
- package/src/tabs.js +119 -0
- package/src/time.js +68 -0
- package/src/tooltip.js +79 -0
package/src/switch.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-switch> is one setting on or off (the WAI-ARIA switch pattern), a form control with a boolean value.
|
|
4
|
+
*
|
|
5
|
+
* <xo-switch name="notify" label="Email me about replies" checked></xo-switch>
|
|
6
|
+
* <xo-switch name="menu" size="sm" label="Show in menu"></xo-switch>
|
|
7
|
+
*
|
|
8
|
+
* - It draws `button.xo-switch[role=switch][aria-checked]`; Space and Enter toggle it (a native button does this), `disabled` and `readonly` stop it.
|
|
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
|
+
* it is on and nothing when it is off, like a checkbox.
|
|
11
|
+
* - `change` and `input` fire when the person toggles it. `busy` sets aria-busy and ignores clicks while a save is running.
|
|
12
|
+
* - `label` is the accessible name; a <label for> pointing at the element name also works through aria-labelledby on click-through.
|
|
13
|
+
*/
|
|
14
|
+
import { XoControl, define } from '@xoong/core';
|
|
15
|
+
|
|
16
|
+
const truthy = (v) => v === true || v === 'true' || v === 'on' || v === '1' || v === 1;
|
|
17
|
+
|
|
18
|
+
class XoSwitch extends XoControl {
|
|
19
|
+
static props = { ...XoControl.props, label: String, checked: Boolean, busy: Boolean, size: String };
|
|
20
|
+
|
|
21
|
+
/** @type {HTMLButtonElement | null} */ #button = null;
|
|
22
|
+
|
|
23
|
+
get value() { return truthy(super.value); }
|
|
24
|
+
set value(v) { super.value = truthy(v); }
|
|
25
|
+
setValue(v) { super.setValue(truthy(v)); this.#paint(); }
|
|
26
|
+
|
|
27
|
+
get checked() { return this.value; }
|
|
28
|
+
set checked(v) { this.setValue(!!v); }
|
|
29
|
+
|
|
30
|
+
serialize(v) { return truthy(v) ? 'on' : null; }
|
|
31
|
+
isEmpty(v) { return !truthy(v); }
|
|
32
|
+
get focusTarget() { return this.#button; }
|
|
33
|
+
|
|
34
|
+
connected() {
|
|
35
|
+
if (this.hasAttribute('checked') && !this.value) this.setValue(true);
|
|
36
|
+
const button = document.createElement('button');
|
|
37
|
+
button.type = 'button';
|
|
38
|
+
button.className = 'xo-switch';
|
|
39
|
+
button.setAttribute('role', 'switch');
|
|
40
|
+
button.addEventListener('click', () => this.#toggle());
|
|
41
|
+
this.#button = button;
|
|
42
|
+
this.replaceChildren(button);
|
|
43
|
+
if (this.size === 'sm') button.classList.add('xo-switch--sm');
|
|
44
|
+
this.#paint();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
attributeChangedCallback(attr, old, value) {
|
|
48
|
+
super.attributeChangedCallback(attr, old, value);
|
|
49
|
+
if (attr === 'checked') this.setValue(value !== null);
|
|
50
|
+
this.#paint();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#toggle() {
|
|
54
|
+
if (this.isDisabled || this.readonly || this.busy) return;
|
|
55
|
+
this.userInput(!this.value);
|
|
56
|
+
this.userChange();
|
|
57
|
+
this.#paint();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#paint() {
|
|
61
|
+
const b = this.#button;
|
|
62
|
+
if (!b) return;
|
|
63
|
+
b.setAttribute('aria-checked', String(this.value));
|
|
64
|
+
b.disabled = this.isDisabled;
|
|
65
|
+
b.classList.toggle('xo-switch--sm', this.size === 'sm');
|
|
66
|
+
if (this.label) b.setAttribute('aria-label', this.label); else b.removeAttribute('aria-label');
|
|
67
|
+
if (this.busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
|
|
68
|
+
if (this.readonly) b.setAttribute('aria-readonly', 'true'); else b.removeAttribute('aria-readonly');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
define('xo-switch', XoSwitch);
|
package/src/tabs.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-tabs> turns sections into an accessible tab set (the WAI-ARIA tabs pattern, automatic activation).
|
|
4
|
+
*
|
|
5
|
+
* <xo-tabs label="Article" value="seo">
|
|
6
|
+
* <section xo-tab="General">...</section>
|
|
7
|
+
* <section xo-tab="SEO" id="seo">...</section>
|
|
8
|
+
* </xo-tabs>
|
|
9
|
+
*
|
|
10
|
+
* - The `xo-tab` attribute is the tab's label; the section's `id` is its key (the position when there is none).
|
|
11
|
+
* - `value` is the key of the selected tab (the first one by default); `tabs.select(key)` changes it. `xo-tab-change` fires with `{ value }`.
|
|
12
|
+
* - Keys: Left and Right (wrapping), Home and End move and select; only the selected tab is in the tab order.
|
|
13
|
+
* - Panels are hidden, not removed: their form controls stay in the form, so a required field on another tab still blocks submit.
|
|
14
|
+
* On `xo-reveal` (a form found an invalid field) the tab that holds that field is selected, and the event is answered.
|
|
15
|
+
* - A tab whose panel has a control with `aria-invalid="true"` gets `data-invalid`, for a marker in CSS.
|
|
16
|
+
*/
|
|
17
|
+
import { XoElement, define } from '@xoong/core';
|
|
18
|
+
|
|
19
|
+
let counter = 0;
|
|
20
|
+
|
|
21
|
+
class XoTabs extends XoElement {
|
|
22
|
+
static props = { value: String, label: String };
|
|
23
|
+
|
|
24
|
+
/** @type {{ key: string, panel: HTMLElement, tab: HTMLButtonElement }[]} */ #items = [];
|
|
25
|
+
/** @type {HTMLElement | null} */ #list = null;
|
|
26
|
+
/** @type {MutationObserver | null} */ #watch = null;
|
|
27
|
+
#onReveal = (/** @type {Event} */ event) => {
|
|
28
|
+
const path = /** @type {CustomEvent} */ (event).detail?.path;
|
|
29
|
+
if (!path || event.defaultPrevented) return;
|
|
30
|
+
const item = this.#items.find(({ panel }) => panel.querySelector(`[name="${String(path).replace(/["\\]/g, '\\$&')}"]`));
|
|
31
|
+
if (item && !this.#isSelected(item)) { this.select(item.key); event.preventDefault(); }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
connected() {
|
|
35
|
+
// The children may not exist yet when the parser connects the element: build once the tree is complete.
|
|
36
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this.#build(), { once: true });
|
|
37
|
+
else queueMicrotask(() => this.#build());
|
|
38
|
+
document.addEventListener('xo-reveal', this.#onReveal);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
disconnected() {
|
|
42
|
+
document.removeEventListener('xo-reveal', this.#onReveal);
|
|
43
|
+
this.#watch?.disconnect();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Select a tab by key (or by position); unknown keys are ignored. */
|
|
47
|
+
select(key, { focus = false } = {}) {
|
|
48
|
+
const item = this.#items.find((i) => i.key === String(key));
|
|
49
|
+
if (!item) return;
|
|
50
|
+
const changed = this.getAttribute('value') !== item.key;
|
|
51
|
+
for (const other of this.#items) {
|
|
52
|
+
const on = other === item;
|
|
53
|
+
other.panel.hidden = !on;
|
|
54
|
+
other.tab.setAttribute('aria-selected', String(on));
|
|
55
|
+
other.tab.tabIndex = on ? 0 : -1;
|
|
56
|
+
}
|
|
57
|
+
this.setAttribute('value', item.key);
|
|
58
|
+
if (focus) item.tab.focus();
|
|
59
|
+
if (changed) this.dispatchEvent(new CustomEvent('xo-tab-change', { bubbles: true, detail: { value: item.key } }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#isSelected(/** @type {{ tab: HTMLElement }} */ item) { return item.tab.getAttribute('aria-selected') === 'true'; }
|
|
63
|
+
|
|
64
|
+
#build() {
|
|
65
|
+
if (this.#list || !this.isConnected) return;
|
|
66
|
+
const id = `xo-tabs-${++counter}`;
|
|
67
|
+
this.querySelector(':scope > [role="tablist"]')?.remove(); // a copy of a tabs element that was already built (a view clones its content): start over
|
|
68
|
+
const panels = /** @type {HTMLElement[]} */ ([...this.children].filter((c) => c.hasAttribute('xo-tab')));
|
|
69
|
+
const list = document.createElement('div');
|
|
70
|
+
list.setAttribute('role', 'tablist');
|
|
71
|
+
if (this.label) list.setAttribute('aria-label', this.label);
|
|
72
|
+
this.#items = panels.map((panel, i) => {
|
|
73
|
+
if ('xoGenId' in panel.dataset) { panel.removeAttribute('id'); delete panel.dataset.xoGenId; } // an id made by an earlier build is not a key
|
|
74
|
+
const key = panel.id || String(i); // the section's id when it has one, else its position
|
|
75
|
+
const tab = document.createElement('button');
|
|
76
|
+
tab.type = 'button';
|
|
77
|
+
tab.id = `${id}-tab-${i}`;
|
|
78
|
+
tab.setAttribute('role', 'tab');
|
|
79
|
+
tab.dataset.tab = key;
|
|
80
|
+
tab.textContent = panel.getAttribute('xo-tab') ?? key;
|
|
81
|
+
if (!panel.id) { panel.id = `${id}-panel-${i}`; panel.dataset.xoGenId = ''; } // for aria-controls only: the key stays the position
|
|
82
|
+
tab.setAttribute('aria-controls', panel.id);
|
|
83
|
+
panel.setAttribute('role', 'tabpanel');
|
|
84
|
+
panel.setAttribute('aria-labelledby', tab.id);
|
|
85
|
+
tab.addEventListener('click', () => this.select(key));
|
|
86
|
+
list.append(tab);
|
|
87
|
+
return { key, panel, tab };
|
|
88
|
+
});
|
|
89
|
+
list.addEventListener('keydown', (event) => this.#onKey(/** @type {KeyboardEvent} */ (event)));
|
|
90
|
+
this.prepend(list);
|
|
91
|
+
this.#list = list;
|
|
92
|
+
this.select(this.#items.some((i) => i.key === this.value) ? this.value : (this.#items[0]?.key ?? ''));
|
|
93
|
+
this.#watch = new MutationObserver(() => this.#markInvalid());
|
|
94
|
+
this.#watch.observe(this, { subtree: true, attributes: true, attributeFilter: ['aria-invalid'] });
|
|
95
|
+
this.#markInvalid();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#markInvalid() {
|
|
99
|
+
for (const { panel, tab } of this.#items) tab.toggleAttribute('data-invalid', !!panel.querySelector('[aria-invalid="true"]'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#onKey(/** @type {KeyboardEvent} */ event) {
|
|
103
|
+
const at = this.#items.findIndex((i) => i.tab === event.target);
|
|
104
|
+
if (at < 0) return;
|
|
105
|
+
const last = this.#items.length - 1;
|
|
106
|
+
const next = event.key === 'ArrowRight' ? (at === last ? 0 : at + 1)
|
|
107
|
+
: event.key === 'ArrowLeft' ? (at === 0 ? last : at - 1)
|
|
108
|
+
: event.key === 'Home' ? 0 : event.key === 'End' ? last : -1;
|
|
109
|
+
if (next < 0) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
this.select(this.#items[next].key, { focus: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
attributeChangedCallback(attr, old, value) {
|
|
115
|
+
super.attributeChangedCallback(attr, old, value);
|
|
116
|
+
if (attr === 'value' && this.#list && value !== null && !this.#items.some((i) => i.key === value && this.#isSelected(i))) this.select(value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
define('xo-tabs', XoTabs);
|
package/src/time.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-time> is a time of day as two selects, hour and minute.
|
|
4
|
+
*
|
|
5
|
+
* <xo-time name="at" label="Time" step="15" value="09:30"></xo-time>
|
|
6
|
+
*
|
|
7
|
+
* - The value is `HH:mm` (24 hours) or '' when empty. `step` is the minutes between choices (default 5, 1 to 30); a value off the step is kept as a choice.
|
|
8
|
+
* - Choosing an hour on an empty field sets the minutes to 00. Both selects are native (keyboard and screen readers as they are), named Hour and Minute.
|
|
9
|
+
* - `change` and `input` fire when the person chooses; `min` and `max` (`HH:mm`) make a value outside invalid.
|
|
10
|
+
*/
|
|
11
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
12
|
+
|
|
13
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
14
|
+
const parse = (v) => { const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(v ?? ''); return m ? [m[1], m[2]] : null; };
|
|
15
|
+
|
|
16
|
+
class XoTime extends XoControl {
|
|
17
|
+
static props = { ...XoControl.props, label: String, step: Number, min: String, max: String };
|
|
18
|
+
|
|
19
|
+
/** @type {HTMLSelectElement | null} */ #h = null;
|
|
20
|
+
/** @type {HTMLSelectElement | null} */ #m = null;
|
|
21
|
+
|
|
22
|
+
setValue(v) { super.setValue(parse(v) ? v : ''); this.#paint(); }
|
|
23
|
+
isEmpty(v) { return !v; }
|
|
24
|
+
get focusTarget() { return this.#h; }
|
|
25
|
+
validate(v) { return v && ((this.min && v < this.min) || (this.max && v > this.max)) ? text('xoong.controls.timeRange', 'Choose a time within the allowed range.') : ''; }
|
|
26
|
+
|
|
27
|
+
connected() {
|
|
28
|
+
const group = document.createElement('div');
|
|
29
|
+
group.className = 'row-inline xo-time';
|
|
30
|
+
group.setAttribute('role', 'group');
|
|
31
|
+
if (this.label) group.setAttribute('aria-label', this.label);
|
|
32
|
+
// the native `change` stops here: the control fires its own, once
|
|
33
|
+
const make = (name) => { const s = document.createElement('select'); s.setAttribute('aria-label', name); s.addEventListener('change', (e) => { e.stopPropagation(); this.#choose(); }); return s; };
|
|
34
|
+
this.#h = make(text('xoong.controls.hour', 'Hour'));
|
|
35
|
+
this.#m = make(text('xoong.controls.minute', 'Minute'));
|
|
36
|
+
const colon = document.createElement('span');
|
|
37
|
+
colon.textContent = ':'; colon.setAttribute('aria-hidden', 'true');
|
|
38
|
+
group.append(this.#h, colon, this.#m);
|
|
39
|
+
this.replaceChildren(group);
|
|
40
|
+
queueMicrotask(() => this.#paint()); // the base class applies a `value` attribute after connected()
|
|
41
|
+
}
|
|
42
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
|
|
43
|
+
|
|
44
|
+
#paint() {
|
|
45
|
+
const h = this.#h, m = this.#m;
|
|
46
|
+
if (!h || !m) return;
|
|
47
|
+
const [vh, vm] = parse(super.value) ?? ['', ''];
|
|
48
|
+
const step = Math.min(30, Math.max(1, this.step || 5));
|
|
49
|
+
const fill = (/** @type {HTMLSelectElement} */ s, /** @type {string[]} */ list, /** @type {string} */ now) => {
|
|
50
|
+
const all = list.includes(now) || !now ? list : [...list, now].sort();
|
|
51
|
+
s.replaceChildren(...['', ...all].map((t) => { const o = document.createElement('option'); o.value = t; o.textContent = t || '--'; return o; }));
|
|
52
|
+
s.value = now;
|
|
53
|
+
};
|
|
54
|
+
fill(h, Array.from({ length: 24 }, (_, i) => pad(i)), vh);
|
|
55
|
+
fill(m, Array.from({ length: Math.ceil(60 / step) }, (_, i) => pad(i * step)), vm);
|
|
56
|
+
h.disabled = m.disabled = this.isDisabled || this.readonly;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#choose() {
|
|
60
|
+
const h = /** @type {HTMLSelectElement} */ (this.#h).value;
|
|
61
|
+
let m = /** @type {HTMLSelectElement} */ (this.#m).value;
|
|
62
|
+
if (h && !m) m = '00';
|
|
63
|
+
this.setValue(h ? `${h}:${m}` : '');
|
|
64
|
+
this.userInput(super.value);
|
|
65
|
+
this.userChange();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
define('xo-time', XoTime);
|
package/src/tooltip.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* xo-tooltip="Save the draft" on any element shows a short text next to it (the WAI-ARIA tooltip pattern).
|
|
4
|
+
*
|
|
5
|
+
* <button xo-tooltip="Save the draft">Save</button>
|
|
6
|
+
* <button xo-tooltip="Delete" aria-label="Delete">🗑</button>
|
|
7
|
+
*
|
|
8
|
+
* - Shown after a short delay on hover, at once on keyboard focus; hidden on leave, blur, Escape or a click. The pointer may move onto the
|
|
9
|
+
* tooltip without it closing (WCAG 1.4.13: dismissible, hoverable, persistent).
|
|
10
|
+
* - The element gets `aria-describedby` while it is shown. The text is a description, not the accessible name: an icon-only button
|
|
11
|
+
* still needs `aria-label`.
|
|
12
|
+
* - One shared `<div role="tooltip" class="xo-tooltip">` for the page, placed above the element (below when there is no room).
|
|
13
|
+
*/
|
|
14
|
+
const DELAY = 400;
|
|
15
|
+
|
|
16
|
+
/** @type {HTMLElement | null} */ let tip = null;
|
|
17
|
+
/** @type {Element | null} */ let current = null;
|
|
18
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */ let timer;
|
|
19
|
+
|
|
20
|
+
function element() {
|
|
21
|
+
if (!tip) {
|
|
22
|
+
tip = document.createElement('div');
|
|
23
|
+
tip.id = 'xo-tooltip';
|
|
24
|
+
tip.className = 'xo-tooltip';
|
|
25
|
+
tip.setAttribute('role', 'tooltip');
|
|
26
|
+
tip.hidden = true;
|
|
27
|
+
if ('showPopover' in tip) tip.setAttribute('popover', 'manual');
|
|
28
|
+
tip.addEventListener('pointerleave', () => hide());
|
|
29
|
+
}
|
|
30
|
+
if (!tip.isConnected) document.body.append(tip); // a page that replaced the body gets it back
|
|
31
|
+
return tip;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function show(/** @type {Element} */ target) {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
const text = target.getAttribute('xo-tooltip');
|
|
37
|
+
if (!text) return;
|
|
38
|
+
const t = element();
|
|
39
|
+
if (current && current !== target) current.removeAttribute('aria-describedby');
|
|
40
|
+
current = target;
|
|
41
|
+
t.textContent = text;
|
|
42
|
+
t.hidden = false;
|
|
43
|
+
if (t.hasAttribute('popover') && !t.matches(':popover-open')) t.showPopover();
|
|
44
|
+
target.setAttribute('aria-describedby', t.id);
|
|
45
|
+
const r = target.getBoundingClientRect();
|
|
46
|
+
const tr = t.getBoundingClientRect();
|
|
47
|
+
const above = r.top - tr.height - 6 >= 0;
|
|
48
|
+
const left = Math.max(4, Math.min(r.left + r.width / 2 - tr.width / 2, (globalThis.innerWidth || 1024) - tr.width - 4));
|
|
49
|
+
Object.assign(t.style, { position: 'fixed', inset: 'auto', margin: '0', left: `${left}px`, top: `${above ? r.top - tr.height - 6 : r.bottom + 6}px` });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hide() {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (!tip || tip.hidden) return;
|
|
55
|
+
current?.removeAttribute('aria-describedby');
|
|
56
|
+
current = null;
|
|
57
|
+
if (tip.hasAttribute('popover') && tip.matches(':popover-open')) tip.hidePopover();
|
|
58
|
+
tip.hidden = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const owner = (/** @type {EventTarget | null} */ n) => /** @type {Element | null} */ (n instanceof Element ? n.closest('[xo-tooltip]') : null);
|
|
62
|
+
|
|
63
|
+
document.addEventListener('mouseover', (event) => {
|
|
64
|
+
const target = owner(event.target);
|
|
65
|
+
if (!target || target === current) return;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
timer = setTimeout(() => show(target), DELAY);
|
|
68
|
+
});
|
|
69
|
+
document.addEventListener('mouseout', (event) => {
|
|
70
|
+
const from = owner(event.target);
|
|
71
|
+
if (!from) return;
|
|
72
|
+
const to = /** @type {Node | null} */ (event.relatedTarget);
|
|
73
|
+
if (to && (from.contains(to) || tip?.contains(to))) return; // still on the element, or moving onto the tooltip
|
|
74
|
+
hide();
|
|
75
|
+
});
|
|
76
|
+
document.addEventListener('focusin', (event) => { const target = owner(event.target); if (target) show(target); });
|
|
77
|
+
document.addEventListener('focusout', (event) => { if (owner(event.target)) hide(); });
|
|
78
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && tip && !tip.hidden) hide(); });
|
|
79
|
+
document.addEventListener('pointerdown', () => hide());
|