@gjsify/adwaita-web 0.22.0 → 0.23.0

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.
@@ -1,19 +1,30 @@
1
1
  // <adw-spin-row> — Row with a title/subtitle and a numeric spin control (+/− buttons).
2
2
  // Attributes: title, subtitle, min, max, step, value
3
3
  // Events: notify::value (CustomEvent, mirrors GJS GObject signal naming)
4
+ //
5
+ // The ADJUSTMENT state machine (value/min/max/step plus the clamp-on-every-
6
+ // mutation and the change detection) is HEADLESS and lives in
7
+ // `@gjsify/adwaita-core` (ADR 0004) as {@link SpinState}; this element composes it
8
+ // and keeps only the DOM render half — the −/input/+ control, the step-precision
9
+ // display (the `Adw.SpinRow:digits` concern, which belongs to the widget, not to
10
+ // the adjustment) and the `notify::value` event.
11
+ // `@gjsify/adwaita-nativescript` composes the same state machine, so both ports
12
+ // share one behaviour.
13
+ //
4
14
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
5
15
  // Copyright (c) 2025 csm. MIT License.
6
16
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
7
- // title/subtitle text column added to match Adw.SpinRow.
17
+ // title/subtitle text column added to match Adw.SpinRow; the adjustment state
18
+ // machine composed from @gjsify/adwaita-core.
19
+
20
+ import { SpinState } from '@gjsify/adwaita-core';
8
21
 
9
22
  export class AdwSpinRow extends HTMLElement {
10
23
  private _input!: HTMLInputElement;
11
24
  private _titleEl!: HTMLSpanElement;
12
25
  private _subtitleEl!: HTMLSpanElement;
13
- private _min = 0;
14
- private _max = 100;
15
- private _step = 1;
16
- private _value = 0;
26
+ /** The headless value/min/max/step adjustment with clamping (ADR 0004). */
27
+ private readonly _state = new SpinState();
17
28
  private _initialized = false;
18
29
 
19
30
  static get observedAttributes() {
@@ -21,28 +32,26 @@ export class AdwSpinRow extends HTMLElement {
21
32
  }
22
33
 
23
34
  get value(): number {
24
- return this._value;
35
+ return this._state.value;
25
36
  }
26
37
 
27
38
  set value(v: number) {
28
- const clamped = Math.min(this._max, Math.max(this._min, v));
29
- // Round to step precision to avoid floating-point drift
30
- const decimals = this._countDecimals(this._step);
31
- this._value = parseFloat(clamped.toFixed(decimals));
32
- if (this._input) {
33
- this._input.value = this._formatValue(this._value);
34
- }
35
- this.setAttribute('value', String(this._value));
39
+ // Round to step precision BEFORE the core clamp so repeated stepping
40
+ // cannot accumulate floating-point drift (0.1 + 0.2 → 0.30000000000000004).
41
+ this._state.setValue(this._roundToStep(v));
42
+ this._syncValue();
36
43
  }
37
44
 
38
45
  connectedCallback() {
39
46
  if (this._initialized) return;
40
47
  this._initialized = true;
41
48
 
42
- this._min = parseFloat(this.getAttribute('min') || '0');
43
- this._max = parseFloat(this.getAttribute('max') || '100');
44
- this._step = parseFloat(this.getAttribute('step') || '1');
45
- this._value = parseFloat(this.getAttribute('value') || String(this._min));
49
+ // Seed the headless adjustment BEFORE subscribing, so building the initial
50
+ // DOM below is not driven by a change notification.
51
+ this._state.setMin(parseFloat(this.getAttribute('min') || '0'));
52
+ this._state.setMax(parseFloat(this.getAttribute('max') || '100'));
53
+ this._state.setStep(parseFloat(this.getAttribute('step') || '1'));
54
+ this._state.setValue(this._roundToStep(parseFloat(this.getAttribute('value') || String(this._state.min))));
46
55
 
47
56
  const text = document.createElement('div');
48
57
  text.className = 'adw-row-text';
@@ -59,29 +68,34 @@ export class AdwSpinRow extends HTMLElement {
59
68
  const decBtn = document.createElement('button');
60
69
  decBtn.className = 'adw-spin-dec';
61
70
  decBtn.textContent = '−';
62
- decBtn.addEventListener('click', () => this._adjust(-this._step));
71
+ decBtn.addEventListener('click', () => this._adjust(-this._state.step));
63
72
 
64
73
  const input = document.createElement('input');
65
74
  input.type = 'text';
66
- input.value = this._formatValue(this._value);
75
+ input.value = this._formatValue(this._state.value);
67
76
  input.addEventListener('change', () => {
68
77
  const parsed = parseFloat(input.value);
69
78
  if (!Number.isNaN(parsed)) {
70
79
  this.value = parsed;
71
80
  this._emitChange();
72
81
  } else {
73
- input.value = this._formatValue(this._value);
82
+ input.value = this._formatValue(this._state.value);
74
83
  }
75
84
  });
76
85
 
77
86
  const incBtn = document.createElement('button');
78
87
  incBtn.className = 'adw-spin-inc';
79
88
  incBtn.textContent = '+';
80
- incBtn.addEventListener('click', () => this._adjust(this._step));
89
+ incBtn.addEventListener('click', () => this._adjust(this._state.step));
81
90
 
82
91
  control.append(decBtn, input, incBtn);
83
92
  this.replaceChildren(text, control);
84
93
  this._input = input;
94
+
95
+ // Every core change (a set, or a re-clamp after a bound moved) refreshes
96
+ // the display; `notify::value` is emitted at the interactive sites below.
97
+ this._state.subscribe(() => this._syncValue());
98
+
85
99
  this._renderText();
86
100
  }
87
101
 
@@ -94,19 +108,16 @@ export class AdwSpinRow extends HTMLElement {
94
108
  const num = parseFloat(val || '0');
95
109
  switch (name) {
96
110
  case 'value':
97
- if (num !== this._value) {
98
- this._value = Math.min(this._max, Math.max(this._min, num));
99
- this._input.value = this._formatValue(this._value);
100
- }
111
+ this._state.setValue(this._roundToStep(num));
101
112
  break;
102
113
  case 'min':
103
- this._min = num;
114
+ this._state.setMin(num);
104
115
  break;
105
116
  case 'max':
106
- this._max = num;
117
+ this._state.setMax(num);
107
118
  break;
108
119
  case 'step':
109
- this._step = num;
120
+ this._state.setStep(num);
110
121
  break;
111
122
  }
112
123
  }
@@ -118,8 +129,9 @@ export class AdwSpinRow extends HTMLElement {
118
129
  this._subtitleEl.hidden = subtitle.length === 0;
119
130
  }
120
131
 
132
+ /** A stepper press — the interactive path, so it emits `notify::value`. */
121
133
  private _adjust(delta: number) {
122
- this.value = this._value + delta;
134
+ this.value = this._state.value + delta;
123
135
  this._emitChange();
124
136
  }
125
137
 
@@ -127,11 +139,23 @@ export class AdwSpinRow extends HTMLElement {
127
139
  this.dispatchEvent(
128
140
  new CustomEvent('notify::value', {
129
141
  bubbles: true,
130
- detail: { value: this._value },
142
+ detail: { value: this._state.value },
131
143
  }),
132
144
  );
133
145
  }
134
146
 
147
+ /** Mirror the core value into the input display + the reflected attribute. */
148
+ private _syncValue() {
149
+ if (this._input) this._input.value = this._formatValue(this._state.value);
150
+ this.setAttribute('value', String(this._state.value));
151
+ }
152
+
153
+ private _roundToStep(n: number): number {
154
+ if (!Number.isFinite(n)) return n;
155
+ const decimals = this._countDecimals(this._state.step);
156
+ return decimals > 0 ? parseFloat(n.toFixed(decimals)) : n;
157
+ }
158
+
135
159
  private _countDecimals(n: number): number {
136
160
  const s = String(n);
137
161
  const dot = s.indexOf('.');
@@ -139,7 +163,7 @@ export class AdwSpinRow extends HTMLElement {
139
163
  }
140
164
 
141
165
  private _formatValue(v: number): string {
142
- const decimals = this._countDecimals(this._step);
166
+ const decimals = this._countDecimals(this._state.step);
143
167
  return decimals > 0 ? v.toFixed(decimals) : String(v);
144
168
  }
145
169
  }
@@ -2,14 +2,31 @@
2
2
  // The web counterpart of Adw.ToastOverlay: it wraps arbitrary content and shows
3
3
  // transient `.adw-toast` banners (the Adw.Toast equivalent — a title, an
4
4
  // optional action button and a close affordance) layered over the bottom of its
5
- // own bounds, each auto-dismissing after a timeout.
5
+ // own bounds.
6
+ //
7
+ // The QUEUE — Adw.ToastOverlay's one-at-a-time policy, the FIFO ordering and the
8
+ // auto-dismiss lifecycle — is HEADLESS and lives in `@gjsify/adwaita-core` (ADR
9
+ // 0004) as {@link AdwToastQueue}; this element composes it and keeps only the DOM
10
+ // render half: building the `.adw-toast` strip on `onShow`, tearing it down on
11
+ // `onHide`, and the enter/exit CSS transitions. `@gjsify/adwaita-nativescript`
12
+ // composes the same core queue, so both ports share one behaviour.
13
+ //
6
14
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
7
15
  // Reference: refs/adwaita-web/adwaita-web/scss/_toast.scss, _toast_overlay.scss
8
- // Reference: refs/libadwaita/src/adw-toast-overlay.c, adw-toast.c (Adw.Toast.add_toast)
16
+ // Reference: refs/libadwaita/src/adw-toast-overlay.c, adw-toast.c (add_toast queues,
17
+ // one toast is shown at a time, dismissing advances to the next)
9
18
  // Copyright (c) 2025 csm (adwaita-web). MIT License.
10
19
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
11
20
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
12
- // extended addToast() with action button + close button + options API.
21
+ // extended addToast() with action button + close button + options API; the
22
+ // queue/auto-dismiss state machine composed from @gjsify/adwaita-core.
23
+
24
+ import { AdwToast, AdwToastQueue } from '@gjsify/adwaita-core';
25
+ import type { ToastScheduler, ToastTimerHandle } from '@gjsify/adwaita-core';
26
+
27
+ // The timing seam is re-exported so a consumer can type a custom scheduler for
28
+ // {@link AdwToastOverlay.scheduler} without depending on the core package.
29
+ export type { ToastScheduler, ToastTimerHandle } from '@gjsify/adwaita-core';
13
30
 
14
31
  /** Options for {@link AdwToastOverlay.addToast}, mirroring Adw.Toast's properties. */
15
32
  export interface AdwToastOptions {
@@ -21,9 +38,47 @@ export interface AdwToastOptions {
21
38
  onAction?: () => void;
22
39
  }
23
40
 
41
+ /** Default toast lifetime in SECONDS — this element's public unit (Adw.Toast:timeout). */
42
+ const DEFAULT_TIMEOUT_SECONDS = 5;
43
+
44
+ /** Exit-transition budget in ms; must stay in sync with `.adw-toast.hiding` in `_toast.scss`. */
45
+ const TOAST_EXIT_MS = 300;
46
+
47
+ /** The default timing seam: the browser's own timers. */
48
+ const domScheduler: ToastScheduler = {
49
+ schedule: (callback, ms) => setTimeout(callback, ms) as unknown as ToastTimerHandle,
50
+ cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
51
+ };
52
+
24
53
  export class AdwToastOverlay extends HTMLElement {
25
54
  private _toasts!: HTMLDivElement;
26
55
  private _initialized = false;
56
+ /** The headless one-at-a-time + FIFO + auto-dismiss state machine (ADR 0004). */
57
+ private readonly _queue: AdwToastQueue;
58
+ /** The timing source the queue's auto-dismiss runs on — swappable, see {@link scheduler}. */
59
+ private _scheduler: ToastScheduler = domScheduler;
60
+ /** The strip currently mounted for {@link AdwToastQueue.current}, if any. */
61
+ private _currentEl: HTMLDivElement | null = null;
62
+ /** Per-toast action callbacks (the core toast descriptor carries only the label). */
63
+ private readonly _actions = new WeakMap<AdwToast, () => void>();
64
+
65
+ constructor() {
66
+ super();
67
+ this._queue = new AdwToastQueue({
68
+ // Indirect through `this._scheduler` so the timing source stays
69
+ // swappable after construction (the queue takes its scheduler once).
70
+ scheduler: {
71
+ schedule: (callback, ms) => this._scheduler.schedule(callback, ms),
72
+ cancel: (handle) => this._scheduler.cancel(handle),
73
+ },
74
+ onShow: (toast) => this._mountToast(toast),
75
+ // The core queue fires `onHide` BEFORE it shifts the next toast in, so
76
+ // `pending > 0` here means another toast takes this slot immediately:
77
+ // detach without the exit transition so exactly one strip is ever in
78
+ // the DOM. A last toast fades out instead.
79
+ onHide: () => this._unmountToast(this._queue.pending > 0),
80
+ });
81
+ }
27
82
 
28
83
  connectedCallback() {
29
84
  if (this._initialized) return;
@@ -41,12 +96,40 @@ export class AdwToastOverlay extends HTMLElement {
41
96
  this.replaceChildren(content, this._toasts);
42
97
  }
43
98
 
99
+ /**
100
+ * The timing source driving the queue's auto-dismiss and the exit transition.
101
+ * Defaults to the browser's `setTimeout`/`clearTimeout`; swap it for a
102
+ * deterministic fake to drive toast lifetimes without real timers (the
103
+ * {@link ToastScheduler} seam `@gjsify/adwaita-core` defines).
104
+ */
105
+ get scheduler(): ToastScheduler {
106
+ return this._scheduler;
107
+ }
108
+
109
+ set scheduler(scheduler: ToastScheduler) {
110
+ this._scheduler = scheduler;
111
+ }
112
+
113
+ /** The toast currently on screen, or `null` when nothing is shown. */
114
+ get currentToast(): AdwToast | null {
115
+ return this._queue.current;
116
+ }
117
+
118
+ /** Number of toasts queued behind the visible one. */
119
+ get pendingToasts(): number {
120
+ return this._queue.pending;
121
+ }
122
+
44
123
  /**
45
124
  * Show a toast notification — the web equivalent of Adw.ToastOverlay.add_toast()
46
125
  * with a freshly-built Adw.Toast.
47
126
  *
127
+ * Like Adw.ToastOverlay, only ONE toast is visible at a time: this one shows
128
+ * immediately when the slot is free, otherwise it waits its turn behind the
129
+ * toasts already queued.
130
+ *
48
131
  * @param title The text to display.
49
- * @param options Either a timeout in seconds (legacy shorthand) or an
132
+ * @param options Either a timeout in milliseconds (legacy shorthand) or an
50
133
  * {@link AdwToastOptions} bag (title text already supplied here).
51
134
  */
52
135
  addToast(title: string, options: number | AdwToastOptions = {}): void {
@@ -55,47 +138,53 @@ export class AdwToastOverlay extends HTMLElement {
55
138
  if (!this._toasts) this.connectedCallback();
56
139
 
57
140
  // Back-compat: the original signature was addToast(title, timeoutMs).
58
- const opts: AdwToastOptions =
59
- typeof options === 'number' ? { timeout: options / 1000 } : options;
60
- const timeoutSeconds = opts.timeout ?? 5;
141
+ const opts: AdwToastOptions = typeof options === 'number' ? { timeout: options / 1000 } : options;
142
+
143
+ // This element's public timeout unit is SECONDS (Adw.Toast:timeout); the
144
+ // headless descriptor counts milliseconds.
145
+ const toast = new AdwToast(title, {
146
+ timeout: (opts.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
147
+ buttonLabel: opts.buttonLabel,
148
+ });
149
+ if (opts.onAction) this._actions.set(toast, opts.onAction);
150
+
151
+ this._queue.add(toast);
152
+ }
153
+
154
+ /** Dismiss the visible toast early; the next queued toast takes its place. */
155
+ dismiss(): void {
156
+ this._queue.dismiss();
157
+ }
61
158
 
62
- const toast = document.createElement('div');
63
- toast.className = 'adw-toast';
159
+ /** Dismiss the visible toast and discard everything still queued behind it. */
160
+ clearToasts(): void {
161
+ this._queue.clear();
162
+ }
163
+
164
+ /** Build + attach the strip for `toast` — the DOM half of the queue's `onShow`. */
165
+ private _mountToast(toast: AdwToast): void {
166
+ const el = document.createElement('div');
167
+ el.className = 'adw-toast';
64
168
 
65
169
  const wrapper = document.createElement('div');
66
170
  wrapper.className = 'adw-toast-content-wrapper';
67
171
 
68
172
  const titleEl = document.createElement('span');
69
173
  titleEl.className = 'adw-toast-title';
70
- titleEl.textContent = title;
174
+ titleEl.textContent = toast.title;
71
175
  wrapper.appendChild(titleEl);
72
- toast.appendChild(wrapper);
73
-
74
- let dismissTimer = 0;
75
- const dismiss = (): void => {
76
- if (dismissTimer) {
77
- clearTimeout(dismissTimer);
78
- dismissTimer = 0;
79
- }
80
- toast.classList.remove('visible');
81
- toast.classList.add('hiding');
82
- toast.addEventListener('transitionend', () => toast.remove(), { once: true });
83
- // Fallback if transitionend doesn't fire (e.g. reduced motion).
84
- setTimeout(() => {
85
- if (toast.parentNode) toast.remove();
86
- }, 300);
87
- };
88
-
89
- if (opts.buttonLabel) {
176
+ el.appendChild(wrapper);
177
+
178
+ if (toast.hasButton) {
90
179
  const button = document.createElement('button');
91
180
  button.type = 'button';
92
181
  button.className = 'adw-toast-action-button';
93
- button.textContent = opts.buttonLabel;
182
+ button.textContent = toast.buttonLabel;
94
183
  button.addEventListener('click', () => {
95
- opts.onAction?.();
96
- dismiss();
184
+ this._actions.get(toast)?.();
185
+ this._queue.dismiss();
97
186
  });
98
- toast.appendChild(button);
187
+ el.appendChild(button);
99
188
  }
100
189
 
101
190
  // Close affordance — a CSS-drawn "×" (no symbolic close icon ships in the
@@ -104,20 +193,43 @@ export class AdwToastOverlay extends HTMLElement {
104
193
  close.type = 'button';
105
194
  close.className = 'adw-toast-close-button';
106
195
  close.setAttribute('aria-label', 'Close');
107
- close.addEventListener('click', dismiss);
108
- toast.appendChild(close);
196
+ close.addEventListener('click', () => this._queue.dismiss());
197
+ el.appendChild(close);
109
198
 
110
- this._toasts.appendChild(toast);
199
+ this._toasts.appendChild(el);
200
+ this._currentEl = el;
111
201
 
112
202
  // Trigger the enter animation on the next frame.
113
203
  requestAnimationFrame(() => {
114
- toast.classList.add('visible');
204
+ el.classList.add('visible');
115
205
  });
206
+ }
116
207
 
117
- // Auto-dismiss after the timeout (0 = stay until manually dismissed).
118
- if (timeoutSeconds > 0) {
119
- dismissTimer = setTimeout(dismiss, timeoutSeconds * 1000) as unknown as number;
208
+ /**
209
+ * Detach the mounted strip — the DOM half of the queue's `onHide`.
210
+ *
211
+ * @param immediate Skip the exit transition because the next queued toast is
212
+ * about to take this slot (keeps exactly one strip mounted).
213
+ */
214
+ private _unmountToast(immediate: boolean): void {
215
+ const el = this._currentEl;
216
+ this._currentEl = null;
217
+ if (!el) return;
218
+
219
+ if (immediate) {
220
+ el.remove();
221
+ return;
120
222
  }
223
+
224
+ el.classList.remove('visible');
225
+ el.classList.add('hiding');
226
+ el.addEventListener('transitionend', () => el.remove(), { once: true });
227
+ // Fallback if transitionend doesn't fire (e.g. reduced motion). Routed
228
+ // through the injected scheduler so the whole lifecycle is deterministic
229
+ // when a fake clock is installed.
230
+ this._scheduler.schedule(() => {
231
+ if (el.parentNode) el.remove();
232
+ }, TOAST_EXIT_MS);
121
233
  }
122
234
  }
123
235
 
@@ -8,11 +8,22 @@
8
8
  // style classes).
9
9
  // Events: `notify::active` (CustomEvent, bubbles, `detail = { active }`) when the
10
10
  // active toggle changes — mirrors the Adw.ToggleGroup `active` GObject property.
11
+ //
12
+ // The SELECTION state machine (the segment list plus the guarded, no-op-on-same
13
+ // active index) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
14
+ // {@link ToggleGroupState}; this element composes it and keeps only the DOM render
15
+ // half — building the buttons, the active pill / `aria-pressed` reflection, and
16
+ // the `notify::active` event. `@gjsify/adwaita-nativescript` composes the same
17
+ // state machine, so both ports share one behaviour.
18
+ //
11
19
  // Reference: refs/libadwaita/src/adw-toggle-group.c (AdwToggleGroup behaviour)
12
20
  // Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss
13
21
  // Reference: refs/adwaita-web/adwaita-web/scss/_toggle_group.scss
14
22
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
15
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
23
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
24
+ // selection state machine composed from @gjsify/adwaita-core.
25
+
26
+ import { ToggleGroupState } from '@gjsify/adwaita-core';
16
27
 
17
28
  /** A single toggle. Children of <adw-toggle-group>; consumed at connect time. */
18
29
  export class AdwToggle extends HTMLElement {
@@ -24,7 +35,8 @@ export class AdwToggle extends HTMLElement {
24
35
  export class AdwToggleGroup extends HTMLElement {
25
36
  private _innerEl!: HTMLDivElement;
26
37
  private _buttons: HTMLButtonElement[] = [];
27
- private _active = 0;
38
+ /** The headless segment list + guarded selection state machine (ADR 0004). */
39
+ private readonly _state = new ToggleGroupState();
28
40
  private _initialized = false;
29
41
 
30
42
  static get observedAttributes() {
@@ -33,7 +45,7 @@ export class AdwToggleGroup extends HTMLElement {
33
45
 
34
46
  /** Zero-based index of the active toggle. */
35
47
  get active(): number {
36
- return this._active;
48
+ return this._state.selected;
37
49
  }
38
50
 
39
51
  set active(value: number) {
@@ -76,18 +88,21 @@ export class AdwToggleGroup extends HTMLElement {
76
88
  this._innerEl.append(...this._buttons);
77
89
  this.replaceChildren(this._innerEl);
78
90
 
79
- this._active = this._readActiveAttr();
91
+ // Hand the segments to the headless state machine (it needs the count to
92
+ // bound the selection), then seed the active index from the attribute.
93
+ this._state.setLabels(
94
+ toggles.map((toggle) => toggle.getAttribute('label') || toggle.getAttribute('icon-name') || ''),
95
+ );
96
+ this._state.subscribe(() => this._render());
97
+ this._state.setSelected(this._readActiveAttr());
80
98
  this._render();
81
99
  }
82
100
 
83
101
  attributeChangedCallback(name: string) {
84
102
  if (!this._initialized) return;
85
103
  if (name === 'active') {
86
- const next = this._readActiveAttr();
87
- if (next !== this._active) {
88
- this._active = next;
89
- this._render();
90
- }
104
+ // A no-op / out-of-range index is rejected by the core state machine.
105
+ this._state.setSelected(this._readActiveAttr());
91
106
  return;
92
107
  }
93
108
  // flat / round are styling-only.
@@ -97,25 +112,26 @@ export class AdwToggleGroup extends HTMLElement {
97
112
  private _readActiveAttr(): number {
98
113
  const raw = Number.parseInt(this.getAttribute('active') ?? '0', 10);
99
114
  if (Number.isNaN(raw)) return 0;
100
- const max = Math.max(0, this._buttons.length - 1);
115
+ const max = Math.max(0, this._state.count - 1);
101
116
  return Math.min(Math.max(raw, 0), max);
102
117
  }
103
118
 
104
119
  private _selectIndex(index: number): void {
105
- if (index === this._active) return;
106
- this._active = index;
120
+ // The core state machine guards the no-op/out-of-range cases and notifies
121
+ // the subscriber (which re-renders) only on a real change.
122
+ if (!this._state.setSelected(index)) return;
107
123
  // Keep the attribute in sync without re-entering via the guarded
108
124
  // attributeChangedCallback (value already matches, so it no-ops).
109
125
  this.setAttribute('active', String(index));
110
- this._render();
111
126
  this.dispatchEvent(new CustomEvent('notify::active', { bubbles: true, detail: { active: index } }));
112
127
  }
113
128
 
114
129
  private _render(): void {
115
130
  this.classList.toggle('flat', this.hasAttribute('flat'));
116
131
  this.classList.toggle('round', this.hasAttribute('round'));
132
+ const active = this._state.selected;
117
133
  this._buttons.forEach((btn, index) => {
118
- const isActive = index === this._active;
134
+ const isActive = index === active;
119
135
  btn.classList.toggle('active', isActive);
120
136
  btn.setAttribute('aria-pressed', String(isActive));
121
137
  });
@@ -11,7 +11,9 @@ import { AdwButtonRowTest } from './adw-button-row.spec.js';
11
11
  import { AdwDataGridTest } from './adw-data-grid.spec.js';
12
12
  import { AdwDialogTest } from './adw-dialog.spec.js';
13
13
  import { AdwDropDownTest } from './adw-drop-down.spec.js';
14
+ import { AdwRowStateTest } from './adw-row-state.spec.js';
14
15
  import { AdwTabViewTest } from './adw-tab-view.spec.js';
16
+ import { AdwToastOverlayTest } from './adw-toast-overlay.spec.js';
15
17
  import { AdwViewSwitcherBarTest } from './adw-view-switcher-bar.spec.js';
16
18
  import { AdwStyleIsolationTest } from './style-isolation.spec.js';
17
19
 
@@ -20,7 +22,9 @@ run({
20
22
  AdwDataGridTest,
21
23
  AdwDialogTest,
22
24
  AdwDropDownTest,
25
+ AdwRowStateTest,
23
26
  AdwTabViewTest,
27
+ AdwToastOverlayTest,
24
28
  AdwViewSwitcherBarTest,
25
29
  AdwStyleIsolationTest,
26
30
  });