@gjsify/adwaita-web 0.15.1 → 0.16.1
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/dist/adwaita-web.css +408 -0
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-data-grid.spec.d.ts +1 -0
- package/lib/types/adw-dialog.spec.d.ts +1 -0
- package/lib/types/adw-drop-down.spec.d.ts +1 -0
- package/lib/types/elements/adw-data-grid.d.ts +60 -0
- package/lib/types/elements/adw-dialog.d.ts +51 -0
- package/lib/types/elements/adw-drop-down.d.ts +63 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +13 -11
- package/scss/_data_grid.scss +144 -0
- package/scss/_dialog.scss +220 -0
- package/scss/_drop_down.scss +185 -0
- package/scss/_icons.generated.scss +221 -0
- package/scss/_theme.scss +2 -0
- package/scss/_variables.scss +4 -0
- package/scss/adwaita-skin.scss +3 -0
- package/src/adw-data-grid.spec.ts +243 -0
- package/src/adw-dialog.spec.ts +169 -0
- package/src/adw-drop-down.spec.ts +186 -0
- package/src/elements/adw-data-grid.ts +335 -0
- package/src/elements/adw-dialog.ts +329 -0
- package/src/elements/adw-drop-down.ts +405 -0
- package/src/index.ts +11 -0
- package/src/styles.generated.ts +4 -0
- package/src/test.browser.mts +14 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// <adw-data-grid> — A slim, Adwaita-styled ALIGNED data grid: the web mirror of
|
|
2
|
+
// the native Gtk.Grid an accounting app (Buchhaltung) uses for its financial
|
|
3
|
+
// statements (the BWA / P&L, invoice line-items). It is DELIBERATELY NOT a
|
|
4
|
+
// heavyweight sortable Gtk.ColumnView / TreeView — boxed lists (<adw-action-row>
|
|
5
|
+
// inside <adw-preferences-group>) stay the default for record lists. Reach for
|
|
6
|
+
// this ONLY for genuinely tabular numeric data where columns of figures must
|
|
7
|
+
// line up (labels left, amounts right-aligned with tabular figures), with the
|
|
8
|
+
// accounting row emphasis a statement needs: section headers, subtotals and a
|
|
9
|
+
// bold final total.
|
|
10
|
+
//
|
|
11
|
+
// It is a PLAIN presentational element — it does layout, alignment and emphasis;
|
|
12
|
+
// it never formats or sorts. Cell values are PRE-FORMATTED strings (locale
|
|
13
|
+
// currency formatting stays in the app, e.g. `1.234,50 €`); the grid renders the
|
|
14
|
+
// strings and aligns them. Sorting is a data op driven externally (e.g. an
|
|
15
|
+
// <adw-drop-down>) — there are no clickable-sortable column headers here.
|
|
16
|
+
//
|
|
17
|
+
// Columns (the `columns` property, or a JSON `columns` attribute):
|
|
18
|
+
// { key, label?, align?, width?, flex?, monospace?, numeric? }
|
|
19
|
+
// key — the row-object key this column reads.
|
|
20
|
+
// label — header text (defaults to `key`).
|
|
21
|
+
// align — 'start' | 'end' | 'center' (default 'start'; a `numeric`
|
|
22
|
+
// column defaults to 'end' so figures right-align).
|
|
23
|
+
// width — a fixed CSS track size ('80px', '6rem') — pins the column.
|
|
24
|
+
// flex — a fractional track weight (→ `<flex>fr`) — grows the column.
|
|
25
|
+
// monospace — render cells in a monospace family (tabular figures either way).
|
|
26
|
+
// numeric — convenience: right-aligns + applies tabular-nums (the point of
|
|
27
|
+
// the grid vs a boxed list — numeric columns of figures line up).
|
|
28
|
+
// With neither width nor flex declared anywhere, the FIRST column absorbs the
|
|
29
|
+
// slack (so trailing numeric columns hug the right edge) and the rest size to
|
|
30
|
+
// content — the faithful "label left, amounts right" statement shape.
|
|
31
|
+
//
|
|
32
|
+
// Rows (the `rows` property, or a JSON `rows` attribute) — row objects keyed by
|
|
33
|
+
// column key, PLUS optional accounting metadata:
|
|
34
|
+
// variant? — 'normal' | 'section' | 'subtotal' | 'total'
|
|
35
|
+
// section — a bold group header spanning all columns (its text = the value
|
|
36
|
+
// under the FIRST column's key). Nests a BWA into its sections.
|
|
37
|
+
// subtotal — bold, with a hairline rule above (Gesamtleistung, Rohertrag…).
|
|
38
|
+
// total — bold + a stronger rule above (the final result — Betriebsergebnis).
|
|
39
|
+
// normal — a plain data row (the default).
|
|
40
|
+
// interactive? — make just this row clickable (see `row-activated`).
|
|
41
|
+
//
|
|
42
|
+
// Attributes / properties:
|
|
43
|
+
// caption — an optional title shown above the grid.
|
|
44
|
+
// interactive — element-level flag: every `normal` row becomes clickable
|
|
45
|
+
// (a row may opt out with `interactive: false`); mainly for a
|
|
46
|
+
// master-detail list. Section/subtotal/total rows never activate.
|
|
47
|
+
// Events:
|
|
48
|
+
// `row-activated` (CustomEvent, bubbles, detail = { index, row }) — a clickable
|
|
49
|
+
// row was activated by click or Enter/Space. `index` is the row's index in
|
|
50
|
+
// the `rows` array; `row` is the original row object.
|
|
51
|
+
// Responsive: the grid scrolls horizontally within its OWN container on narrow
|
|
52
|
+
// viewports (it never overflows the page); it sits inside an <adw-card>/boxed
|
|
53
|
+
// surface and follows light + dark via the adwaita-web CSS variables.
|
|
54
|
+
// Reference: Gtk.Grid usage in the Buchhaltung BWA view (native financial grid).
|
|
55
|
+
// Reference: refs/libadwaita/src/stylesheet/_colors.scss (separator / card tokens).
|
|
56
|
+
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
57
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
58
|
+
|
|
59
|
+
/** Horizontal alignment of a column's header + cells. */
|
|
60
|
+
export type AdwDataGridAlign = 'start' | 'end' | 'center';
|
|
61
|
+
|
|
62
|
+
/** Accounting row emphasis. `normal` is the default plain data row. */
|
|
63
|
+
export type AdwDataGridRowVariant = 'normal' | 'section' | 'subtotal' | 'total';
|
|
64
|
+
|
|
65
|
+
/** A column descriptor — what to read (`key`), how to label + align it. */
|
|
66
|
+
export interface AdwDataGridColumn {
|
|
67
|
+
/** The row-object key this column renders. */
|
|
68
|
+
key: string;
|
|
69
|
+
/** Header text (defaults to `key`). */
|
|
70
|
+
label?: string;
|
|
71
|
+
/** 'start' | 'end' | 'center'. Defaults to 'start' (or 'end' when numeric). */
|
|
72
|
+
align?: AdwDataGridAlign;
|
|
73
|
+
/** Fixed CSS track size (e.g. '80px', '6rem') — pins the column width. */
|
|
74
|
+
width?: string;
|
|
75
|
+
/** Fractional track weight (→ `<flex>fr`) — lets the column grow. */
|
|
76
|
+
flex?: number;
|
|
77
|
+
/** Render cells in a monospace family (tabular figures apply regardless). */
|
|
78
|
+
monospace?: boolean;
|
|
79
|
+
/** Convenience: right-align + tabular-nums (numeric columns of figures). */
|
|
80
|
+
numeric?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A row: cell values keyed by column key + optional accounting metadata. */
|
|
84
|
+
export interface AdwDataGridRow {
|
|
85
|
+
/** Accounting emphasis. Default 'normal'. */
|
|
86
|
+
variant?: AdwDataGridRowVariant;
|
|
87
|
+
/** Make just this row clickable (overrides the element-level flag). */
|
|
88
|
+
interactive?: boolean;
|
|
89
|
+
/** Cell values keyed by column key (pre-formatted strings, or numbers). */
|
|
90
|
+
[key: string]: string | number | boolean | AdwDataGridRowVariant | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const VARIANTS: ReadonlySet<string> = new Set(['normal', 'section', 'subtotal', 'total']);
|
|
94
|
+
|
|
95
|
+
export class AdwDataGrid extends HTMLElement {
|
|
96
|
+
private _captionEl!: HTMLDivElement;
|
|
97
|
+
private _scrollEl!: HTMLDivElement;
|
|
98
|
+
private _tableEl!: HTMLDivElement;
|
|
99
|
+
private _columns: AdwDataGridColumn[] = [];
|
|
100
|
+
private _rows: AdwDataGridRow[] = [];
|
|
101
|
+
private _initialized = false;
|
|
102
|
+
|
|
103
|
+
static get observedAttributes() {
|
|
104
|
+
return ['columns', 'rows', 'caption', 'interactive'];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The column descriptors. Setting rebuilds the grid. */
|
|
108
|
+
get columns(): AdwDataGridColumn[] {
|
|
109
|
+
return this._columns;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
set columns(value: ReadonlyArray<AdwDataGridColumn>) {
|
|
113
|
+
this._columns = normalizeColumns(value);
|
|
114
|
+
if (this._initialized) this._render();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The row objects. Setting rebuilds the body. */
|
|
118
|
+
get rows(): AdwDataGridRow[] {
|
|
119
|
+
return this._rows;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
set rows(value: ReadonlyArray<AdwDataGridRow>) {
|
|
123
|
+
this._rows = Array.isArray(value) ? value.map((r) => ({ ...r })) : [];
|
|
124
|
+
if (this._initialized) this._render();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The optional grid caption/title (reflected to the attribute). */
|
|
128
|
+
get caption(): string {
|
|
129
|
+
return this.getAttribute('caption') ?? '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
set caption(value: string) {
|
|
133
|
+
if (value) this.setAttribute('caption', value);
|
|
134
|
+
else this.removeAttribute('caption');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether every `normal` row is clickable (element-level flag). */
|
|
138
|
+
get interactive(): boolean {
|
|
139
|
+
return this.hasAttribute('interactive');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set interactive(value: boolean) {
|
|
143
|
+
if (value) this.setAttribute('interactive', '');
|
|
144
|
+
else this.removeAttribute('interactive');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
connectedCallback() {
|
|
148
|
+
if (this._initialized) return;
|
|
149
|
+
this._initialized = true;
|
|
150
|
+
|
|
151
|
+
this.classList.add('adw-data-grid');
|
|
152
|
+
|
|
153
|
+
this._captionEl = document.createElement('div');
|
|
154
|
+
this._captionEl.className = 'adw-data-grid-caption';
|
|
155
|
+
this._captionEl.hidden = true;
|
|
156
|
+
|
|
157
|
+
this._scrollEl = document.createElement('div');
|
|
158
|
+
this._scrollEl.className = 'adw-data-grid-scroll';
|
|
159
|
+
|
|
160
|
+
this._tableEl = document.createElement('div');
|
|
161
|
+
this._tableEl.className = 'adw-data-grid-table';
|
|
162
|
+
this._tableEl.setAttribute('role', 'table');
|
|
163
|
+
this._scrollEl.appendChild(this._tableEl);
|
|
164
|
+
|
|
165
|
+
// Seed from JSON attributes only when the properties were not set.
|
|
166
|
+
if (this._columns.length === 0) this._columns = this._parseJsonAttr('columns', normalizeColumns);
|
|
167
|
+
if (this._rows.length === 0) this._rows = this._parseJsonAttr('rows', (v) => (Array.isArray(v) ? v : []));
|
|
168
|
+
|
|
169
|
+
this.replaceChildren(this._captionEl, this._scrollEl);
|
|
170
|
+
this._render();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
174
|
+
if (!this._initialized) return;
|
|
175
|
+
if (name === 'columns') {
|
|
176
|
+
this._columns = this._parseJsonAttr('columns', normalizeColumns);
|
|
177
|
+
this._render();
|
|
178
|
+
} else if (name === 'rows') {
|
|
179
|
+
this._rows = this._parseJsonAttr('rows', (v) => (Array.isArray(v) ? (v as AdwDataGridRow[]) : []));
|
|
180
|
+
this._render();
|
|
181
|
+
} else if (name === 'caption') {
|
|
182
|
+
this._renderCaption();
|
|
183
|
+
} else if (name === 'interactive') {
|
|
184
|
+
this._render();
|
|
185
|
+
}
|
|
186
|
+
void value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── internals ──────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
private _parseJsonAttr<T>(name: string, coerce: (v: unknown) => T): T {
|
|
192
|
+
const raw = this.getAttribute(name);
|
|
193
|
+
if (!raw) return coerce(undefined);
|
|
194
|
+
try {
|
|
195
|
+
return coerce(JSON.parse(raw) as unknown);
|
|
196
|
+
} catch {
|
|
197
|
+
return coerce(undefined);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private _renderCaption(): void {
|
|
202
|
+
const caption = this.getAttribute('caption') ?? '';
|
|
203
|
+
this._captionEl.textContent = caption;
|
|
204
|
+
this._captionEl.hidden = caption.length === 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The CSS `grid-template-columns` value derived from the column descriptors. */
|
|
208
|
+
private _trackTemplate(): string {
|
|
209
|
+
const anyExplicit = this._columns.some((c) => c.width !== undefined || c.flex !== undefined);
|
|
210
|
+
return this._columns
|
|
211
|
+
.map((column, index) => {
|
|
212
|
+
if (column.width !== undefined) return column.width;
|
|
213
|
+
if (column.flex !== undefined) return `${column.flex}fr`;
|
|
214
|
+
// With no explicit sizing anywhere, the first (label) column
|
|
215
|
+
// absorbs slack so trailing numeric columns hug the right edge.
|
|
216
|
+
// `0px` (not `0`) is the CSSOM-canonical form so it round-trips.
|
|
217
|
+
if (!anyExplicit && index === 0) return 'minmax(0px, 1fr)';
|
|
218
|
+
return 'auto';
|
|
219
|
+
})
|
|
220
|
+
.join(' ');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _columnClasses(column: AdwDataGridColumn): string {
|
|
224
|
+
const align: AdwDataGridAlign = column.align ?? (column.numeric ? 'end' : 'start');
|
|
225
|
+
const classes = ['adw-data-grid-cell', `align-${align}`];
|
|
226
|
+
if (column.numeric) classes.push('numeric');
|
|
227
|
+
if (column.monospace) classes.push('mono');
|
|
228
|
+
return classes.join(' ');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private _render(): void {
|
|
232
|
+
if (!this._tableEl) return;
|
|
233
|
+
this._renderCaption();
|
|
234
|
+
this._tableEl.style.gridTemplateColumns = this._trackTemplate();
|
|
235
|
+
this._tableEl.replaceChildren();
|
|
236
|
+
|
|
237
|
+
const columnCount = this._columns.length;
|
|
238
|
+
if (columnCount === 0) return;
|
|
239
|
+
|
|
240
|
+
// Header row (dim, small-caps) — column labels, aligned per column.
|
|
241
|
+
const header = document.createElement('div');
|
|
242
|
+
header.className = 'adw-data-grid-row header';
|
|
243
|
+
header.setAttribute('role', 'row');
|
|
244
|
+
for (const column of this._columns) {
|
|
245
|
+
const cell = document.createElement('div');
|
|
246
|
+
cell.className = `${this._columnClasses(column)} header-cell`;
|
|
247
|
+
cell.setAttribute('role', 'columnheader');
|
|
248
|
+
cell.textContent = column.label ?? column.key;
|
|
249
|
+
header.appendChild(cell);
|
|
250
|
+
}
|
|
251
|
+
this._tableEl.appendChild(header);
|
|
252
|
+
|
|
253
|
+
const elementInteractive = this.hasAttribute('interactive');
|
|
254
|
+
|
|
255
|
+
this._rows.forEach((rowData, index) => {
|
|
256
|
+
const variant = normalizeVariant(rowData.variant);
|
|
257
|
+
const row = document.createElement('div');
|
|
258
|
+
row.className = `adw-data-grid-row variant-${variant}`;
|
|
259
|
+
row.setAttribute('role', 'row');
|
|
260
|
+
|
|
261
|
+
if (variant === 'section') {
|
|
262
|
+
// A section header spans all columns; its text is the first
|
|
263
|
+
// column's value (the section title).
|
|
264
|
+
const firstKey = this._columns[0]?.key ?? '';
|
|
265
|
+
const cell = document.createElement('div');
|
|
266
|
+
cell.className = 'adw-data-grid-cell align-start section-cell';
|
|
267
|
+
cell.setAttribute('role', 'cell');
|
|
268
|
+
cell.style.gridColumn = '1 / -1';
|
|
269
|
+
cell.textContent = cellText(rowData[firstKey]);
|
|
270
|
+
row.appendChild(cell);
|
|
271
|
+
} else {
|
|
272
|
+
for (const column of this._columns) {
|
|
273
|
+
const cell = document.createElement('div');
|
|
274
|
+
cell.className = this._columnClasses(column);
|
|
275
|
+
cell.setAttribute('role', 'cell');
|
|
276
|
+
cell.textContent = cellText(rowData[column.key]);
|
|
277
|
+
row.appendChild(cell);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A row is clickable only when it is a plain data row AND either the
|
|
282
|
+
// element opts everything in or the row opts itself in.
|
|
283
|
+
const rowInteractive =
|
|
284
|
+
variant === 'normal' &&
|
|
285
|
+
rowData.interactive !== false &&
|
|
286
|
+
(rowData.interactive === true || elementInteractive);
|
|
287
|
+
if (rowInteractive) {
|
|
288
|
+
row.classList.add('interactive');
|
|
289
|
+
row.tabIndex = 0;
|
|
290
|
+
row.addEventListener('click', () => this._activate(index, rowData));
|
|
291
|
+
row.addEventListener('keydown', (event) => {
|
|
292
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
this._activate(index, rowData);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this._tableEl.appendChild(row);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private _activate(index: number, row: AdwDataGridRow): void {
|
|
304
|
+
this.dispatchEvent(new CustomEvent('row-activated', { bubbles: true, detail: { index, row } }));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Coerce a cell value to display text — pre-formatted strings pass through. */
|
|
309
|
+
function cellText(value: string | number | boolean | AdwDataGridRowVariant | undefined): string {
|
|
310
|
+
if (value === undefined || value === null) return '';
|
|
311
|
+
return String(value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Clamp an arbitrary variant to the known set (unknown → 'normal'). */
|
|
315
|
+
function normalizeVariant(variant: unknown): AdwDataGridRowVariant {
|
|
316
|
+
return typeof variant === 'string' && VARIANTS.has(variant) ? (variant as AdwDataGridRowVariant) : 'normal';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Normalise raw column descriptors to a stable list (drops malformed entries). */
|
|
320
|
+
function normalizeColumns(raw: unknown): AdwDataGridColumn[] {
|
|
321
|
+
if (!Array.isArray(raw)) return [];
|
|
322
|
+
return raw
|
|
323
|
+
.filter((c): c is AdwDataGridColumn => typeof c === 'object' && c !== null && 'key' in c)
|
|
324
|
+
.map((c) => ({
|
|
325
|
+
key: String(c.key),
|
|
326
|
+
label: c.label !== undefined ? String(c.label) : undefined,
|
|
327
|
+
align: c.align,
|
|
328
|
+
width: c.width !== undefined ? String(c.width) : undefined,
|
|
329
|
+
flex: typeof c.flex === 'number' ? c.flex : undefined,
|
|
330
|
+
monospace: c.monospace === true,
|
|
331
|
+
numeric: c.numeric === true,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
customElements.define('adw-data-grid', AdwDataGrid);
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// <adw-dialog> — A generic adaptive dialog: the web counterpart of Adw.Dialog.
|
|
2
|
+
// It wraps arbitrary content in a floating card centred over a full-cover scrim
|
|
3
|
+
// on wide viewports, and re-anchors it as a bottom sheet (full-width, rounded
|
|
4
|
+
// top corners) on narrow ones — the adaptive presentation Adw.Dialog gets for
|
|
5
|
+
// free from AdwBreakpoint on GTK. There is no Adw.Breakpoint primitive on the
|
|
6
|
+
// web, so the switch is a CSS media query keyed on the `presentation-mode`.
|
|
7
|
+
//
|
|
8
|
+
// Unlike the specialised dialogs (<adw-alert-dialog>, <adw-preferences-dialog>,
|
|
9
|
+
// <adw-about-dialog>) this one imposes no content model of its own: its default
|
|
10
|
+
// slot holds any markup, and it renders an OPTIONAL flat header bar (a title +
|
|
11
|
+
// a trailing close button) only when a `title` is set or `show-header` is used.
|
|
12
|
+
// It is the generic base those specialised dialogs could later be built on.
|
|
13
|
+
//
|
|
14
|
+
// Like Adw.Dialog it starts hidden; `present()` (or the `open` attribute)
|
|
15
|
+
// reveals it and `close()` dismisses it — gated by `can-close` (Escape /
|
|
16
|
+
// scrim-click / close-button raise `close-attempt` instead of closing while
|
|
17
|
+
// locked). Presenting traps focus inside the dialog and returns it to the
|
|
18
|
+
// previously-focused element on close. A disconnected element is attached to
|
|
19
|
+
// `document.body` on present (the Adw.Dialog.present(parent) idiom); a
|
|
20
|
+
// declaratively-authored dialog already in the DOM is simply revealed in place
|
|
21
|
+
// (and stays reusable — close hides, it does not detach).
|
|
22
|
+
//
|
|
23
|
+
// Attributes:
|
|
24
|
+
// title (the header title — Adw.Dialog:title; presence renders the header)
|
|
25
|
+
// open (boolean — whether the dialog is revealed; default hidden)
|
|
26
|
+
// can-close (boolean — user may dismiss; default on. `="false"` locks it
|
|
27
|
+
// so a dismissal raises `close-attempt` — Adw.Dialog:can-close)
|
|
28
|
+
// content-width (max content width in px — Adw.Dialog:content-width; default 360)
|
|
29
|
+
// content-height (max content height in px — Adw.Dialog:content-height; default unset)
|
|
30
|
+
// presentation-mode (`auto` | `floating` | `bottom-sheet`; default `auto` —
|
|
31
|
+
// Adw.Dialog:presentation-mode. `auto` = floating when wide,
|
|
32
|
+
// bottom sheet when narrow)
|
|
33
|
+
// show-header (boolean — render the header bar even without a title)
|
|
34
|
+
//
|
|
35
|
+
// Properties: open, title, canClose, contentWidth, contentHeight, presentationMode.
|
|
36
|
+
// Methods: present(), close(), forceClose().
|
|
37
|
+
// Events:
|
|
38
|
+
// `notify::open` (CustomEvent, bubbles, detail = { open }) — revealed state changed.
|
|
39
|
+
// `closed` (CustomEvent, bubbles) — the dialog finished closing (Adw.Dialog::closed).
|
|
40
|
+
// `close-attempt` (CustomEvent, bubbles) — a dismissal was attempted while locked
|
|
41
|
+
// (Adw.Dialog::close-attempt).
|
|
42
|
+
//
|
|
43
|
+
// Reference: refs/libadwaita/src/adw-dialog.c (present/close/can-close, Escape, focus)
|
|
44
|
+
// Reference: refs/libadwaita/src/stylesheet/widgets/_dialogs.scss (floating sheet + bottom sheet)
|
|
45
|
+
// Copyright (c) 2023-2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
|
|
46
|
+
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
47
|
+
|
|
48
|
+
/** Adaptive presentation of the dialog — mirrors Adw.DialogPresentationMode. */
|
|
49
|
+
export type AdwDialogPresentationMode = 'auto' | 'floating' | 'bottom-sheet';
|
|
50
|
+
|
|
51
|
+
const DEFAULT_CONTENT_WIDTH = 360;
|
|
52
|
+
const PRESENTATION_MODES: readonly AdwDialogPresentationMode[] = ['auto', 'floating', 'bottom-sheet'];
|
|
53
|
+
|
|
54
|
+
// Selector matching the tabbable elements a focus trap must cycle through.
|
|
55
|
+
const FOCUSABLE_SELECTOR = [
|
|
56
|
+
'a[href]',
|
|
57
|
+
'button:not([disabled])',
|
|
58
|
+
'input:not([disabled])',
|
|
59
|
+
'select:not([disabled])',
|
|
60
|
+
'textarea:not([disabled])',
|
|
61
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
62
|
+
].join(',');
|
|
63
|
+
|
|
64
|
+
export class AdwDialog extends HTMLElement {
|
|
65
|
+
private _initialized = false;
|
|
66
|
+
private _scrimEl!: HTMLDivElement;
|
|
67
|
+
private _boxEl!: HTMLDivElement;
|
|
68
|
+
private _headerEl!: HTMLDivElement;
|
|
69
|
+
private _titleEl!: HTMLSpanElement;
|
|
70
|
+
private _closeBtn!: HTMLButtonElement;
|
|
71
|
+
private _contentEl!: HTMLDivElement;
|
|
72
|
+
// The element focused before present(), restored on close (return-focus).
|
|
73
|
+
private _previouslyFocused: HTMLElement | null = null;
|
|
74
|
+
|
|
75
|
+
static get observedAttributes() {
|
|
76
|
+
return ['title', 'open', 'can-close', 'content-width', 'content-height', 'presentation-mode', 'show-header'];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether the dialog is currently revealed. */
|
|
80
|
+
get open(): boolean {
|
|
81
|
+
return this.hasAttribute('open');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
set open(value: boolean) {
|
|
85
|
+
if (value) this.setAttribute('open', '');
|
|
86
|
+
else this.removeAttribute('open');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The header title (Adw.Dialog:title). Setting a title renders the header. */
|
|
90
|
+
get title(): string {
|
|
91
|
+
return this.getAttribute('title') ?? '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
set title(value: string) {
|
|
95
|
+
this.setAttribute('title', value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the user can dismiss the dialog (scrim / Escape / close button). */
|
|
99
|
+
get canClose(): boolean {
|
|
100
|
+
const value = this.getAttribute('can-close');
|
|
101
|
+
// Absent → default on; explicit `="false"` → off (mirrors the boxed dialogs).
|
|
102
|
+
if (value === null) return true;
|
|
103
|
+
return value !== 'false';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
set canClose(value: boolean) {
|
|
107
|
+
if (value) this.setAttribute('can-close', '');
|
|
108
|
+
else this.setAttribute('can-close', 'false');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The dialog's preferred/max content width in px (Adw.Dialog:content-width). */
|
|
112
|
+
get contentWidth(): number {
|
|
113
|
+
const raw = Number.parseInt(this.getAttribute('content-width') ?? '', 10);
|
|
114
|
+
return Number.isNaN(raw) ? DEFAULT_CONTENT_WIDTH : raw;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
set contentWidth(value: number) {
|
|
118
|
+
this.setAttribute('content-width', String(value));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The dialog's max content height in px, or 0 for "unconstrained" (Adw.Dialog:content-height). */
|
|
122
|
+
get contentHeight(): number {
|
|
123
|
+
const raw = Number.parseInt(this.getAttribute('content-height') ?? '', 10);
|
|
124
|
+
return Number.isNaN(raw) ? 0 : raw;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
set contentHeight(value: number) {
|
|
128
|
+
if (value > 0) this.setAttribute('content-height', String(value));
|
|
129
|
+
else this.removeAttribute('content-height');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The adaptive presentation mode (Adw.Dialog:presentation-mode). */
|
|
133
|
+
get presentationMode(): AdwDialogPresentationMode {
|
|
134
|
+
const value = this.getAttribute('presentation-mode') as AdwDialogPresentationMode | null;
|
|
135
|
+
return value && PRESENTATION_MODES.includes(value) ? value : 'auto';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
set presentationMode(value: AdwDialogPresentationMode) {
|
|
139
|
+
this.setAttribute('presentation-mode', value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The content container — arbitrary child markup lives here after connect. */
|
|
143
|
+
get contentArea(): HTMLDivElement {
|
|
144
|
+
return this._contentEl;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reveal the dialog (mirrors Adw.Dialog.present). Auto-attaches if disconnected. */
|
|
148
|
+
present(parent?: Element): void {
|
|
149
|
+
if (!this.isConnected) (parent ?? document.body).appendChild(this);
|
|
150
|
+
this.open = true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Dismiss the dialog, respecting `can-close` (mirrors Adw.Dialog.close). */
|
|
154
|
+
close(): void {
|
|
155
|
+
this._attemptClose();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Dismiss the dialog regardless of `can-close` (mirrors Adw.Dialog.force_close). */
|
|
159
|
+
forceClose(): void {
|
|
160
|
+
if (!this.open) return;
|
|
161
|
+
this.open = false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
connectedCallback() {
|
|
165
|
+
if (this._initialized) {
|
|
166
|
+
// A re-attach (e.g. present() on a previously-connected dialog) must
|
|
167
|
+
// not rebuild the subtree — the DOM already exists.
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this._initialized = true;
|
|
171
|
+
|
|
172
|
+
this.classList.add('adw-dialog');
|
|
173
|
+
|
|
174
|
+
// Everything the author placed inside becomes the dialog content.
|
|
175
|
+
const contentChildren = Array.from(this.childNodes);
|
|
176
|
+
|
|
177
|
+
// Full-cover scrim — a flex container that centres the box; clicking it
|
|
178
|
+
// (outside the box) attempts to dismiss the dialog.
|
|
179
|
+
this._scrimEl = document.createElement('div');
|
|
180
|
+
this._scrimEl.className = 'adw-dialog-scrim';
|
|
181
|
+
this._scrimEl.addEventListener('click', () => this._attemptClose());
|
|
182
|
+
|
|
183
|
+
// The dialog box (the "sheet"). Focusable so it can receive keys even
|
|
184
|
+
// when the content has none; clicks inside must not reach the scrim.
|
|
185
|
+
this._boxEl = document.createElement('div');
|
|
186
|
+
this._boxEl.className = 'adw-dialog-box';
|
|
187
|
+
this._boxEl.setAttribute('role', 'dialog');
|
|
188
|
+
this._boxEl.setAttribute('aria-modal', 'true');
|
|
189
|
+
this._boxEl.tabIndex = -1;
|
|
190
|
+
this._boxEl.addEventListener('click', (event) => event.stopPropagation());
|
|
191
|
+
this._boxEl.addEventListener('keydown', (event) => this._onBoxKeyDown(event));
|
|
192
|
+
|
|
193
|
+
// Optional flat header bar (title + trailing close button), strictly
|
|
194
|
+
// centred like an AdwHeaderBar.
|
|
195
|
+
this._headerEl = document.createElement('div');
|
|
196
|
+
this._headerEl.className = 'adw-dialog-header';
|
|
197
|
+
|
|
198
|
+
const leading = document.createElement('div');
|
|
199
|
+
leading.className = 'adw-dialog-header-side';
|
|
200
|
+
|
|
201
|
+
this._titleEl = document.createElement('span');
|
|
202
|
+
this._titleEl.className = 'adw-dialog-title';
|
|
203
|
+
|
|
204
|
+
const trailing = document.createElement('div');
|
|
205
|
+
trailing.className = 'adw-dialog-header-side adw-dialog-header-side--end';
|
|
206
|
+
|
|
207
|
+
this._closeBtn = document.createElement('button');
|
|
208
|
+
this._closeBtn.type = 'button';
|
|
209
|
+
this._closeBtn.className = 'adw-dialog-close';
|
|
210
|
+
this._closeBtn.setAttribute('aria-label', 'Close');
|
|
211
|
+
// No `window-close` symbolic ships in @gjsify/adwaita-icons; the "×"
|
|
212
|
+
// glyph is drawn in CSS (same approach as <adw-preferences-dialog>).
|
|
213
|
+
this._closeBtn.addEventListener('click', () => this._attemptClose());
|
|
214
|
+
trailing.appendChild(this._closeBtn);
|
|
215
|
+
|
|
216
|
+
this._headerEl.append(leading, this._titleEl, trailing);
|
|
217
|
+
|
|
218
|
+
// The content area — the author's markup.
|
|
219
|
+
this._contentEl = document.createElement('div');
|
|
220
|
+
this._contentEl.className = 'adw-dialog-content';
|
|
221
|
+
for (const child of contentChildren) this._contentEl.appendChild(child);
|
|
222
|
+
|
|
223
|
+
this._boxEl.append(this._headerEl, this._contentEl);
|
|
224
|
+
this._scrimEl.appendChild(this._boxEl);
|
|
225
|
+
this.replaceChildren(this._scrimEl);
|
|
226
|
+
|
|
227
|
+
this._render();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
|
231
|
+
if (!this._initialized) return;
|
|
232
|
+
if (oldValue === newValue) return;
|
|
233
|
+
this._render();
|
|
234
|
+
if (name === 'open') {
|
|
235
|
+
this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
|
|
236
|
+
if (this.open) this._onPresented();
|
|
237
|
+
else this._onClosed();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── internals ──────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** A dismissal was requested: honour `can-close`, else raise close-attempt. */
|
|
244
|
+
private _attemptClose(): void {
|
|
245
|
+
if (!this.open) return;
|
|
246
|
+
if (!this.canClose) {
|
|
247
|
+
this.dispatchEvent(new CustomEvent('close-attempt', { bubbles: true }));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Drives attributeChangedCallback → notify::open + closed + return-focus.
|
|
251
|
+
this.open = false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Focus trap + Escape while the box has focus. */
|
|
255
|
+
private _onBoxKeyDown(event: KeyboardEvent): void {
|
|
256
|
+
if (event.key === 'Escape' && this.open) {
|
|
257
|
+
event.stopPropagation();
|
|
258
|
+
this._attemptClose();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.key !== 'Tab') return;
|
|
262
|
+
const focusables = this._focusables();
|
|
263
|
+
if (focusables.length === 0) {
|
|
264
|
+
// Nothing tabbable inside — keep focus on the box.
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
this._boxEl.focus();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const first = focusables[0];
|
|
270
|
+
const last = focusables[focusables.length - 1];
|
|
271
|
+
const active = document.activeElement;
|
|
272
|
+
if (event.shiftKey && (active === first || active === this._boxEl)) {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
last.focus();
|
|
275
|
+
} else if (!event.shiftKey && active === last) {
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
first.focus();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private _focusables(): HTMLElement[] {
|
|
282
|
+
return Array.from(this._boxEl.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
283
|
+
(el) => !el.hasAttribute('disabled') && !el.hidden,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** On present: remember focus, then move it into the dialog. */
|
|
288
|
+
private _onPresented(): void {
|
|
289
|
+
const active = document.activeElement;
|
|
290
|
+
this._previouslyFocused = active instanceof HTMLElement && active !== this ? active : null;
|
|
291
|
+
const target = this._focusables()[0] ?? this._boxEl;
|
|
292
|
+
target.focus();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** On close: emit `closed` and return focus to where it was before present. */
|
|
296
|
+
private _onClosed(): void {
|
|
297
|
+
this.dispatchEvent(new CustomEvent('closed', { bubbles: true }));
|
|
298
|
+
const restore = this._previouslyFocused;
|
|
299
|
+
this._previouslyFocused = null;
|
|
300
|
+
if (restore && restore.isConnected) restore.focus();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private _render(): void {
|
|
304
|
+
this.classList.toggle('open', this.open);
|
|
305
|
+
|
|
306
|
+
const title = this.title;
|
|
307
|
+
this._titleEl.textContent = title;
|
|
308
|
+
|
|
309
|
+
// The header shows when there is a title or the author forces it.
|
|
310
|
+
const hasHeader = title.length > 0 || this.hasAttribute('show-header');
|
|
311
|
+
this._headerEl.hidden = !hasHeader;
|
|
312
|
+
|
|
313
|
+
// Presentation mode drives the CSS layout (floating vs bottom sheet).
|
|
314
|
+
this.dataset.presentation = this.presentationMode;
|
|
315
|
+
|
|
316
|
+
// Size constraints as CSS custom properties consumed by the box.
|
|
317
|
+
this._boxEl.style.setProperty('--adw-dialog-content-width', `${this.contentWidth}px`);
|
|
318
|
+
const height = this.contentHeight;
|
|
319
|
+
if (height > 0) this._boxEl.style.setProperty('--adw-dialog-content-height', `${height}px`);
|
|
320
|
+
else this._boxEl.style.removeProperty('--adw-dialog-content-height');
|
|
321
|
+
|
|
322
|
+
// A locked dialog's close button has no effect; reflect that.
|
|
323
|
+
const locked = !this.canClose;
|
|
324
|
+
this._closeBtn.disabled = locked;
|
|
325
|
+
this._closeBtn.classList.toggle('locked', locked);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
customElements.define('adw-dialog', AdwDialog);
|