@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.
@@ -0,0 +1,99 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-inline-edit> is a piece of text (a title, a name) that the person can rename in place.
4
+ *
5
+ * <xo-inline-edit name="title" value="Greece Golden Visa" label="Rename"></xo-inline-edit>
6
+ *
7
+ * - At rest it draws `.xo-inline-edit` with the text and a ghost icon button (a pencil) named by `label` ("Rename" by default). A click on the
8
+ * text or on the button starts editing: the text is replaced by an input (named by `label`), focused with everything selected.
9
+ * - Enter saves, Escape cancels, leaving the input (blur) saves. Focus goes back to the button after Enter and Escape.
10
+ * An empty text cancels (the old value stays). The text is trimmed.
11
+ * - `value` is the string. When the person saves a changed text: `input`, `change` and `save` (detail `{ value }`) fire. `cancel` fires when
12
+ * editing is abandoned (Escape, or an empty text). Saving an unchanged text just closes the editor with no event.
13
+ * - `edit()` starts editing from code. `disabled` and `readonly` hide the pencil's action: the text is shown and nothing can be edited.
14
+ */
15
+ import { XoControl, define, text } from '@xoong/core';
16
+
17
+ class XoInlineEdit extends XoControl {
18
+ static props = { ...XoControl.props, label: String };
19
+
20
+ /** @type {HTMLElement | null} */ #root = null;
21
+ /** @type {HTMLElement | null} */ #text = null;
22
+ /** @type {HTMLButtonElement | null} */ #button = null;
23
+ /** @type {HTMLInputElement | null} */ #input = null;
24
+
25
+ setValue(v) { super.setValue(v == null ? '' : String(v)); this.#paintText(); }
26
+ reset() { super.reset(); this.#paintText(); }
27
+ get focusTarget() { return this.#input ?? this.#button; }
28
+ /** True while the input is shown. */
29
+ get editing() { return !!this.#input; }
30
+
31
+ connected() {
32
+ const root = document.createElement('span');
33
+ root.className = 'xo-inline-edit';
34
+ const label = document.createElement('span');
35
+ label.className = 'xo-inline-edit__text';
36
+ label.addEventListener('click', () => this.edit());
37
+ const button = document.createElement('button');
38
+ button.type = 'button';
39
+ button.className = 'ghost icon-only sm';
40
+ button.innerHTML = '<span class="ic" data-ic="edit" aria-hidden="true"></span>';
41
+ button.addEventListener('click', () => this.edit());
42
+ root.append(label, button);
43
+ this.#root = root; this.#text = label; this.#button = button;
44
+ this.replaceChildren(root);
45
+ queueMicrotask(() => this.#paintText());
46
+ }
47
+
48
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paintText(); }
49
+
50
+ /** Start editing: an input replaces the text, focused with the text selected. */
51
+ edit() {
52
+ const root = this.#root, label = this.#text, button = this.#button;
53
+ if (!root || !label || !button || this.#input || this.isDisabled || this.readonly) return;
54
+ const input = document.createElement('input');
55
+ input.type = 'text';
56
+ input.value = String(super.value);
57
+ input.setAttribute('aria-label', this.label || text('xoong.inlineEdit.rename', 'Rename'));
58
+ input.addEventListener('input', (e) => e.stopPropagation());
59
+ input.addEventListener('change', (e) => e.stopPropagation());
60
+ input.addEventListener('keydown', (e) => {
61
+ if (e.key === 'Enter') { e.preventDefault(); this.#finish(true, true); }
62
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.#finish(false, true); }
63
+ });
64
+ input.addEventListener('blur', () => this.#finish(true, false));
65
+ this.#input = input;
66
+ root.replaceChildren(input); // removed rather than hidden: a button's own display rule would beat [hidden]
67
+ input.focus();
68
+ input.select();
69
+ }
70
+
71
+ /** @param {boolean} save @param {boolean} refocus */
72
+ #finish(save, refocus) {
73
+ const input = this.#input, label = this.#text, button = this.#button;
74
+ if (!input || !label || !button) return;
75
+ this.#input = null; // first: removing the focused input may fire blur
76
+ const next = input.value.trim();
77
+ this.#root?.replaceChildren(label, button);
78
+ if (save && next !== '') {
79
+ if (next !== super.value) {
80
+ this.userInput(next);
81
+ this.userChange();
82
+ this.emit('save', { value: next });
83
+ }
84
+ } else this.emit('cancel', {});
85
+ this.#paintText();
86
+ if (refocus) button.focus();
87
+ }
88
+
89
+ #paintText() {
90
+ const label = this.#text, button = this.#button;
91
+ if (!label || !button) return;
92
+ label.textContent = String(super.value);
93
+ const name = this.label || text('xoong.inlineEdit.rename', 'Rename');
94
+ button.setAttribute('aria-label', name);
95
+ button.title = name;
96
+ button.disabled = this.isDisabled || this.readonly;
97
+ }
98
+ }
99
+ define('xo-inline-edit', XoInlineEdit);
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 room = (globalThis.innerHeight || 768) - r.bottom;
28
- const height = panel.getBoundingClientRect().height;
29
- const flip = room < height + 8 && r.top > room; // not enough room below and more above: open upwards
30
- Object.assign(panel.style, {
31
- position: 'fixed', inset: 'auto', margin: '0', left: `${r.left}px`,
32
- top: flip ? `${Math.max(4, r.top - height)}px` : `${r.bottom}px`,
33
- ...(matchWidth ? { width: `${r.width}px`, boxSizing: 'border-box' } : {}),
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/otp.js ADDED
@@ -0,0 +1,152 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-otp> is a one-time code: one box per digit.
4
+ *
5
+ * <xo-otp name="code" label="Verification code" length="6"></xo-otp>
6
+ *
7
+ * - It draws `.xo-otp[role=group]` with `length` (6 by default) `input[maxlength=1][inputmode=numeric]`. The group is named by `label`; each box is
8
+ * named "Digit n"; the first box has `autocomplete="one-time-code"` so the browser and the phone offer the SMS code.
9
+ * - Digits only: anything else is dropped. Typing a digit moves to the next box; Backspace clears the box (or the one before when this one is empty)
10
+ * and moves back; Delete clears the box; ArrowLeft/ArrowRight/Home/End move between boxes. Pasting (or an autofill that puts the whole code in one
11
+ * box) splits the digits across the boxes.
12
+ * - `value` is the string of digits typed so far ("" when empty). A partly filled code is invalid ("Enter all 6 digits."); `required` rejects an empty one.
13
+ * - `complete` fires (bubbles, detail `{ value }`) when the person fills the last box. `input` fires on every edit; `change` fires when the code
14
+ * is complete and when the focus leaves the group after an edit.
15
+ */
16
+ import { XoControl, define, text } from '@xoong/core';
17
+
18
+ const digitsOf = (/** @type {any} */ v) => String(v ?? '').replace(/\D/g, '');
19
+
20
+ class XoOtp extends XoControl {
21
+ static props = { ...XoControl.props, label: String, length: Number };
22
+
23
+ /** @type {HTMLElement | null} */ #group = null;
24
+ /** @type {HTMLInputElement[]} */ #boxes = [];
25
+ /** @type {string[]} one entry per box, '' when empty (a gap is possible after Delete) */ #cells = [];
26
+ #own = false;
27
+ #pending = false;
28
+ get #len() { const n = Math.floor(this.length); return n >= 1 ? Math.min(n, 12) : 6; }
29
+
30
+ setValue(v) {
31
+ const d = digitsOf(v).slice(0, this.#len);
32
+ super.setValue(d);
33
+ if (!this.#own) this.#cells = Array.from({ length: this.#len }, (_, i) => d[i] ?? '');
34
+ this.#paint();
35
+ }
36
+ reset() { super.reset(); this.setValue(super.value); }
37
+ validate(v) { return v !== '' && String(v).length < this.#len ? text('xoong.otp.incomplete', 'Enter all {n} digits.', { n: this.#len }) : ''; }
38
+ get focusTarget() { return this.#boxes[Math.min(super.value.length, this.#boxes.length - 1)] ?? this.#boxes[0] ?? null; }
39
+
40
+ connected() {
41
+ this.#build();
42
+ this.addEventListener('focusout', (e) => { if (this.#pending && !this.contains(/** @type {Node | null} */ (e.relatedTarget))) { this.#pending = false; this.userChange(); } });
43
+ queueMicrotask(() => this.setValue(super.value));
44
+ }
45
+
46
+ #build() {
47
+ const group = document.createElement('div');
48
+ group.className = 'xo-otp';
49
+ group.setAttribute('role', 'group');
50
+ this.#boxes = Array.from({ length: this.#len }, (_, i) => {
51
+ const box = document.createElement('input');
52
+ box.type = 'text';
53
+ box.maxLength = 1;
54
+ box.setAttribute('inputmode', 'numeric');
55
+ box.setAttribute('pattern', '[0-9]*');
56
+ box.autocomplete = i === 0 ? 'one-time-code' : 'off';
57
+ box.addEventListener('input', (e) => { e.stopPropagation(); this.#typed(i); });
58
+ box.addEventListener('change', (e) => e.stopPropagation());
59
+ box.addEventListener('keydown', (e) => this.#key(i, e));
60
+ box.addEventListener('paste', (e) => this.#paste(i, e));
61
+ box.addEventListener('focus', () => box.select());
62
+ return box;
63
+ });
64
+ group.append(...this.#boxes);
65
+ this.#group = group;
66
+ this.replaceChildren(group);
67
+ this.#cells = Array.from({ length: this.#len }, (_, i) => super.value[i] ?? '');
68
+ }
69
+
70
+ attributeChangedCallback(attr, old, value) {
71
+ super.attributeChangedCallback(attr, old, value);
72
+ if (attr === 'length' && this.#group && this.#boxes.length !== this.#len) { this.#build(); this.setValue(super.value); }
73
+ else this.#paint();
74
+ }
75
+
76
+ #focus(/** @type {number} */ i) {
77
+ const box = this.#boxes[Math.max(0, Math.min(i, this.#boxes.length - 1))];
78
+ box?.focus();
79
+ box?.select();
80
+ }
81
+
82
+ #typed(/** @type {number} */ i) {
83
+ const d = digitsOf(this.#boxes[i].value);
84
+ if (d.length > 1) { this.#fill(i, d); return; }
85
+ this.#cells[i] = d;
86
+ this.#boxes[i].value = d; // a letter is dropped
87
+ this.#commit();
88
+ if (d && i < this.#boxes.length - 1) this.#focus(i + 1);
89
+ }
90
+
91
+ #key(/** @type {number} */ i, /** @type {KeyboardEvent} */ e) {
92
+ const k = e.key;
93
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
94
+ if (k === 'Backspace') {
95
+ e.preventDefault();
96
+ if (this.isDisabled || this.readonly) return;
97
+ if (this.#cells[i]) { this.#cells[i] = ''; this.#commit(); this.#focus(i - 1); }
98
+ else if (i > 0) { this.#cells[i - 1] = ''; this.#commit(); this.#focus(i - 1); }
99
+ } else if (k === 'Delete') {
100
+ e.preventDefault();
101
+ if (!this.isDisabled && !this.readonly && this.#cells[i]) { this.#cells[i] = ''; this.#commit(); }
102
+ } else if (k === 'ArrowLeft') { e.preventDefault(); this.#focus(i - 1); }
103
+ else if (k === 'ArrowRight') { e.preventDefault(); this.#focus(i + 1); }
104
+ else if (k === 'Home') { e.preventDefault(); this.#focus(0); }
105
+ else if (k === 'End') { e.preventDefault(); this.#focus(this.#boxes.length - 1); }
106
+ else if (k.length === 1 && !/\d/.test(k)) e.preventDefault(); // letters and symbols do nothing
107
+ }
108
+
109
+ #paste(/** @type {number} */ i, /** @type {ClipboardEvent} */ e) {
110
+ e.preventDefault();
111
+ if (this.isDisabled || this.readonly) return;
112
+ const d = digitsOf(e.clipboardData?.getData('text'));
113
+ if (d) this.#fill(d.length >= this.#boxes.length ? 0 : i, d);
114
+ }
115
+
116
+ #fill(/** @type {number} */ start, /** @type {string} */ d) {
117
+ let n = 0;
118
+ for (; n < d.length && start + n < this.#boxes.length; n++) this.#cells[start + n] = d[n];
119
+ this.#paint();
120
+ this.#commit();
121
+ this.#focus(start + n);
122
+ }
123
+
124
+ #commit() {
125
+ if (this.isDisabled || this.readonly) { this.#paint(); return; }
126
+ const v = this.#cells.join('');
127
+ const before = super.value;
128
+ if (v === before) { this.#paint(); return; } // a letter that was dropped, or the same digit again
129
+ this.#own = true;
130
+ try { this.userInput(v); } finally { this.#own = false; }
131
+ this.#pending = true;
132
+ if (v.length === this.#boxes.length && before !== v) {
133
+ this.#pending = false;
134
+ this.userChange();
135
+ this.emit('complete', { value: v });
136
+ }
137
+ }
138
+
139
+ #paint() {
140
+ const g = this.#group;
141
+ if (!g) return;
142
+ g.setAttribute('aria-label', this.label || text('xoong.otp.label', 'Verification code'));
143
+ this.#boxes.forEach((box, i) => {
144
+ box.value = this.#cells[i] ?? '';
145
+ box.setAttribute('aria-label', text('xoong.otp.digit', 'Digit {n}', { n: i + 1 }));
146
+ box.disabled = this.isDisabled;
147
+ box.readOnly = this.readonly;
148
+ if (this.required) box.setAttribute('aria-required', 'true'); else box.removeAttribute('aria-required');
149
+ });
150
+ }
151
+ }
152
+ define('xo-otp', XoOtp);
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);
@@ -0,0 +1,98 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-progress> is a bar that shows how much is done; <xo-spinner> shows that something is happening and nobody knows for how long.
4
+ *
5
+ * <xo-progress value="45" label="Upload"></xo-progress>
6
+ * <xo-progress value="30" max="50" thin label="Characters used"></xo-progress>
7
+ * <xo-progress label="Importing"></xo-progress> <!-- no value: indeterminate -->
8
+ * <xo-spinner label="Saving" size="md"></xo-spinner>
9
+ *
10
+ * - <xo-progress> draws `.xo-progress[role=progressbar] > i`, with `aria-valuemin="0"`, `aria-valuemax` (`max`, 100 by default) and `aria-valuenow`
11
+ * (`value`, clamped between 0 and `max`). The bar's width is set through the CSS object model (strict CSP: no style attribute). `label` is its name.
12
+ * Without a `value` attribute (or with `value` set to null) the bar is indeterminate: no `aria-valuenow`, `data-state="indeterminate"`, and the
13
+ * bar shows a short fixed fill. `thin` is the 4px bar (`.xo-progress--thin`).
14
+ * - <xo-spinner> draws `.xo-spinner` (`size="md"` or `"lg"` for the larger ones) with `role=status` and the name `label` ("Loading" by default).
15
+ * `label=""` makes it decorative (`aria-hidden`, no role) for a spinner that sits next to text that already says it. Reduced motion is handled by the CSS.
16
+ */
17
+ import { XoElement, define, text, signal } from '@xoong/core';
18
+
19
+ class XoProgress extends XoElement {
20
+ static props = { max: Number, label: String, thin: Boolean };
21
+ static get observedAttributes() { return [...super.observedAttributes, 'value']; }
22
+
23
+ #value = signal(/** @type {number | null} */ (null));
24
+ /** @type {HTMLElement | null} */ #bar = null;
25
+ /** @type {HTMLElement | null} */ #fill = null;
26
+
27
+ /** The amount done, or null when it is not known (indeterminate). */
28
+ get value() { return this.#value.value; }
29
+ set value(v) { this.#value.value = v == null || v === '' || Number.isNaN(Number(v)) ? null : Number(v); this.#paint(); }
30
+
31
+ connected() {
32
+ const bar = document.createElement('div');
33
+ bar.setAttribute('role', 'progressbar');
34
+ const fill = document.createElement('i');
35
+ bar.append(fill);
36
+ this.#bar = bar; this.#fill = fill;
37
+ this.replaceChildren(bar);
38
+ if (this.hasAttribute('value')) this.#value.value = this.#number(this.getAttribute('value'));
39
+ this.#paint();
40
+ }
41
+
42
+ #number(/** @type {string | null} */ raw) { return raw == null || raw.trim() === '' || Number.isNaN(Number(raw)) ? null : Number(raw); }
43
+
44
+ attributeChangedCallback(attr, old, value) {
45
+ super.attributeChangedCallback(attr, old, value);
46
+ if (attr === 'value') this.#value.value = this.#number(value);
47
+ this.#paint();
48
+ }
49
+
50
+ #paint() {
51
+ const bar = this.#bar, fill = this.#fill;
52
+ if (!bar || !fill) return;
53
+ const max = this.max > 0 ? this.max : 100;
54
+ const v = this.#value.peek();
55
+ bar.className = this.thin ? 'xo-progress xo-progress--thin' : 'xo-progress';
56
+ bar.setAttribute('aria-valuemin', '0');
57
+ bar.setAttribute('aria-valuemax', String(max));
58
+ bar.setAttribute('aria-label', this.label || text('xoong.progress.label', 'Progress'));
59
+ if (v === null) {
60
+ bar.removeAttribute('aria-valuenow');
61
+ this.dataset.state = 'indeterminate';
62
+ fill.style.width = '40%';
63
+ } else {
64
+ const now = Math.min(max, Math.max(0, v));
65
+ bar.setAttribute('aria-valuenow', String(now));
66
+ this.dataset.state = now >= max ? 'complete' : 'progress';
67
+ fill.style.width = `${(now / max) * 100}%`;
68
+ }
69
+ }
70
+ }
71
+ define('xo-progress', XoProgress);
72
+
73
+ class XoSpinner extends XoElement {
74
+ static props = { label: String, size: String };
75
+
76
+ /** @type {HTMLElement | null} */ #dot = null;
77
+
78
+ connected() {
79
+ const dot = document.createElement('span');
80
+ this.#dot = dot;
81
+ this.replaceChildren(dot);
82
+ this.#paint();
83
+ }
84
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
85
+
86
+ #paint() {
87
+ const dot = this.#dot;
88
+ if (!dot) return;
89
+ dot.className = 'xo-spinner' + (this.size === 'md' || this.size === 'lg' ? ` xo-spinner--${this.size}` : '');
90
+ if (this.hasAttribute('label') && this.label === '') {
91
+ dot.removeAttribute('role'); dot.removeAttribute('aria-label'); dot.setAttribute('aria-hidden', 'true');
92
+ } else {
93
+ dot.removeAttribute('aria-hidden'); dot.setAttribute('role', 'status');
94
+ dot.setAttribute('aria-label', this.label || text('xoong.spinner.label', 'Loading'));
95
+ }
96
+ }
97
+ }
98
+ define('xo-spinner', XoSpinner);
@@ -0,0 +1,83 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-range-slider> picks a minimum and a maximum on one track: two native range inputs, the part between them filled.
4
+ *
5
+ * <xo-range-slider name="price" label="Price" min="0" max="100" step="5" value="20,70" min-label="Minimum price" max-label="Maximum price"></xo-range-slider>
6
+ *
7
+ * - The value is `[min, max]` (numbers); the attribute is `value="20,70"`. The thumbs cannot cross: the one you move stops at the other.
8
+ * - Each input is named (`min-label`, `max-label`, default "Minimum" and "Maximum"); the group is named by `label`. Native keys work on each thumb.
9
+ * - `input` fires while dragging, `change` on release. The fill is placed through the CSS object model (no style attribute).
10
+ */
11
+ import { XoControl, define, text } from '@xoong/core';
12
+
13
+ const pair = (/** @type {any} */ v) => (Array.isArray(v) ? v : typeof v === 'string' && v.includes(',') ? v.split(',') : []).map(Number);
14
+
15
+ class XoRangeSlider extends XoControl {
16
+ static props = { ...XoControl.props, label: String, min: Number, max: Number, step: Number, minLabel: String, maxLabel: String };
17
+
18
+ /** @type {HTMLInputElement[]} */ #inputs = [];
19
+ /** @type {HTMLElement | null} */ #fill = null;
20
+
21
+ get #lo() { return this.hasAttribute('min') ? this.min : 0; } // an absent Number prop reads as 0
22
+ get #hi() { return this.hasAttribute('max') ? this.max : 100; }
23
+ setValue(v) {
24
+ let [a, b] = pair(v);
25
+ if (Number.isNaN(a) || a === undefined) { a = this.#lo; b = this.#hi; }
26
+ if (Number.isNaN(b) || b === undefined) b = this.#hi;
27
+ a = Math.min(Math.max(a, this.#lo), this.#hi); b = Math.min(Math.max(b, this.#lo), this.#hi);
28
+ super.setValue(a <= b ? [a, b] : [b, a]);
29
+ this.#paint();
30
+ }
31
+ serialize(v) { return Array.isArray(v) ? v.join(',') : null; }
32
+ isEmpty() { return false; }
33
+ get focusTarget() { return this.#inputs[0] ?? null; }
34
+
35
+ connected() {
36
+ const box = document.createElement('div');
37
+ box.className = 'xo-range-dual';
38
+ box.setAttribute('role', 'group');
39
+ const track = document.createElement('div');
40
+ track.className = 'xo-range-dual__track';
41
+ const fill = document.createElement('div');
42
+ fill.className = 'xo-range-dual__fill';
43
+ track.append(fill);
44
+ box.append(track);
45
+ this.#fill = fill;
46
+ this.#inputs = [0, 1].map((i) => {
47
+ const input = document.createElement('input');
48
+ input.type = 'range'; input.className = 'xo-range';
49
+ input.addEventListener('input', (e) => { e.stopPropagation(); this.#move(i); });
50
+ input.addEventListener('change', (e) => { e.stopPropagation(); this.userChange(); });
51
+ box.append(input);
52
+ return input;
53
+ });
54
+ this.replaceChildren(box);
55
+ if (this.hasAttribute('value')) this.setValue(this.getAttribute('value'));
56
+ else if (!Array.isArray(super.value)) this.setValue('');
57
+ this.#paint();
58
+ }
59
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); if (attr === 'value' && this.#inputs.length) this.setValue(value); else this.#paint(); }
60
+
61
+ #move(/** @type {number} */ i) {
62
+ const [a, b] = this.#inputs.map((x) => Number(x.value));
63
+ this.userInput(i === 0 ? [Math.min(a, b), b] : [a, Math.max(a, b)]);
64
+ this.#paint();
65
+ }
66
+
67
+ #paint() {
68
+ if (!this.#inputs.length) return;
69
+ const v = super.value;
70
+ const [a, b] = Array.isArray(v) ? v : [this.#lo, this.#hi];
71
+ const span = this.#hi - this.#lo || 1;
72
+ this.#inputs.forEach((x, i) => {
73
+ x.min = String(this.#lo); x.max = String(this.#hi); x.step = String(this.step || 1);
74
+ x.value = String(i ? b : a);
75
+ x.disabled = this.isDisabled || this.readonly;
76
+ x.setAttribute('aria-label', (i ? this.maxLabel : this.minLabel) || (i ? text('xoong.controls.maximum', 'Maximum') : text('xoong.controls.minimum', 'Minimum')));
77
+ x.style.pointerEvents = 'none'; // the thumbs take the pointer (the CSS gives it back to them), the track under them does not
78
+ });
79
+ /** @type {HTMLElement} */ (this.firstElementChild).setAttribute('aria-label', this.label || this.name || text('xoong.controls.range', 'Range'));
80
+ if (this.#fill) { this.#fill.style.left = `${((a - this.#lo) / span) * 100}%`; this.#fill.style.right = `${100 - ((b - this.#lo) / span) * 100}%`; }
81
+ }
82
+ }
83
+ define('xo-range-slider', XoRangeSlider);
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-segmented> is a group of buttons where one is chosen (a view switch, a filter, a billing period).
4
+ *
5
+ * <xo-segmented name="period" label="Billing" value="month">
6
+ * <button value="month">Monthly</button><button value="year">Yearly <span class="xo-count">-20%</span></button>
7
+ * </xo-segmented>
8
+ *
9
+ * - The children are the segments (their `value`, text and icons are kept). The value is the chosen segment's `value`; with `multiple` it is an array
10
+ * and each segment toggles on its own. Draws `.xo-seg[role=group]` with `aria-pressed` on every button.
11
+ * - Arrow keys move the focus between segments (Home/End too, wrapping off); Space or Enter chooses. `change` fires when the choice changes.
12
+ * - `clearable` lets a click on the chosen segment unchoose it (value '').
13
+ */
14
+ import { XoControl, define } from '@xoong/core';
15
+
16
+ class XoSegmented extends XoControl {
17
+ static props = { ...XoControl.props, label: String, multiple: Boolean, clearable: Boolean };
18
+
19
+ /** @type {HTMLButtonElement[]} */ #buttons = [];
20
+
21
+ setValue(v) { super.setValue(this.multiple ? (Array.isArray(v) ? v.map(String) : v ? String(v).split(',') : []) : Array.isArray(v) ? String(v[0] ?? '') : v == null ? '' : String(v)); this.#paint(); }
22
+ serialize(v) { return Array.isArray(v) ? (v.length ? v.join(',') : null) : super.serialize(v); }
23
+ get focusTarget() { return this.#buttons.find((b) => b.tabIndex === 0) ?? this.#buttons[0] ?? null; }
24
+
25
+ connected() { void this.ready().then(() => this.#build()); } // the segments are the children
26
+
27
+ #build() {
28
+ if (this.#buttons.length) return;
29
+ const group = document.createElement('div');
30
+ group.className = 'xo-seg';
31
+ group.setAttribute('role', 'group');
32
+ if (this.label) group.setAttribute('aria-label', this.label);
33
+ this.#buttons = [...this.children].filter((c) => c instanceof HTMLButtonElement).map((b) => {
34
+ const btn = /** @type {HTMLButtonElement} */ (b);
35
+ btn.type = 'button';
36
+ btn.addEventListener('click', () => this.#pick(btn.value));
37
+ btn.addEventListener('keydown', (e) => this.#key(e, btn));
38
+ group.append(btn);
39
+ return btn;
40
+ });
41
+ this.replaceChildren(group);
42
+ if (!this.multiple && super.value === '' && this.hasAttribute('value')) this.setValue(this.getAttribute('value'));
43
+ else if (this.multiple && this.hasAttribute('value') && !(super.value ?? []).length) this.setValue(this.getAttribute('value'));
44
+ this.#paint();
45
+ }
46
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); if (attr === 'value' && this.#buttons.length) this.setValue(value); }
47
+
48
+ #pick(/** @type {string} */ v) {
49
+ if (this.isDisabled || this.readonly) return;
50
+ const now = super.value;
51
+ const next = this.multiple ? (now.includes(v) ? now.filter((x) => x !== v) : [...now, v]) : now === v ? (this.clearable ? '' : v) : v;
52
+ if (JSON.stringify(next) === JSON.stringify(now)) return;
53
+ this.userInput(next);
54
+ this.userChange();
55
+ this.#paint();
56
+ }
57
+ #key(/** @type {KeyboardEvent} */ e, /** @type {HTMLButtonElement} */ btn) {
58
+ const i = this.#buttons.indexOf(btn), n = this.#buttons.length;
59
+ const to = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: n - 1 }[e.key];
60
+ if (to === undefined) return;
61
+ e.preventDefault();
62
+ const next = this.#buttons[Math.min(n - 1, Math.max(0, to))];
63
+ next.focus();
64
+ this.#buttons.forEach((b) => { b.tabIndex = b === next ? 0 : -1; });
65
+ }
66
+ #paint() {
67
+ const v = super.value;
68
+ const on = (/** @type {string} */ x) => (Array.isArray(v) ? v.includes(x) : v === x);
69
+ const current = this.#buttons.find((b) => on(b.value)) ?? this.#buttons[0];
70
+ for (const b of this.#buttons) { b.setAttribute('aria-pressed', String(on(b.value))); b.disabled = this.isDisabled; b.tabIndex = b === current ? 0 : -1; }
71
+ }
72
+ }
73
+ define('xo-segmented', XoSegmented);
package/src/slider.js CHANGED
@@ -26,6 +26,7 @@ class XoSlider extends XoControl {
26
26
  input.addEventListener('change', (e) => { e.stopPropagation(); this.userChange(); });
27
27
  this.#input = input;
28
28
  this.replaceChildren(input);
29
+ if (this.hasAttribute('value')) this.setValue(this.getAttribute('value')); // the base class would keep the attribute text: a number control holds a number
29
30
  queueMicrotask(() => this.#paint());
30
31
  }
31
32
  attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
@@ -33,7 +34,7 @@ class XoSlider extends XoControl {
33
34
  #paint() {
34
35
  const i = this.#input;
35
36
  if (!i) return;
36
- const min = this.min ?? 0, max = this.max ?? 100;
37
+ const min = this.hasAttribute('min') ? this.min : 0, max = this.hasAttribute('max') ? this.max : 100; // an absent Number prop reads as 0
37
38
  i.min = String(min); i.max = String(max); i.step = String(this.step || 1);
38
39
  i.disabled = this.isDisabled || this.readonly;
39
40
  i.setAttribute('aria-label', this.label || this.name || 'Value');