@xoong/controls 0.0.41 → 0.0.43

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/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);
@@ -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');
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-split-button> is a main action with a menu of related actions next to it.
4
+ *
5
+ * <xo-split-button more="More publish options">
6
+ * <button class="primary" xo-click="POST /articles/{id}/publish">Publish</button>
7
+ * <button xo-click="POST /articles/{id}/schedule">Schedule</button>
8
+ * <a href="/articles/{id}/preview">Preview</a>
9
+ * </xo-split-button>
10
+ *
11
+ * - The first child is the main button. The others become the items of an <xo-menu> opened by the arrow button, which is named by `more`
12
+ * (default "More options") and has `aria-haspopup="menu"`. The arrow keys open the menu from the arrow button (the menu's own keys).
13
+ * - Draws `.xo-split-button > main button + xo-menu > button.icon-only[xo-menu-trigger] + items`. A `primary` class on the main button is carried over to the arrow.
14
+ */
15
+ import { XoElement, define, text } from '@xoong/core';
16
+
17
+ class XoSplitButton extends XoElement {
18
+ static props = { more: String };
19
+
20
+ connected() { void this.ready().then(() => this.#build()); } // the buttons are the children
21
+
22
+ #build() {
23
+ if (this.querySelector(':scope > .xo-split-button')) return;
24
+ const [main, ...items] = [...this.children].filter((c) => c instanceof HTMLElement);
25
+ if (!main) return;
26
+ const wrap = document.createElement('div');
27
+ wrap.className = 'xo-split-button';
28
+ const arrow = document.createElement('button');
29
+ arrow.type = 'button';
30
+ arrow.className = `${main.classList.contains('primary') ? 'primary ' : ''}icon-only`;
31
+ arrow.setAttribute('xo-menu-trigger', '');
32
+ arrow.setAttribute('aria-haspopup', 'menu');
33
+ arrow.setAttribute('aria-label', this.more || text('xoong.controls.moreOptions', 'More options'));
34
+ arrow.innerHTML = '<span class="ic" data-ic="chevron-down" aria-hidden="true"></span>';
35
+ const menu = document.createElement('xo-menu');
36
+ menu.setAttribute('label', arrow.getAttribute('aria-label') ?? '');
37
+ menu.append(arrow, ...items);
38
+ wrap.append(main, menu);
39
+ this.replaceChildren(wrap);
40
+ }
41
+ }
42
+ define('xo-split-button', XoSplitButton);
package/src/stat.js ADDED
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-stat> is a KPI tile: a label, a big value, a change against before, and a sparkline from a series.
4
+ *
5
+ * <xo-stat label="Visits" value="48,210" delta="12.4" delta-label="vs last week" series="20,22,18,26,24,32,29"></xo-stat>
6
+ *
7
+ * - `delta` is a number: positive shows an up arrow, negative a down arrow (the sign and the arrow carry the meaning, not only the colour);
8
+ * `trend="down-is-good"` swaps which direction counts as good (costs, errors). `delta-label` is the text after it.
9
+ * - `series` (comma-separated numbers, or `el.seriesData = [...]`) draws the sparkline; it is decoration (`aria-hidden`), the numbers are in the text.
10
+ * - Draws `.xo-kpi > .xo-kpi__label + .xo-kpi__value + .xo-kpi__delta + svg.xo-kpi__spark`.
11
+ */
12
+ import { XoElement, define, text } from '@xoong/core';
13
+
14
+ const NS = 'http://www.w3.org/2000/svg';
15
+
16
+ class XoStat extends XoElement {
17
+ static props = { label: String, value: String, delta: Number, deltaLabel: String, series: String, trend: String };
18
+
19
+ #list = /** @type {number[] | null} */ (null);
20
+ #built = false;
21
+ get seriesData() { return this.#list ?? (this.series ?? '').split(',').map((s) => s.trim()).filter(Boolean).map(Number).filter((n) => !Number.isNaN(n)); }
22
+ set seriesData(v) { this.#list = Array.isArray(v) ? v.map(Number) : null; if (this.#built) this.#draw(); }
23
+
24
+ connected() { this.#built = true; this.#draw(); }
25
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); if (this.#built) this.#draw(); }
26
+
27
+ #draw() {
28
+ this.classList.add('xo-kpi');
29
+ const parts = [];
30
+ const span = (/** @type {string} */ cls, /** @type {string} */ t) => { const s = document.createElement('span'); s.className = cls; s.textContent = t; return s; };
31
+ parts.push(span('xo-kpi__label', this.label ?? ''), span('xo-kpi__value', this.value ?? ''));
32
+ if (this.hasAttribute('delta') && !Number.isNaN(this.delta)) {
33
+ const d = this.delta;
34
+ const el = span('xo-kpi__delta', '');
35
+ const good = this.trend === 'down-is-good' ? d <= 0 : d >= 0;
36
+ el.dataset.good = String(good);
37
+ el.dataset.direction = d > 0 ? 'up' : d < 0 ? 'down' : 'flat';
38
+ const icon = document.createElement('span');
39
+ icon.className = 'ic'; icon.dataset.ic = d >= 0 ? 'arrow-up' : 'arrow-down'; icon.setAttribute('aria-hidden', 'true');
40
+ const sign = d > 0 ? '+' : d < 0 ? '−' : '';
41
+ el.append(icon, `${sign}${Math.abs(d)}%${this.deltaLabel ? ` ${this.deltaLabel}` : ''}`);
42
+ el.setAttribute('aria-label', (good ? text('xoong.stat.better', '{change} better', { change: `${sign}${Math.abs(d)}%` }) : text('xoong.stat.worse', '{change} worse', { change: `${sign}${Math.abs(d)}%` })) + (this.deltaLabel ? ` ${this.deltaLabel}` : ''));
43
+ parts.push(el);
44
+ }
45
+ const data = this.seriesData;
46
+ if (data.length > 1) {
47
+ const lo = Math.min(...data), hi = Math.max(...data), r = hi - lo || 1;
48
+ const svg = document.createElementNS(NS, 'svg');
49
+ svg.setAttribute('class', 'xo-kpi__spark'); svg.setAttribute('viewBox', '0 0 100 32'); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true');
50
+ const path = document.createElementNS(NS, 'path');
51
+ path.setAttribute('d', data.map((v, i) => `${i ? 'L' : 'M'}${((i / (data.length - 1)) * 100).toFixed(1)} ${(30 - ((v - lo) / r) * 28).toFixed(1)}`).join(' '));
52
+ svg.append(path);
53
+ parts.push(svg);
54
+ }
55
+ this.replaceChildren(...parts);
56
+ }
57
+ }
58
+ define('xo-stat', XoStat);
package/src/stepper.js CHANGED
@@ -18,7 +18,9 @@ class XoStepper extends XoControl {
18
18
 
19
19
  setValue(v) { const n = Number(v); super.setValue(v === '' || v == null || Number.isNaN(n) ? '' : this.#clamp(n)); this.#paint(); }
20
20
  get focusTarget() { return this.#input; }
21
- #clamp(/** @type {number} */ n) { return Math.min(this.max ?? Infinity, Math.max(this.min ?? -Infinity, n)); }
21
+ get #min() { return this.hasAttribute('min') ? /** @type {number} */ (this.min) : -Infinity; } // an absent Number prop reads as 0, so the attribute decides
22
+ get #max() { return this.hasAttribute('max') ? /** @type {number} */ (this.max) : Infinity; }
23
+ #clamp(/** @type {number} */ n) { return Math.min(this.#max, Math.max(this.#min, n)); }
22
24
  get #step() { return this.step || 1; }
23
25
 
24
26
  connected() {
@@ -42,19 +44,20 @@ class XoStepper extends XoControl {
42
44
  if (k === 'Enter') { this.#commit(); return; }
43
45
  const by = { ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 }[k];
44
46
  if (by) { e.preventDefault(); this.#move(by); }
45
- else if ((k === 'Home' && this.min != null) || (k === 'End' && this.max != null)) { e.preventDefault(); this.#set(k === 'Home' ? /** @type {number} */ (this.min) : /** @type {number} */ (this.max)); }
47
+ else if ((k === 'Home' && this.hasAttribute('min')) || (k === 'End' && this.hasAttribute('max'))) { e.preventDefault(); this.#set(k === 'Home' ? /** @type {number} */ (this.min) : /** @type {number} */ (this.max)); }
46
48
  });
47
49
  this.#input = input;
48
50
  this.#buttons = [button(-1, text('xoong.controls.decrease', 'Decrease'), 'minus'), button(1, text('xoong.controls.increase', 'Increase'), 'plus')];
49
51
  wrap.append(this.#buttons[0], input, this.#buttons[1]);
50
52
  this.replaceChildren(wrap);
53
+ if (this.hasAttribute('value')) this.setValue(this.getAttribute('value')); // the base class would keep the attribute text: a number control holds a number
51
54
  queueMicrotask(() => this.#paint());
52
55
  }
53
56
  attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
54
57
 
55
58
  #move(/** @type {number} */ steps) {
56
59
  if (this.isDisabled || this.readonly) return;
57
- const now = super.value === '' ? (this.min ?? 0) - (steps > 0 ? this.#step : 0) : super.value;
60
+ const now = super.value === '' ? (this.hasAttribute('min') ? /** @type {number} */ (this.min) : 0) - (steps > 0 ? this.#step : 0) : super.value;
58
61
  this.#set(now + steps * this.#step);
59
62
  }
60
63
  #set(/** @type {number} */ n) {
@@ -75,11 +78,11 @@ class XoStepper extends XoControl {
75
78
  const v = super.value;
76
79
  i.value = v === '' ? '' : String(v);
77
80
  i.setAttribute('aria-label', this.label || this.name || 'Number');
78
- for (const [a, val] of [['aria-valuemin', this.min], ['aria-valuemax', this.max], ['aria-valuenow', v === '' ? undefined : v]]) { if (val == null) i.removeAttribute(/** @type {string} */ (a)); else i.setAttribute(/** @type {string} */ (a), String(val)); }
81
+ for (const [a, val] of [['aria-valuemin', this.hasAttribute('min') ? this.min : undefined], ['aria-valuemax', this.hasAttribute('max') ? this.max : undefined], ['aria-valuenow', v === '' ? undefined : v]]) { if (val == null) i.removeAttribute(/** @type {string} */ (a)); else i.setAttribute(/** @type {string} */ (a), String(val)); }
79
82
  const off = this.isDisabled || this.readonly;
80
83
  i.disabled = off;
81
- this.#buttons[0].disabled = off || (v !== '' && this.min != null && v <= this.min);
82
- this.#buttons[1].disabled = off || (v !== '' && this.max != null && v >= this.max);
84
+ this.#buttons[0].disabled = off || (v !== '' && this.hasAttribute('min') && v <= /** @type {number} */ (this.min));
85
+ this.#buttons[1].disabled = off || (v !== '' && this.hasAttribute('max') && v >= /** @type {number} */ (this.max));
83
86
  }
84
87
  }
85
88
  define('xo-stepper', XoStepper);
package/src/steps.js ADDED
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-steps> shows where the person is in a flow of a few steps.
4
+ *
5
+ * <xo-steps current="1"><span>Account</span><span>Plan</span><span>Done</span></xo-steps>
6
+ * <xo-steps current="0"><ol><li>Choose</li><li>Details</li><li>Insert</li></ol></xo-steps>
7
+ *
8
+ * - The children are the data: each `li`, `[data-step]` or (when there are none of those) each child element is one step and its text is the title.
9
+ * The element replaces them with `.xo-steps > .xo-step(--done) + .xo-steps__line(--done) + ...`.
10
+ * - `current` is the zero-based index of the step being worked on (0 by default). Steps before it are done: a check mark in the circle (named
11
+ * "Completed"), never colour alone, and the line before the current step is filled. The current step has `aria-current="step"`, its circle is
12
+ * marked (`is-current`) and its title is bold. A `current` equal to the number of steps (or more) marks everything done; no step is current.
13
+ * - The container is a `role=list` of `role=listitem` steps. It is display only: nothing is clickable and there is no value.
14
+ */
15
+ import { XoElement, define, text } from '@xoong/core';
16
+
17
+ class XoSteps extends XoElement {
18
+ static props = { current: Number, label: String };
19
+
20
+ /** @type {string[] | null} */ #titles = null;
21
+ #built = false;
22
+
23
+ connected() { void this.ready().then(() => this.#read()); }
24
+
25
+ #read() {
26
+ if (this.#built || !this.isConnected) return;
27
+ this.#built = true;
28
+ let items = [...this.querySelectorAll('li,[data-step]')];
29
+ if (!items.length) items = [...this.children];
30
+ this.#titles = items.map((el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean);
31
+ this.#paint();
32
+ }
33
+
34
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
35
+
36
+ #paint() {
37
+ const titles = this.#titles;
38
+ if (!titles) return;
39
+ const current = Math.floor(this.current) || 0;
40
+ const root = document.createElement('div');
41
+ root.className = 'xo-steps';
42
+ root.setAttribute('role', 'list');
43
+ if (this.label) root.setAttribute('aria-label', this.label);
44
+ titles.forEach((title, i) => {
45
+ const done = i < current;
46
+ const here = i === current;
47
+ if (i > 0) {
48
+ const line = document.createElement('span');
49
+ line.className = 'xo-steps__line' + (i <= current ? ' xo-steps__line--done' : '');
50
+ line.setAttribute('aria-hidden', 'true');
51
+ root.append(line);
52
+ }
53
+ const step = document.createElement('span');
54
+ step.className = 'xo-step' + (done ? ' xo-step--done' : '');
55
+ step.setAttribute('role', 'listitem');
56
+ if (here) step.setAttribute('aria-current', 'step');
57
+ const n = document.createElement('span');
58
+ n.className = 'xo-step__n' + (here ? ' is-current' : '');
59
+ if (done) {
60
+ n.setAttribute('role', 'img');
61
+ n.setAttribute('aria-label', text('xoong.steps.done', 'Completed'));
62
+ n.innerHTML = '<span class="ic" data-ic="check" aria-hidden="true"></span>';
63
+ } else n.textContent = String(i + 1);
64
+ const name = document.createElement(here ? 'b' : 'span');
65
+ if (!done && !here) name.className = 'muted';
66
+ name.textContent = title;
67
+ step.append(n, name);
68
+ root.append(step);
69
+ });
70
+ this.replaceChildren(root);
71
+ }
72
+ }
73
+ define('xo-steps', XoSteps);