@gjsify/adwaita-web 0.21.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.
- package/README.md +48 -0
- package/dist/adwaita-web.css +364 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-row-state.spec.d.ts +1 -0
- package/lib/types/adw-toast-overlay.spec.d.ts +1 -0
- package/lib/types/adw-view-switcher-bar.spec.d.ts +1 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +6 -5
- package/lib/types/elements/adw-combo-row.d.ts +2 -1
- package/lib/types/elements/adw-expander-row.d.ts +4 -1
- package/lib/types/elements/adw-spin-row.d.ts +6 -4
- package/lib/types/elements/adw-toast-overlay.d.ts +42 -1
- package/lib/types/elements/adw-toggle-group.d.ts +2 -1
- package/lib/types/style-isolation.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -5
- package/scss/_reset.scss +69 -0
- package/scss/_toast.scss +5 -2
- package/scss/_view_switcher_bar.scss +10 -0
- package/scss/adwaita-skin.scss +3 -0
- package/src/adw-row-state.spec.ts +217 -0
- package/src/adw-toast-overlay.spec.ts +172 -0
- package/src/adw-view-switcher-bar.spec.ts +31 -0
- package/src/elements/adw-alert-dialog.ts +88 -57
- package/src/elements/adw-combo-row.ts +42 -15
- package/src/elements/adw-expander-row.ts +46 -10
- package/src/elements/adw-spin-row.ts +56 -32
- package/src/elements/adw-toast-overlay.ts +151 -39
- package/src/elements/adw-toggle-group.ts +30 -14
- package/src/style-isolation.spec.ts +36 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +15 -1
|
@@ -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
|
-
|
|
14
|
-
private
|
|
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.
|
|
35
|
+
return this._state.value;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
set value(v: number) {
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
this.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
114
|
+
this._state.setMin(num);
|
|
104
115
|
break;
|
|
105
116
|
case 'max':
|
|
106
|
-
this.
|
|
117
|
+
this._state.setMax(num);
|
|
107
118
|
break;
|
|
108
119
|
case 'step':
|
|
109
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
182
|
+
button.textContent = toast.buttonLabel;
|
|
94
183
|
button.addEventListener('click', () => {
|
|
95
|
-
|
|
96
|
-
dismiss();
|
|
184
|
+
this._actions.get(toast)?.();
|
|
185
|
+
this._queue.dismiss();
|
|
97
186
|
});
|
|
98
|
-
|
|
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
|
-
|
|
196
|
+
close.addEventListener('click', () => this._queue.dismiss());
|
|
197
|
+
el.appendChild(close);
|
|
109
198
|
|
|
110
|
-
this._toasts.appendChild(
|
|
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
|
-
|
|
204
|
+
el.classList.add('visible');
|
|
115
205
|
});
|
|
206
|
+
}
|
|
116
207
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
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 ===
|
|
134
|
+
const isActive = index === active;
|
|
119
135
|
btn.classList.toggle('active', isActive);
|
|
120
136
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
121
137
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Style-isolation regression test (browser axis). Guards the boundary reset
|
|
2
|
+
// (scss/_reset.scss, ADR 0010): a host page's inherited typography must not leak
|
|
3
|
+
// into an adwaita-web widget. It also guards that the widget's tag is listed in
|
|
4
|
+
// the reset's `$adw-components` — if it weren't, the reset wouldn't apply and
|
|
5
|
+
// these assertions would fail.
|
|
6
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
7
|
+
|
|
8
|
+
export const AdwStyleIsolationTest = async () => {
|
|
9
|
+
await describe('style isolation (boundary reset)', async () => {
|
|
10
|
+
await it('re-roots typography so a host font does not inherit into a widget', async () => {
|
|
11
|
+
// A container styled like a hostile host theme.
|
|
12
|
+
const host = document.createElement('div');
|
|
13
|
+
host.style.fontFamily = 'Georgia, "Times New Roman", serif';
|
|
14
|
+
host.style.color = 'rgb(255, 0, 255)';
|
|
15
|
+
host.style.lineHeight = '2.4';
|
|
16
|
+
host.style.letterSpacing = '3px';
|
|
17
|
+
host.style.textTransform = 'uppercase';
|
|
18
|
+
document.body.appendChild(host);
|
|
19
|
+
|
|
20
|
+
const row = document.createElement('adw-switch-row');
|
|
21
|
+
row.setAttribute('title', 'Wi-Fi');
|
|
22
|
+
host.appendChild(row);
|
|
23
|
+
|
|
24
|
+
const cs = getComputedStyle(row);
|
|
25
|
+
// The boundary reset re-roots the inherited typography to Adwaita's,
|
|
26
|
+
// so the host's serif / magenta / spacing / casing do not leak in.
|
|
27
|
+
expect(cs.fontFamily.includes('Adwaita Sans')).toBe(true);
|
|
28
|
+
expect(cs.fontFamily.includes('Georgia')).toBe(false);
|
|
29
|
+
expect(cs.letterSpacing).toBe('normal');
|
|
30
|
+
expect(cs.textTransform).toBe('none');
|
|
31
|
+
expect(cs.color === 'rgb(255, 0, 255)').toBe(false);
|
|
32
|
+
|
|
33
|
+
host.remove();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
};
|