@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,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);
package/src/switch.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * - `value` is a boolean (`checked` is the same thing as an attribute/property). The form model gets true or false; a plain <form> submits "on" when
10
10
  * it is on and nothing when it is off, like a checkbox.
11
11
  * - `change` and `input` fire when the person toggles it. `busy` sets aria-busy and ignores clicks while a save is running.
12
- * - `label` is the accessible name; a <label for> pointing at the element name also works through aria-labelledby on click-through.
12
+ * - Text inside the element is the visible label: `<xo-switch name="n">Email me about replies</xo-switch>` draws `label.xo-switch-row > button + span.xo-switch-text`; a click on the text toggles the switch and the text is its accessible name.
13
+ * - `label` is the accessible name when there is no visible text (an icon-only row, a switch in a table).
13
14
  */
14
15
  import { XoControl, define } from '@xoong/core';
15
16
 
@@ -19,6 +20,7 @@ class XoSwitch extends XoControl {
19
20
  static props = { ...XoControl.props, label: String, checked: Boolean, busy: Boolean, size: String };
20
21
 
21
22
  /** @type {HTMLButtonElement | null} */ #button = null;
23
+ #words = false;
22
24
 
23
25
  get value() { return truthy(super.value); }
24
26
  set value(v) { super.value = truthy(v); }
@@ -31,15 +33,28 @@ class XoSwitch extends XoControl {
31
33
  isEmpty(v) { return !truthy(v); }
32
34
  get focusTarget() { return this.#button; }
33
35
 
34
- connected() {
36
+ connected() { void this.ready().then(() => this.#build()); } // the visible text is the children: wait until the parser has them
37
+
38
+ #build() {
39
+ if (this.#button) return;
35
40
  if (this.hasAttribute('checked') && !this.value) this.setValue(true);
41
+ const words = (this.textContent ?? '').trim();
36
42
  const button = document.createElement('button');
37
43
  button.type = 'button';
38
44
  button.className = 'xo-switch';
39
45
  button.setAttribute('role', 'switch');
40
46
  button.addEventListener('click', () => this.#toggle());
41
47
  this.#button = button;
42
- this.replaceChildren(button);
48
+ if (words) {
49
+ const row = document.createElement('label');
50
+ row.className = 'xo-switch-row';
51
+ const span = document.createElement('span');
52
+ span.className = 'xo-switch-text';
53
+ span.textContent = words;
54
+ row.append(button, span);
55
+ this.replaceChildren(row);
56
+ } else this.replaceChildren(button);
57
+ this.#words = !!words;
43
58
  if (this.size === 'sm') button.classList.add('xo-switch--sm');
44
59
  this.#paint();
45
60
  }
@@ -63,7 +78,7 @@ class XoSwitch extends XoControl {
63
78
  b.setAttribute('aria-checked', String(this.value));
64
79
  b.disabled = this.isDisabled;
65
80
  b.classList.toggle('xo-switch--sm', this.size === 'sm');
66
- if (this.label) b.setAttribute('aria-label', this.label); else b.removeAttribute('aria-label');
81
+ if (this.label && !this.#words) b.setAttribute('aria-label', this.label); else b.removeAttribute('aria-label'); // visible text names it through its <label>
67
82
  if (this.busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
68
83
  if (this.readonly) b.setAttribute('aria-readonly', 'true'); else b.removeAttribute('aria-readonly');
69
84
  }
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-textarea> is a textarea with a character counter and a thin progress bar toward `maxlength`.
4
+ *
5
+ * <xo-textarea name="excerpt" label="Excerpt" maxlength="160" rows="4"></xo-textarea>
6
+ *
7
+ * - The counter ("142 / 160") sits in `.hint` under the field; it is announced politely only when the text gets near the limit (the last 10%) or goes over.
8
+ * Over the limit the field is invalid with a message (the browser's `maxlength` is not set, so pasted text is kept and shown red, not cut silently).
9
+ * - The value is the string. `input` and `change` fire like a native textarea.
10
+ */
11
+ import { XoControl, define, text } from '@xoong/core';
12
+
13
+ class XoTextarea extends XoControl {
14
+ static props = { ...XoControl.props, label: String, maxlength: Number, rows: Number, placeholder: String };
15
+
16
+ /** @type {HTMLTextAreaElement | null} */ #area = null;
17
+ /** @type {HTMLElement | null} */ #count = null;
18
+ /** @type {HTMLElement | null} */ #bar = null;
19
+
20
+ setValue(v) { super.setValue(v == null ? '' : String(v)); if (this.#area && this.#area.value !== super.value) this.#area.value = super.value; this.#paint(); }
21
+ get focusTarget() { return this.#area; }
22
+ validate(v) { return this.maxlength && String(v).length > this.maxlength ? text('xoong.controls.tooLong', 'Use at most {max} characters.', { max: this.maxlength }) : ''; }
23
+
24
+ connected() {
25
+ const wrap = document.createElement('div');
26
+ wrap.className = 'xo-textarea-meter';
27
+ const area = document.createElement('textarea');
28
+ area.addEventListener('input', (e) => { e.stopPropagation(); this.userInput(area.value); this.#paint(); });
29
+ area.addEventListener('change', (e) => { e.stopPropagation(); this.userChange(); });
30
+ const bar = document.createElement('div');
31
+ bar.className = 'xo-progress xo-progress--thin';
32
+ bar.append(document.createElement('i'));
33
+ const count = document.createElement('span');
34
+ count.className = 'hint';
35
+ wrap.append(area, bar);
36
+ this.#area = area; this.#bar = bar; this.#count = count;
37
+ this.replaceChildren(wrap, count);
38
+ queueMicrotask(() => { area.value = super.value; this.#paint(); });
39
+ }
40
+ attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
41
+
42
+ #paint() {
43
+ const a = this.#area, c = this.#count, bar = this.#bar;
44
+ if (!a || !c || !bar) return;
45
+ const n = String(super.value).length, max = this.maxlength || 0;
46
+ a.rows = this.rows || 3; a.placeholder = this.placeholder ?? ''; a.disabled = this.isDisabled; a.readOnly = this.readonly;
47
+ a.setAttribute('aria-label', this.label || this.name || text('xoong.controls.text', 'Text'));
48
+ a.setAttribute('aria-invalid', String(!!max && n > max));
49
+ bar.hidden = !max; c.hidden = !max;
50
+ if (!max) return;
51
+ c.textContent = `${n} / ${max}`;
52
+ c.setAttribute('aria-live', n >= max * 0.9 ? 'polite' : 'off');
53
+ c.classList.toggle('error', n > max);
54
+ /** @type {HTMLElement} */ (bar.firstElementChild).style.width = `${Math.min(100, (n / max) * 100)}%`;
55
+ }
56
+ }
57
+ define('xo-textarea', XoTextarea);
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-theme-toggle> lets the visitor choose the light or the dark theme.
4
+ *
5
+ * <xo-theme-toggle></xo-theme-toggle>
6
+ *
7
+ * - A toggle button (`aria-pressed` = dark is on) that sets `data-theme="light" | "dark"` on <html>, which the themes read; the choice is saved
8
+ * in localStorage (`xoong-theme`, or the `storage` attribute). Until the visitor chooses, nothing is set and the system setting
9
+ * (prefers-color-scheme) decides, and the button follows it when it changes.
10
+ * - To avoid a flash of the wrong theme, apply the saved value in a script in the <head> before the first paint:
11
+ * `try { const t = localStorage.getItem('xoong-theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; } catch {}`
12
+ * - `xo-theme-change` fires with `{ theme }` ('light' | 'dark'); `reset()` forgets the choice and follows the system again.
13
+ */
14
+ import { XoElement, define, text } from '@xoong/core';
15
+
16
+ class XoThemeToggle extends XoElement {
17
+ static props = { storage: String };
18
+
19
+ /** @type {HTMLButtonElement | null} */ #button = null;
20
+ #query = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;
21
+ #onSystem = () => this.#paint();
22
+ get #key() { return this.storage || 'xoong-theme'; }
23
+
24
+ /** The theme in effect: the chosen one, else the system's. */
25
+ get theme() {
26
+ const set = document.documentElement.dataset.theme;
27
+ return set === 'dark' || set === 'light' ? set : this.#query?.matches ? 'dark' : 'light';
28
+ }
29
+
30
+ connected() {
31
+ try { const saved = localStorage.getItem(this.#key); if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved; } catch { /* not saved */ }
32
+ const b = document.createElement('button');
33
+ b.type = 'button';
34
+ b.className = 'icon-only ghost';
35
+ b.innerHTML = '<span class="ic" aria-hidden="true"></span>';
36
+ b.addEventListener('click', () => this.#set(this.theme === 'dark' ? 'light' : 'dark'));
37
+ this.#button = b;
38
+ this.replaceChildren(b);
39
+ this.#query?.addEventListener?.('change', this.#onSystem);
40
+ this.#paint();
41
+ }
42
+ disconnected() { this.#query?.removeEventListener?.('change', this.#onSystem); }
43
+
44
+ #set(/** @type {'light' | 'dark'} */ theme) {
45
+ document.documentElement.dataset.theme = theme;
46
+ try { localStorage.setItem(this.#key, theme); } catch { /* not saved */ }
47
+ this.#paint();
48
+ this.dispatchEvent(new CustomEvent('xo-theme-change', { bubbles: true, detail: { theme } }));
49
+ }
50
+
51
+ /** Forget the choice: the system setting decides again. */
52
+ reset() {
53
+ delete document.documentElement.dataset.theme;
54
+ try { localStorage.removeItem(this.#key); } catch { /* not saved */ }
55
+ this.#paint();
56
+ this.dispatchEvent(new CustomEvent('xo-theme-change', { bubbles: true, detail: { theme: this.theme } }));
57
+ }
58
+
59
+ #paint() {
60
+ const b = this.#button;
61
+ if (!b) return;
62
+ const dark = this.theme === 'dark';
63
+ b.setAttribute('aria-pressed', String(dark));
64
+ b.setAttribute('aria-label', text('xoong.controls.darkTheme', 'Dark theme'));
65
+ /** @type {HTMLElement} */ (b.firstElementChild).dataset.ic = dark ? 'sun' : 'moon';
66
+ }
67
+ }
68
+ define('xo-theme-toggle', XoThemeToggle);