@gjsify/adwaita-web 0.10.0 → 0.12.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 +5 -5
- package/dist/adwaita-web.css +2742 -263
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-about-dialog.d.ts +77 -0
- package/lib/types/elements/adw-action-row.d.ts +15 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +80 -0
- package/lib/types/elements/adw-avatar.d.ts +9 -0
- package/lib/types/elements/adw-banner.d.ts +9 -0
- package/lib/types/elements/adw-bottom-sheet.d.ts +35 -0
- package/lib/types/elements/adw-button-content.d.ts +10 -0
- package/lib/types/elements/adw-button-row.d.ts +11 -0
- package/lib/types/elements/adw-button.d.ts +11 -0
- package/lib/types/elements/adw-carousel.d.ts +64 -0
- package/lib/types/elements/adw-clamp.d.ts +6 -0
- package/lib/types/elements/adw-combo-row.d.ts +3 -0
- package/lib/types/elements/adw-entry-row.d.ts +10 -0
- package/lib/types/elements/adw-entry.d.ts +11 -0
- package/lib/types/elements/adw-expander-row.d.ts +26 -0
- package/lib/types/elements/adw-header-bar.d.ts +4 -0
- package/lib/types/elements/adw-inline-view-switcher.d.ts +30 -0
- package/lib/types/elements/adw-navigation-split-view.d.ts +15 -0
- package/lib/types/elements/adw-navigation-view.d.ts +66 -0
- package/lib/types/elements/adw-password-entry-row.d.ts +16 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +36 -0
- package/lib/types/elements/adw-preferences-group.d.ts +8 -0
- package/lib/types/elements/adw-sidebar.d.ts +29 -0
- package/lib/types/elements/adw-spin-row.d.ts +3 -0
- package/lib/types/elements/adw-spinner.d.ts +6 -0
- package/lib/types/elements/adw-split-button.d.ts +18 -0
- package/lib/types/elements/adw-status-page.d.ts +11 -0
- package/lib/types/elements/adw-switch-row.d.ts +3 -0
- package/lib/types/elements/adw-tab-view.d.ts +24 -0
- package/lib/types/elements/adw-toast-overlay.d.ts +19 -4
- package/lib/types/elements/adw-toggle-group.d.ts +19 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +13 -0
- package/lib/types/elements/adw-view-switcher.d.ts +29 -0
- package/lib/types/elements/adw-window-title.d.ts +9 -0
- package/lib/types/elements/adw-wrap-box.d.ts +13 -0
- package/lib/types/index.d.ts +29 -0
- package/lib/types/styles.generated.d.ts +1 -0
- package/package.json +5 -5
- package/scss/_about_dialog.scss +257 -0
- package/scss/_action_row.scss +65 -0
- package/scss/_alert_dialog.scss +151 -0
- package/scss/_avatar.scss +31 -0
- package/scss/_banner.scss +46 -0
- package/scss/_bottom_sheet.scss +113 -0
- package/scss/_button.scss +168 -0
- package/scss/_button_content.scss +48 -0
- package/scss/_button_row.scss +100 -0
- package/scss/_carousel.scss +130 -0
- package/scss/_clamp.scss +11 -0
- package/scss/_entry.scss +49 -0
- package/scss/_entry_row.scss +54 -0
- package/scss/_expander_row.scss +150 -0
- package/scss/_headerbar.scss +1 -1
- package/scss/_inline_view_switcher.scss +174 -0
- package/scss/_navigation_split_view.scss +48 -0
- package/scss/_navigation_view.scss +80 -0
- package/scss/_overlay_split_view.scss +19 -0
- package/scss/_password_entry_row.scss +67 -0
- package/scss/_preferences.scss +26 -3
- package/scss/_preferences_dialog.scss +154 -0
- package/scss/_row.scss +31 -0
- package/scss/_sidebar.scss +213 -0
- package/scss/_spin_row.scss +0 -1
- package/scss/_spinner.scss +28 -0
- package/scss/_split_button.scss +222 -0
- package/scss/_status_page.scss +40 -0
- package/scss/_switch_row.scss +0 -1
- package/scss/_tab_view.scss +193 -0
- package/scss/_theme.scss +12 -0
- package/scss/_toast.scss +122 -12
- package/scss/_toggle_group.scss +124 -0
- package/scss/_toolbar_view.scss +31 -0
- package/scss/_variables.scss +19 -0
- package/scss/_view_switcher.scss +133 -0
- package/scss/_window_title.scss +33 -0
- package/scss/_wrap_box.scss +20 -0
- package/scss/adwaita-skin.scss +38 -0
- package/src/elements/adw-about-dialog.ts +629 -0
- package/src/elements/adw-action-row.ts +82 -0
- package/src/elements/adw-alert-dialog.ts +385 -0
- package/src/elements/adw-avatar.ts +108 -0
- package/src/elements/adw-banner.ts +57 -0
- package/src/elements/adw-bottom-sheet.ts +205 -0
- package/src/elements/adw-button-content.ts +69 -0
- package/src/elements/adw-button-row.ts +75 -0
- package/src/elements/adw-button.ts +81 -0
- package/src/elements/adw-carousel.ts +321 -0
- package/src/elements/adw-clamp.ts +28 -0
- package/src/elements/adw-combo-row.ts +32 -20
- package/src/elements/adw-entry-row.ts +69 -0
- package/src/elements/adw-entry.ts +61 -0
- package/src/elements/adw-expander-row.ts +167 -0
- package/src/elements/adw-header-bar.ts +21 -8
- package/src/elements/adw-inline-view-switcher.ts +222 -0
- package/src/elements/adw-navigation-split-view.ts +89 -0
- package/src/elements/adw-navigation-view.ts +344 -0
- package/src/elements/adw-password-entry-row.ts +121 -0
- package/src/elements/adw-preferences-dialog.ts +240 -0
- package/src/elements/adw-preferences-group.ts +79 -12
- package/src/elements/adw-sidebar.ts +238 -0
- package/src/elements/adw-spin-row.ts +28 -14
- package/src/elements/adw-spinner.ts +31 -0
- package/src/elements/adw-split-button.ts +206 -0
- package/src/elements/adw-status-page.ts +66 -0
- package/src/elements/adw-switch-row.ts +29 -23
- package/src/elements/adw-tab-view.ts +184 -0
- package/src/elements/adw-toast-overlay.ts +98 -19
- package/src/elements/adw-toggle-group.ts +126 -0
- package/src/elements/adw-toolbar-view.ts +60 -0
- package/src/elements/adw-view-switcher.ts +200 -0
- package/src/elements/adw-window-title.ts +48 -0
- package/src/elements/adw-wrap-box.ts +139 -0
- package/src/index.js +1 -1
- package/src/index.ts +46 -3
- package/src/styles.generated.js +2976 -0
- package/src/styles2.generated.js +2976 -0
- package/style.css.d.ts +17 -4
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// <adw-preferences-dialog> — A modal preferences dialog (the web counterpart of
|
|
2
|
+
// Adw.PreferencesDialog). It is a centred floating dialog over a full-cover
|
|
3
|
+
// scrim, hosting one or more <adw-preferences-page> children — each a scrollable
|
|
4
|
+
// clamp of <adw-preferences-group> boxed lists — under a flat header bar that
|
|
5
|
+
// shows the dialog title and a trailing close button.
|
|
6
|
+
//
|
|
7
|
+
// Pages are declared as <adw-preferences-page> children (each with optional
|
|
8
|
+
// `title` / `icon-name` attributes + arbitrary group content); the dialog
|
|
9
|
+
// snapshots them at connect time and stacks their content in the scrolled body.
|
|
10
|
+
// (Adw shows a view-switcher when there is more than one page and the window is
|
|
11
|
+
// wide; the web port renders the pages stacked, matching the single-page common
|
|
12
|
+
// case the storybook exercises.)
|
|
13
|
+
//
|
|
14
|
+
// Like Adw.Dialog the dialog starts hidden; `present()` / setting the `open`
|
|
15
|
+
// attribute reveals it. It is dismissed by Escape, by clicking the scrim, or by
|
|
16
|
+
// the header close button — each routed through the same close path. When
|
|
17
|
+
// `can-close` is false a dismissal raises `close-attempt` instead of closing,
|
|
18
|
+
// mirroring Adw.Dialog:can-close / ::close-attempt. The content width follows
|
|
19
|
+
// the `content-width` attribute (Adw.Dialog:content-width, default 640 — the
|
|
20
|
+
// AdwPreferencesDialog template default).
|
|
21
|
+
//
|
|
22
|
+
// Attributes:
|
|
23
|
+
// title (the dialog title shown in the header — Adw.Dialog:title)
|
|
24
|
+
// open (boolean — whether the dialog is revealed; default hidden)
|
|
25
|
+
// can-close (boolean — user can dismiss via scrim / Escape / close button;
|
|
26
|
+
// default on. When off a dismissal raises `close-attempt`
|
|
27
|
+
// instead — mirrors Adw.Dialog:can-close)
|
|
28
|
+
// content-width (the dialog's preferred width in px; default 640)
|
|
29
|
+
//
|
|
30
|
+
// Events:
|
|
31
|
+
// `notify::open` (CustomEvent, bubbles, `detail = { open }`) when the revealed
|
|
32
|
+
// state changes — mirrors the Adw.Dialog `open`-ness (presented/closed).
|
|
33
|
+
// `closed` (CustomEvent, bubbles) when the dialog finishes closing — mirrors
|
|
34
|
+
// the Adw.Dialog `closed` signal.
|
|
35
|
+
// `close-attempt` (CustomEvent, bubbles) when a dismissal is attempted while
|
|
36
|
+
// `can-close` is false — mirrors the Adw.Dialog `close-attempt` signal.
|
|
37
|
+
//
|
|
38
|
+
// Reference: refs/libadwaita/src/adw-preferences-dialog.c (AdwPreferencesDialog)
|
|
39
|
+
// Reference: refs/libadwaita/src/adw-preferences-dialog.ui (toolbar/title layout)
|
|
40
|
+
// Reference: refs/libadwaita/src/adw-dialog.c (present/close/can-close, Escape)
|
|
41
|
+
// Reference: refs/libadwaita/src/stylesheet/widgets/_dialogs.scss (floating sheet)
|
|
42
|
+
// Copyright (c) 2023-2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
|
|
43
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CONTENT_WIDTH = 640;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single preferences page. Child of <adw-preferences-dialog>; consumed at
|
|
49
|
+
* connect time. Carries optional `title` / `icon-name` attributes and any
|
|
50
|
+
* <adw-preferences-group> content — mirrors Adw.PreferencesPage.
|
|
51
|
+
*/
|
|
52
|
+
export class AdwPreferencesPage extends HTMLElement {
|
|
53
|
+
static get observedAttributes() {
|
|
54
|
+
return ['title', 'icon-name', 'open'];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class AdwPreferencesDialog extends HTMLElement {
|
|
59
|
+
private _initialized = false;
|
|
60
|
+
private _scrimEl!: HTMLDivElement;
|
|
61
|
+
private _dialogEl!: HTMLDivElement;
|
|
62
|
+
private _titleEl!: HTMLSpanElement;
|
|
63
|
+
private _bodyEl!: HTMLDivElement;
|
|
64
|
+
private _closeBtn!: HTMLButtonElement;
|
|
65
|
+
|
|
66
|
+
static get observedAttributes() {
|
|
67
|
+
return ['title', 'open', 'can-close', 'content-width'];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether the dialog is revealed. */
|
|
71
|
+
get open(): boolean {
|
|
72
|
+
return this.hasAttribute('open');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
set open(value: boolean) {
|
|
76
|
+
if (value) this.setAttribute('open', '');
|
|
77
|
+
else this.removeAttribute('open');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether the user can dismiss the dialog (scrim / Escape / close button). */
|
|
81
|
+
get canClose(): boolean {
|
|
82
|
+
const value = this.getAttribute('can-close');
|
|
83
|
+
// Absent → default on; explicit `="false"` → off.
|
|
84
|
+
if (value === null) return true;
|
|
85
|
+
return value !== 'false';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
set canClose(value: boolean) {
|
|
89
|
+
if (value) this.setAttribute('can-close', '');
|
|
90
|
+
else this.setAttribute('can-close', 'false');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The dialog's preferred content width in px (Adw.Dialog:content-width). */
|
|
94
|
+
get contentWidth(): number {
|
|
95
|
+
const raw = Number.parseInt(this.getAttribute('content-width') ?? '', 10);
|
|
96
|
+
return Number.isNaN(raw) ? DEFAULT_CONTENT_WIDTH : raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
set contentWidth(value: number) {
|
|
100
|
+
this.setAttribute('content-width', String(value));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Reveal the dialog (mirrors Adw.Dialog.present). */
|
|
104
|
+
present(): void {
|
|
105
|
+
this.open = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Dismiss the dialog, respecting `can-close` (mirrors Adw.Dialog.close). */
|
|
109
|
+
close(): void {
|
|
110
|
+
this._attemptClose();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Dismiss the dialog regardless of `can-close` (mirrors Adw.Dialog.force_close). */
|
|
114
|
+
forceClose(): void {
|
|
115
|
+
if (!this.open) return;
|
|
116
|
+
this.open = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
connectedCallback() {
|
|
120
|
+
if (this._initialized) return;
|
|
121
|
+
this._initialized = true;
|
|
122
|
+
|
|
123
|
+
// Snapshot the declared pages before taking over the subtree. Each page's
|
|
124
|
+
// content (its groups) is stacked in the scrolled body; any unwrapped
|
|
125
|
+
// children fall back to the body too (Adw.PreferencesDialog's buildable
|
|
126
|
+
// default adds a child as a page).
|
|
127
|
+
const pages = Array.from(this.querySelectorAll(':scope > adw-preferences-page')) as AdwPreferencesPage[];
|
|
128
|
+
const pageContent = pages.flatMap((page) => Array.from(page.childNodes));
|
|
129
|
+
const claimed = new Set<Node>([...pages, ...pageContent]);
|
|
130
|
+
const unwrapped = Array.from(this.childNodes).filter((node) => !claimed.has(node));
|
|
131
|
+
|
|
132
|
+
// Full-cover scrim — clicking it dismisses the dialog (or raises
|
|
133
|
+
// close-attempt when locked).
|
|
134
|
+
this._scrimEl = document.createElement('div');
|
|
135
|
+
this._scrimEl.className = 'adw-preferences-dialog-scrim';
|
|
136
|
+
this._scrimEl.addEventListener('click', () => this._attemptClose());
|
|
137
|
+
|
|
138
|
+
// The centred dialog box: a flat header bar (title + close button) above
|
|
139
|
+
// a scrolled preferences body.
|
|
140
|
+
this._dialogEl = document.createElement('div');
|
|
141
|
+
this._dialogEl.className = 'adw-preferences-dialog-box';
|
|
142
|
+
this._dialogEl.setAttribute('role', 'dialog');
|
|
143
|
+
this._dialogEl.setAttribute('aria-modal', 'true');
|
|
144
|
+
// Clicks inside the dialog must not bubble to the scrim's dismiss handler.
|
|
145
|
+
this._dialogEl.addEventListener('click', (event) => event.stopPropagation());
|
|
146
|
+
|
|
147
|
+
const header = document.createElement('div');
|
|
148
|
+
header.className = 'adw-preferences-dialog-header';
|
|
149
|
+
|
|
150
|
+
// A leading spacer keeps the title visually centred against the trailing
|
|
151
|
+
// close button (the AdwHeaderBar `centering-policy: strict` behaviour).
|
|
152
|
+
const leading = document.createElement('div');
|
|
153
|
+
leading.className = 'adw-preferences-dialog-header-side';
|
|
154
|
+
|
|
155
|
+
this._titleEl = document.createElement('span');
|
|
156
|
+
this._titleEl.className = 'adw-preferences-dialog-title';
|
|
157
|
+
|
|
158
|
+
const trailing = document.createElement('div');
|
|
159
|
+
trailing.className = 'adw-preferences-dialog-header-side adw-preferences-dialog-header-side--end';
|
|
160
|
+
|
|
161
|
+
this._closeBtn = document.createElement('button');
|
|
162
|
+
this._closeBtn.type = 'button';
|
|
163
|
+
this._closeBtn.className = 'adw-preferences-dialog-close';
|
|
164
|
+
this._closeBtn.setAttribute('aria-label', 'Close');
|
|
165
|
+
// No symbolic close icon ships in @gjsify/adwaita-icons; draw the "×"
|
|
166
|
+
// glyph in CSS (same approach as <adw-tab-view>'s close affordance).
|
|
167
|
+
this._closeBtn.addEventListener('click', () => this._attemptClose());
|
|
168
|
+
trailing.appendChild(this._closeBtn);
|
|
169
|
+
|
|
170
|
+
header.append(leading, this._titleEl, trailing);
|
|
171
|
+
|
|
172
|
+
// Scrolled body — a clamped column of the declared pages' content.
|
|
173
|
+
this._bodyEl = document.createElement('div');
|
|
174
|
+
this._bodyEl.className = 'adw-preferences-dialog-body';
|
|
175
|
+
|
|
176
|
+
const clamp = document.createElement('div');
|
|
177
|
+
clamp.className = 'adw-preferences-dialog-pages';
|
|
178
|
+
for (const child of pageContent) clamp.appendChild(child);
|
|
179
|
+
for (const child of unwrapped) clamp.appendChild(child);
|
|
180
|
+
this._bodyEl.appendChild(clamp);
|
|
181
|
+
|
|
182
|
+
this._dialogEl.append(header, this._bodyEl);
|
|
183
|
+
|
|
184
|
+
this.replaceChildren(this._scrimEl, this._dialogEl);
|
|
185
|
+
|
|
186
|
+
// Escape closes the dialog while open (mirrors Adw.Dialog's Escape
|
|
187
|
+
// shortcut → maybe_close_cb). The element is focusable so it can receive
|
|
188
|
+
// the key event when the dialog has focus.
|
|
189
|
+
if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
|
|
190
|
+
this.addEventListener('keydown', (event) => {
|
|
191
|
+
if (event.key === 'Escape' && this.open) {
|
|
192
|
+
event.stopPropagation();
|
|
193
|
+
this._attemptClose();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
this._render();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
|
201
|
+
if (!this._initialized) return;
|
|
202
|
+
if (oldValue === newValue) return;
|
|
203
|
+
this._render();
|
|
204
|
+
if (name === 'open') {
|
|
205
|
+
this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
|
|
206
|
+
if (!this.open) this.dispatchEvent(new CustomEvent('closed', { bubbles: true }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private _attemptClose(): void {
|
|
211
|
+
if (!this.open) return;
|
|
212
|
+
if (!this.canClose) {
|
|
213
|
+
this.dispatchEvent(new CustomEvent('close-attempt', { bubbles: true }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Drives attributeChangedCallback → notify::open + closed.
|
|
217
|
+
this.open = false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private _render(): void {
|
|
221
|
+
this.classList.toggle('open', this.open);
|
|
222
|
+
|
|
223
|
+
const title = this.getAttribute('title') ?? '';
|
|
224
|
+
this._titleEl.textContent = title;
|
|
225
|
+
|
|
226
|
+
this._dialogEl.style.setProperty('--adw-dialog-content-width', `${this.contentWidth}px`);
|
|
227
|
+
|
|
228
|
+
// While locked the close button has no effect; reflect that visually +
|
|
229
|
+
// for assistive tech.
|
|
230
|
+
const locked = !this.canClose;
|
|
231
|
+
this._closeBtn.disabled = locked;
|
|
232
|
+
this._closeBtn.classList.toggle('locked', locked);
|
|
233
|
+
|
|
234
|
+
// Move focus into the dialog on present so Escape works immediately.
|
|
235
|
+
if (this.open) this.focus();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
customElements.define('adw-preferences-page', AdwPreferencesPage);
|
|
240
|
+
customElements.define('adw-preferences-dialog', AdwPreferencesDialog);
|
|
@@ -1,33 +1,100 @@
|
|
|
1
|
-
// <adw-preferences-group> — Groups child rows in a boxed list with a title
|
|
1
|
+
// <adw-preferences-group> — Groups child rows in a boxed list with a title,
|
|
2
|
+
// an optional dimmed description line and an optional trailing header-suffix
|
|
3
|
+
// widget (e.g. a flat "Sign out" button at the trailing edge of the header).
|
|
4
|
+
// Attributes: title, description.
|
|
5
|
+
// Property: description (get/set, proxies the attribute).
|
|
6
|
+
// Header-suffix: any child carrying `slot="header-suffix"` is hoisted into the
|
|
7
|
+
// trailing edge of the header instead of the boxed list — mirroring
|
|
8
|
+
// Adw.PreferencesGroup's `header-suffix` property / buildable type.
|
|
9
|
+
// Reference: refs/libadwaita/src/adw-preferences-group.ui (header_box layout)
|
|
10
|
+
// Reference: refs/adwaita-web/adwaita-web/scss/_preferences.scss
|
|
2
11
|
// Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
|
|
3
12
|
// Copyright (c) 2025 csm. MIT License.
|
|
4
|
-
//
|
|
13
|
+
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
14
|
+
// Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
|
|
15
|
+
// description line + header-suffix slot added to match Adw.PreferencesGroup.
|
|
5
16
|
|
|
6
17
|
export class AdwPreferencesGroup extends HTMLElement {
|
|
18
|
+
private _titleEl!: HTMLSpanElement;
|
|
19
|
+
private _descriptionEl!: HTMLSpanElement;
|
|
20
|
+
private _suffixEl!: HTMLDivElement;
|
|
7
21
|
private _initialized = false;
|
|
8
22
|
|
|
23
|
+
static get observedAttributes() {
|
|
24
|
+
return ['title', 'description'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get description(): string {
|
|
28
|
+
return this.getAttribute('description') ?? '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set description(value: string) {
|
|
32
|
+
if (value) this.setAttribute('description', value);
|
|
33
|
+
else this.removeAttribute('description');
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
connectedCallback() {
|
|
10
37
|
if (this._initialized) return;
|
|
11
38
|
this._initialized = true;
|
|
12
39
|
|
|
13
|
-
const title = this.getAttribute('title') || '';
|
|
14
40
|
const children = Array.from(this.childNodes);
|
|
41
|
+
// Children opting into the header-suffix slot are hoisted to the
|
|
42
|
+
// trailing edge of the header; everything else goes into the boxed list.
|
|
43
|
+
const suffixChildren = children.filter(
|
|
44
|
+
(child) => child instanceof Element && child.getAttribute('slot') === 'header-suffix',
|
|
45
|
+
);
|
|
46
|
+
const rowChildren = children.filter((child) => !suffixChildren.includes(child));
|
|
15
47
|
|
|
16
|
-
//
|
|
48
|
+
// Header: a flex row with a title/description label column on the left
|
|
49
|
+
// and the optional header-suffix on the trailing edge.
|
|
17
50
|
const header = document.createElement('div');
|
|
18
51
|
header.className = 'adw-preferences-group-header';
|
|
19
|
-
const titleEl = document.createElement('span');
|
|
20
|
-
titleEl.className = 'adw-preferences-group-title';
|
|
21
|
-
titleEl.textContent = title;
|
|
22
|
-
header.appendChild(titleEl);
|
|
23
52
|
|
|
24
|
-
|
|
53
|
+
const labels = document.createElement('div');
|
|
54
|
+
labels.className = 'adw-preferences-group-labels';
|
|
55
|
+
|
|
56
|
+
this._titleEl = document.createElement('span');
|
|
57
|
+
this._titleEl.className = 'adw-preferences-group-title';
|
|
58
|
+
|
|
59
|
+
this._descriptionEl = document.createElement('span');
|
|
60
|
+
this._descriptionEl.className = 'adw-preferences-group-description';
|
|
61
|
+
|
|
62
|
+
labels.append(this._titleEl, this._descriptionEl);
|
|
63
|
+
|
|
64
|
+
this._suffixEl = document.createElement('div');
|
|
65
|
+
this._suffixEl.className = 'adw-preferences-group-suffix';
|
|
66
|
+
suffixChildren.forEach((child) => this._suffixEl.appendChild(child));
|
|
67
|
+
|
|
68
|
+
header.append(labels, this._suffixEl);
|
|
69
|
+
|
|
70
|
+
// Boxed list container — the remaining children move into it.
|
|
25
71
|
const listbox = document.createElement('div');
|
|
26
72
|
listbox.className = 'adw-preferences-group-listbox';
|
|
27
|
-
|
|
73
|
+
rowChildren.forEach((child) => listbox.appendChild(child));
|
|
74
|
+
|
|
75
|
+
this.replaceChildren(header, listbox);
|
|
76
|
+
|
|
77
|
+
this._renderHeader();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
attributeChangedCallback() {
|
|
81
|
+
if (this._initialized) this._renderHeader();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private _renderHeader() {
|
|
85
|
+
const title = this.getAttribute('title') ?? '';
|
|
86
|
+
this._titleEl.textContent = title;
|
|
87
|
+
this._titleEl.hidden = title.length === 0;
|
|
88
|
+
|
|
89
|
+
const description = this.getAttribute('description') ?? '';
|
|
90
|
+
this._descriptionEl.textContent = description;
|
|
91
|
+
this._descriptionEl.hidden = description.length === 0;
|
|
28
92
|
|
|
29
|
-
|
|
30
|
-
|
|
93
|
+
// The header only takes space when it carries a title, a description or
|
|
94
|
+
// a suffix — an empty group has no header padding, like Adw's.
|
|
95
|
+
const hasSuffix = this._suffixEl.childElementCount > 0;
|
|
96
|
+
const headerEl = this._titleEl.parentElement?.parentElement;
|
|
97
|
+
if (headerEl) headerEl.hidden = title.length === 0 && description.length === 0 && !hasSuffix;
|
|
31
98
|
}
|
|
32
99
|
}
|
|
33
100
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// <adw-sidebar> — A selectable navigation sidebar (the web counterpart of
|
|
2
|
+
// Adw.Sidebar). It holds <adw-sidebar-section> groups, each of which holds
|
|
3
|
+
// <adw-sidebar-item> children (icon + title + optional subtitle). Zero or one
|
|
4
|
+
// item is selected at a time; clicking an item selects it and activates it. In
|
|
5
|
+
// `sidebar` mode it renders as a flat navigation list (the AdwSidebar
|
|
6
|
+
// `navigation-sidebar`); in `page` mode it renders as boxed lists with a
|
|
7
|
+
// chevron arrow on each row (the AdwPreferencesPage look the native widget
|
|
8
|
+
// switches to when collapsed).
|
|
9
|
+
// Sub-elements (declared as children, consumed at connect time — mirrors
|
|
10
|
+
// adw-tab-page in adw-tab-view):
|
|
11
|
+
// <adw-sidebar-section title="…"> — a titled group of items. An untitled
|
|
12
|
+
// section renders a separator before its rows (first section: nothing),
|
|
13
|
+
// mirroring AdwSidebar's stack header (title vs separator child).
|
|
14
|
+
// <adw-sidebar-item title="…" subtitle="…" icon-name="…"> — one row.
|
|
15
|
+
// Attributes (on <adw-sidebar>):
|
|
16
|
+
// mode (sidebar | page, default sidebar) — mirrors Adw.Sidebar:mode.
|
|
17
|
+
// selected (zero-based flat item index, default 0; -1 = no selection,
|
|
18
|
+
// mirroring Gtk.INVALID_LIST_POSITION) — mirrors Adw.Sidebar:selected.
|
|
19
|
+
// Events:
|
|
20
|
+
// `notify::selected` (CustomEvent, bubbles, `detail = { selected }`) when the
|
|
21
|
+
// selected index changes — mirrors the Adw.Sidebar:selected property.
|
|
22
|
+
// `activated` (CustomEvent, bubbles, `detail = { index }`) when an item is
|
|
23
|
+
// activated (clicked) — mirrors the Adw.Sidebar::activated signal.
|
|
24
|
+
// Reference: refs/libadwaita/src/adw-sidebar.c (AdwSidebar behaviour)
|
|
25
|
+
// Reference: refs/libadwaita/src/adw-sidebar-item.h (AdwSidebarItem properties)
|
|
26
|
+
// Reference: refs/libadwaita/src/stylesheet/widgets/_sidebars.scss (.navigation-sidebar, sidebar)
|
|
27
|
+
// Copyright (c) 2025 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
|
|
28
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
29
|
+
|
|
30
|
+
/** Index meaning "no selection" — the web mirror of Gtk.INVALID_LIST_POSITION. */
|
|
31
|
+
const NO_SELECTION = -1;
|
|
32
|
+
|
|
33
|
+
/** GTK symbolic name (e.g. "folder-symbolic") → adwaita-web icon class. */
|
|
34
|
+
function iconClass(gtkName: string): string {
|
|
35
|
+
const name = gtkName.replace(/-symbolic$/, '');
|
|
36
|
+
return name ? `adw-icon adw-icon--${name}` : 'adw-icon';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A single sidebar item. Child of <adw-sidebar-section>; consumed at connect time. */
|
|
40
|
+
export class AdwSidebarItem extends HTMLElement {
|
|
41
|
+
static get observedAttributes() {
|
|
42
|
+
return ['title', 'subtitle', 'icon-name'];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A titled group of items. Child of <adw-sidebar>; consumed at connect time. */
|
|
47
|
+
export class AdwSidebarSection extends HTMLElement {
|
|
48
|
+
static get observedAttributes() {
|
|
49
|
+
return ['title'];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class AdwSidebar extends HTMLElement {
|
|
54
|
+
private _listEl!: HTMLDivElement;
|
|
55
|
+
private _rows: HTMLButtonElement[] = [];
|
|
56
|
+
private _selected = 0;
|
|
57
|
+
private _mode: 'sidebar' | 'page' = 'sidebar';
|
|
58
|
+
private _initialized = false;
|
|
59
|
+
|
|
60
|
+
static get observedAttributes() {
|
|
61
|
+
return ['mode', 'selected'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Zero-based flat index of the selected item, or -1 for no selection. */
|
|
65
|
+
get selected(): number {
|
|
66
|
+
return this._selected;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set selected(value: number) {
|
|
70
|
+
this.setAttribute('selected', String(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 'sidebar' (flat navigation list) or 'page' (boxed lists). */
|
|
74
|
+
get mode(): 'sidebar' | 'page' {
|
|
75
|
+
return this._mode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
set mode(value: 'sidebar' | 'page') {
|
|
79
|
+
this.setAttribute('mode', value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
connectedCallback() {
|
|
83
|
+
if (this._initialized) return;
|
|
84
|
+
this._initialized = true;
|
|
85
|
+
|
|
86
|
+
// Snapshot the declared <adw-sidebar-section> children before we take
|
|
87
|
+
// over the subtree — their title + <adw-sidebar-item> descendants become
|
|
88
|
+
// the rendered sections and rows.
|
|
89
|
+
const sections = Array.from(this.querySelectorAll('adw-sidebar-section')) as AdwSidebarSection[];
|
|
90
|
+
|
|
91
|
+
this._listEl = document.createElement('div');
|
|
92
|
+
this._listEl.className = 'adw-sidebar-list';
|
|
93
|
+
this._listEl.setAttribute('role', 'listbox');
|
|
94
|
+
|
|
95
|
+
this._rows = [];
|
|
96
|
+
|
|
97
|
+
sections.forEach((section, sectionIndex) => {
|
|
98
|
+
const title = section.getAttribute('title') ?? '';
|
|
99
|
+
const items = Array.from(section.querySelectorAll('adw-sidebar-item')) as AdwSidebarItem[];
|
|
100
|
+
|
|
101
|
+
// The section header — a heading when titled, an empty separator
|
|
102
|
+
// otherwise (mirrors AdwSidebar's header stack: "title" vs
|
|
103
|
+
// "separator" child). The very first section never draws a leading
|
|
104
|
+
// separator (matches the `.header.first` rule).
|
|
105
|
+
const header = document.createElement('div');
|
|
106
|
+
header.className = 'adw-sidebar-section-header';
|
|
107
|
+
if (title) {
|
|
108
|
+
header.classList.add('has-title');
|
|
109
|
+
const heading = document.createElement('span');
|
|
110
|
+
heading.className = 'adw-sidebar-section-heading';
|
|
111
|
+
heading.textContent = title;
|
|
112
|
+
header.appendChild(heading);
|
|
113
|
+
if (sectionIndex === 0) header.classList.add('first');
|
|
114
|
+
} else {
|
|
115
|
+
header.classList.add('separator');
|
|
116
|
+
if (sectionIndex === 0) header.hidden = true;
|
|
117
|
+
}
|
|
118
|
+
this._listEl.appendChild(header);
|
|
119
|
+
|
|
120
|
+
const sectionEl = document.createElement('div');
|
|
121
|
+
sectionEl.className = 'adw-sidebar-section';
|
|
122
|
+
|
|
123
|
+
items.forEach((item) => {
|
|
124
|
+
const flatIndex = this._rows.length;
|
|
125
|
+
const itemTitle = item.getAttribute('title') ?? '';
|
|
126
|
+
const subtitle = item.getAttribute('subtitle') ?? '';
|
|
127
|
+
const icon = item.getAttribute('icon-name') ?? '';
|
|
128
|
+
|
|
129
|
+
const row = document.createElement('button');
|
|
130
|
+
row.type = 'button';
|
|
131
|
+
row.className = 'adw-sidebar-item';
|
|
132
|
+
row.setAttribute('role', 'option');
|
|
133
|
+
|
|
134
|
+
const iconEl = document.createElement('span');
|
|
135
|
+
iconEl.className = `${iconClass(icon)} adw-sidebar-item-icon`;
|
|
136
|
+
iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
|
|
137
|
+
iconEl.hidden = icon.length === 0;
|
|
138
|
+
row.appendChild(iconEl);
|
|
139
|
+
|
|
140
|
+
const textEl = document.createElement('span');
|
|
141
|
+
textEl.className = 'adw-sidebar-item-text';
|
|
142
|
+
|
|
143
|
+
const titleEl = document.createElement('span');
|
|
144
|
+
titleEl.className = 'adw-sidebar-item-title';
|
|
145
|
+
titleEl.textContent = itemTitle;
|
|
146
|
+
titleEl.hidden = itemTitle.length === 0;
|
|
147
|
+
textEl.appendChild(titleEl);
|
|
148
|
+
|
|
149
|
+
const subtitleEl = document.createElement('span');
|
|
150
|
+
subtitleEl.className = 'adw-sidebar-item-subtitle';
|
|
151
|
+
subtitleEl.textContent = subtitle;
|
|
152
|
+
subtitleEl.hidden = subtitle.length === 0;
|
|
153
|
+
textEl.appendChild(subtitleEl);
|
|
154
|
+
|
|
155
|
+
row.appendChild(textEl);
|
|
156
|
+
|
|
157
|
+
// Page mode adds a trailing chevron on every row, the way the
|
|
158
|
+
// boxed-list AdwActionRow carries a `go-next-symbolic` arrow.
|
|
159
|
+
const arrowEl = document.createElement('span');
|
|
160
|
+
arrowEl.className = 'adw-icon adw-icon--go-next adw-sidebar-item-arrow';
|
|
161
|
+
arrowEl.setAttribute('aria-hidden', 'true');
|
|
162
|
+
row.appendChild(arrowEl);
|
|
163
|
+
|
|
164
|
+
row.addEventListener('click', () => this._activateIndex(flatIndex));
|
|
165
|
+
sectionEl.appendChild(row);
|
|
166
|
+
this._rows.push(row);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
this._listEl.appendChild(sectionEl);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
this.replaceChildren(this._listEl);
|
|
173
|
+
|
|
174
|
+
this._mode = this._readModeAttr();
|
|
175
|
+
this._selected = this._readSelectedAttr();
|
|
176
|
+
this._render();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
attributeChangedCallback(name: string) {
|
|
180
|
+
if (!this._initialized) return;
|
|
181
|
+
if (name === 'selected') {
|
|
182
|
+
const next = this._readSelectedAttr();
|
|
183
|
+
if (next !== this._selected) {
|
|
184
|
+
this._selected = next;
|
|
185
|
+
this._render();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (name === 'mode') {
|
|
190
|
+
const next = this._readModeAttr();
|
|
191
|
+
if (next !== this._mode) {
|
|
192
|
+
this._mode = next;
|
|
193
|
+
this._render();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private _readModeAttr(): 'sidebar' | 'page' {
|
|
199
|
+
return this.getAttribute('mode') === 'page' ? 'page' : 'sidebar';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private _readSelectedAttr(): number {
|
|
203
|
+
const raw = Number.parseInt(this.getAttribute('selected') ?? '0', 10);
|
|
204
|
+
if (Number.isNaN(raw)) return 0;
|
|
205
|
+
if (raw < 0) return NO_SELECTION;
|
|
206
|
+
const max = Math.max(0, this._rows.length - 1);
|
|
207
|
+
return Math.min(raw, max);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Click handler — selects the row (if changed) and always emits `activated`. */
|
|
211
|
+
private _activateIndex(index: number): void {
|
|
212
|
+
if (index !== this._selected) {
|
|
213
|
+
this._selected = index;
|
|
214
|
+
// Keep the attribute in sync without re-entering via the guarded
|
|
215
|
+
// attributeChangedCallback (value already matches, so it no-ops).
|
|
216
|
+
this.setAttribute('selected', String(index));
|
|
217
|
+
this._render();
|
|
218
|
+
this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
|
|
219
|
+
}
|
|
220
|
+
this.dispatchEvent(new CustomEvent('activated', { bubbles: true, detail: { index } }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _render(): void {
|
|
224
|
+
this.classList.toggle('page', this._mode === 'page');
|
|
225
|
+
this.classList.toggle('mode-sidebar', this._mode === 'sidebar');
|
|
226
|
+
|
|
227
|
+
this._rows.forEach((row, index) => {
|
|
228
|
+
const isSelected = index === this._selected;
|
|
229
|
+
row.classList.toggle('selected', isSelected);
|
|
230
|
+
row.setAttribute('aria-selected', String(isSelected));
|
|
231
|
+
row.tabIndex = isSelected || (this._selected === NO_SELECTION && index === 0) ? 0 : -1;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
customElements.define('adw-sidebar-item', AdwSidebarItem);
|
|
237
|
+
customElements.define('adw-sidebar-section', AdwSidebarSection);
|
|
238
|
+
customElements.define('adw-sidebar', AdwSidebar);
|