@gjsify/adwaita-web 0.31.0 → 0.33.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.
Files changed (183) hide show
  1. package/dist/adwaita-web.css +835 -299
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  30. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  31. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  32. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  33. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  34. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  35. package/lib/types/elements/adw-icon.d.ts +25 -0
  36. package/lib/types/elements/adw-inline-view-switcher.d.ts +73 -11
  37. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  38. package/lib/types/elements/adw-navigation-split-view.d.ts +48 -0
  39. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  40. package/lib/types/elements/adw-overlay-split-view.d.ts +94 -0
  41. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  42. package/lib/types/elements/adw-popover.d.ts +58 -0
  43. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  44. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  45. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  46. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  47. package/lib/types/elements/adw-spinner.d.ts +23 -0
  48. package/lib/types/elements/adw-split-button.d.ts +53 -7
  49. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  50. package/lib/types/elements/adw-switch.d.ts +15 -0
  51. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  52. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  53. package/lib/types/elements/adw-view-stack.d.ts +39 -19
  54. package/lib/types/elements/adw-view-switcher-bar.d.ts +25 -7
  55. package/lib/types/elements/adw-view-switcher.d.ts +76 -13
  56. package/lib/types/elements/adw-window-title.d.ts +3 -1
  57. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  58. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  59. package/lib/types/entry-rows.spec.d.ts +1 -0
  60. package/lib/types/index.d.ts +7 -0
  61. package/lib/types/navigation-view.spec.d.ts +1 -0
  62. package/lib/types/preferences-search.d.ts +20 -0
  63. package/lib/types/preferences.spec.d.ts +1 -0
  64. package/lib/types/split-button.spec.d.ts +1 -0
  65. package/lib/types/split-views.spec.d.ts +1 -0
  66. package/lib/types/styles.generated.d.ts +1 -1
  67. package/lib/types/tab-view.spec.d.ts +1 -0
  68. package/lib/types/view-stack.spec.d.ts +1 -0
  69. package/lib/types/view-switcher.spec.d.ts +1 -0
  70. package/package.json +6 -6
  71. package/scss/_banner.scss +13 -0
  72. package/scss/_bottom_sheet.scss +5 -6
  73. package/scss/_button.scss +3 -26
  74. package/scss/_button_content.scss +28 -0
  75. package/scss/_checks.scss +228 -0
  76. package/scss/_clamp.scss +9 -3
  77. package/scss/_combo_row.scss +19 -0
  78. package/scss/_data_grid.scss +15 -7
  79. package/scss/_drop_down.scss +12 -55
  80. package/scss/_expander_row.scss +5 -50
  81. package/scss/_icon.scss +51 -0
  82. package/scss/_icons.generated.scss +6 -0
  83. package/scss/_inline_view_switcher.scss +34 -0
  84. package/scss/_labels.scss +135 -0
  85. package/scss/_menu_button.scss +10 -70
  86. package/scss/_navigation_split_view.scss +47 -8
  87. package/scss/_overlay_split_view.scss +117 -32
  88. package/scss/_popover.scss +186 -0
  89. package/scss/_preferences.scss +29 -0
  90. package/scss/_preferences_dialog.scss +17 -0
  91. package/scss/_progress_bar.scss +161 -0
  92. package/scss/_reset.scss +18 -7
  93. package/scss/_spinner.scss +37 -19
  94. package/scss/_split_button.scss +6 -50
  95. package/scss/_switch.scss +90 -0
  96. package/scss/_switch_row.scss +6 -52
  97. package/scss/_tab_view.scss +63 -0
  98. package/scss/_theme.scss +10 -0
  99. package/scss/_toolbar_view.scss +74 -0
  100. package/scss/_variables.scss +34 -0
  101. package/scss/_view_switcher.scss +40 -0
  102. package/scss/_view_switcher_bar.scss +37 -0
  103. package/scss/_wrap_box.scss +65 -6
  104. package/scss/adwaita-skin.scss +23 -0
  105. package/src/adw-about-dialog.spec.ts +350 -0
  106. package/src/adw-action-rows.spec.ts +342 -0
  107. package/src/adw-avatar.spec.ts +176 -0
  108. package/src/adw-banner.spec.ts +152 -0
  109. package/src/adw-button-content.spec.ts +207 -0
  110. package/src/adw-button-row.spec.ts +12 -6
  111. package/src/adw-carousel.spec.ts +389 -0
  112. package/src/adw-checks.spec.ts +368 -0
  113. package/src/adw-drop-down.spec.ts +123 -0
  114. package/src/adw-header-bar.spec.ts +83 -0
  115. package/src/adw-icon.spec.ts +167 -0
  116. package/src/adw-progress-bar.spec.ts +173 -0
  117. package/src/adw-row-state.spec.ts +49 -0
  118. package/src/adw-sidebar.spec.ts +332 -0
  119. package/src/adw-switch.spec.ts +242 -0
  120. package/src/adw-tab-view.spec.ts +12 -3
  121. package/src/adw-view-switcher-bar.spec.ts +61 -13
  122. package/src/adw-wrap-box.spec.ts +341 -0
  123. package/src/bottom-sheet.spec.ts +325 -0
  124. package/src/breakpoints.spec.ts +147 -0
  125. package/src/breakpoints.ts +67 -0
  126. package/src/chrome.spec.ts +400 -0
  127. package/src/elements/adw-about-dialog.ts +223 -163
  128. package/src/elements/adw-action-row.ts +131 -11
  129. package/src/elements/adw-avatar.ts +48 -54
  130. package/src/elements/adw-banner.ts +46 -10
  131. package/src/elements/adw-bottom-sheet.ts +145 -60
  132. package/src/elements/adw-button-content.ts +107 -33
  133. package/src/elements/adw-button-row.ts +61 -37
  134. package/src/elements/adw-button.ts +5 -7
  135. package/src/elements/adw-carousel.ts +467 -105
  136. package/src/elements/adw-checks.ts +299 -0
  137. package/src/elements/adw-clamp.ts +80 -10
  138. package/src/elements/adw-combo-row.ts +32 -7
  139. package/src/elements/adw-data-grid.ts +43 -91
  140. package/src/elements/adw-drop-down.ts +117 -128
  141. package/src/elements/adw-entry-row.ts +404 -36
  142. package/src/elements/adw-expander-row.ts +61 -29
  143. package/src/elements/adw-header-bar.ts +31 -6
  144. package/src/elements/adw-icon.ts +148 -0
  145. package/src/elements/adw-inline-view-switcher.ts +325 -131
  146. package/src/elements/adw-menu-button.ts +93 -70
  147. package/src/elements/adw-navigation-split-view.ts +220 -14
  148. package/src/elements/adw-navigation-view.ts +286 -148
  149. package/src/elements/adw-overlay-split-view.ts +536 -28
  150. package/src/elements/adw-password-entry-row.ts +111 -89
  151. package/src/elements/adw-popover.ts +283 -0
  152. package/src/elements/adw-preferences-dialog.ts +120 -21
  153. package/src/elements/adw-preferences-group.ts +128 -22
  154. package/src/elements/adw-progress-bar.ts +189 -0
  155. package/src/elements/adw-sidebar.ts +301 -142
  156. package/src/elements/adw-spin-row.ts +11 -5
  157. package/src/elements/adw-spinner.ts +168 -9
  158. package/src/elements/adw-split-button.ts +371 -92
  159. package/src/elements/adw-status-page.ts +9 -9
  160. package/src/elements/adw-switch-row.ts +100 -30
  161. package/src/elements/adw-switch.ts +129 -0
  162. package/src/elements/adw-tab-view.ts +700 -129
  163. package/src/elements/adw-toggle-group.ts +5 -7
  164. package/src/elements/adw-toolbar-view.ts +84 -21
  165. package/src/elements/adw-view-stack.ts +183 -85
  166. package/src/elements/adw-view-switcher-bar.ts +171 -81
  167. package/src/elements/adw-view-switcher.ts +328 -117
  168. package/src/elements/adw-window-title.ts +44 -11
  169. package/src/elements/adw-wrap-box.ts +329 -78
  170. package/src/elements/view-switcher-dom.ts +108 -0
  171. package/src/entry-rows.spec.ts +391 -0
  172. package/src/index.ts +23 -0
  173. package/src/navigation-view.spec.ts +435 -0
  174. package/src/preferences-search.ts +135 -0
  175. package/src/preferences.spec.ts +250 -0
  176. package/src/source-view/adw-source-view.ts +2 -5
  177. package/src/split-button.spec.ts +470 -0
  178. package/src/split-views.spec.ts +893 -0
  179. package/src/styles.generated.ts +1 -1
  180. package/src/tab-view.spec.ts +451 -0
  181. package/src/test.browser.mts +49 -0
  182. package/src/view-stack.spec.ts +272 -0
  183. package/src/view-switcher.spec.ts +665 -0
@@ -51,46 +51,36 @@
51
51
  // Responsive: the grid scrolls horizontally within its OWN container on narrow
52
52
  // viewports (it never overflows the page); it sits inside an <adw-card>/boxed
53
53
  // surface and follows light + dark via the adwaita-web CSS variables.
54
+ // The derivations are HEADLESS and live in `@gjsify/adwaita-core` (ADR 0004):
55
+ // the column tracks, the cell classes, the two normalisers, the cell text and
56
+ // the activation rule. `@gjsify/adwaita-nativescript` renders the same widget
57
+ // over `GridLayout` `ItemSpec`s — NativeScript has no subgrid — so the TRACK
58
+ // rule is emitted as a renderer-neutral descriptor and only the mapping onto
59
+ // `grid-template-columns` stays here. `@gjsify/adwaita-core/conformance`'s
60
+ // `DATA_GRID_*_VECTORS` is the table both ports are held to.
54
61
  // Reference: Gtk.Grid usage in the Buchhaltung BWA view (native financial grid).
55
62
  // Reference: refs/libadwaita/src/stylesheet/_colors.scss (separator / card tokens).
56
63
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
57
64
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
58
65
 
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']);
66
+ import {
67
+ dataGridCellText,
68
+ dataGridColumnClasses,
69
+ dataGridRowInteractive,
70
+ dataGridTrackTemplate,
71
+ dataGridTracks,
72
+ normalizeDataGridColumns,
73
+ normalizeDataGridVariant,
74
+ } from '@gjsify/adwaita-core';
75
+ import type { AdwDataGridColumn, AdwDataGridRow } from '@gjsify/adwaita-core';
76
+
77
+ export type {
78
+ AdwDataGridAlign,
79
+ AdwDataGridCellValue,
80
+ AdwDataGridColumn,
81
+ AdwDataGridRow,
82
+ AdwDataGridRowVariant,
83
+ } from '@gjsify/adwaita-core';
94
84
 
95
85
  export class AdwDataGrid extends HTMLElement {
96
86
  private _captionEl!: HTMLDivElement;
@@ -110,7 +100,7 @@ export class AdwDataGrid extends HTMLElement {
110
100
  }
111
101
 
112
102
  set columns(value: ReadonlyArray<AdwDataGridColumn>) {
113
- this._columns = normalizeColumns(value);
103
+ this._columns = normalizeDataGridColumns(value);
114
104
  if (this._initialized) this._render();
115
105
  }
116
106
 
@@ -163,7 +153,7 @@ export class AdwDataGrid extends HTMLElement {
163
153
  this._scrollEl.appendChild(this._tableEl);
164
154
 
165
155
  // Seed from JSON attributes only when the properties were not set.
166
- if (this._columns.length === 0) this._columns = this._parseJsonAttr('columns', normalizeColumns);
156
+ if (this._columns.length === 0) this._columns = this._parseJsonAttr('columns', normalizeDataGridColumns);
167
157
  if (this._rows.length === 0) this._rows = this._parseJsonAttr('rows', (v) => (Array.isArray(v) ? v : []));
168
158
 
169
159
  this.replaceChildren(this._captionEl, this._scrollEl);
@@ -173,7 +163,7 @@ export class AdwDataGrid extends HTMLElement {
173
163
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
174
164
  if (!this._initialized) return;
175
165
  if (name === 'columns') {
176
- this._columns = this._parseJsonAttr('columns', normalizeColumns);
166
+ this._columns = this._parseJsonAttr('columns', normalizeDataGridColumns);
177
167
  this._render();
178
168
  } else if (name === 'rows') {
179
169
  this._rows = this._parseJsonAttr('rows', (v) => (Array.isArray(v) ? (v as AdwDataGridRow[]) : []));
@@ -204,28 +194,21 @@ export class AdwDataGrid extends HTMLElement {
204
194
  this._captionEl.hidden = caption.length === 0;
205
195
  }
206
196
 
207
- /** The CSS `grid-template-columns` value derived from the column descriptors. */
197
+ /**
198
+ * The CSS `grid-template-columns` value derived from the column descriptors.
199
+ *
200
+ * The DERIVATION is core's (`dataGridTracks`), because NativeScript needs the
201
+ * same one for its `ItemSpec`s and can do nothing with a CSS string; the
202
+ * mapping onto CSS text — including the `minmax(0px, 1fr)` slack track whose
203
+ * `0px` must keep its unit to round-trip through the CSSOM — is
204
+ * `dataGridTrackTemplate`.
205
+ */
208
206
  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(' ');
207
+ return dataGridTrackTemplate(dataGridTracks(this._columns));
221
208
  }
222
209
 
223
210
  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(' ');
211
+ return dataGridColumnClasses(column).join(' ');
229
212
  }
230
213
 
231
214
  private _render(): void {
@@ -253,7 +236,7 @@ export class AdwDataGrid extends HTMLElement {
253
236
  const elementInteractive = this.hasAttribute('interactive');
254
237
 
255
238
  this._rows.forEach((rowData, index) => {
256
- const variant = normalizeVariant(rowData.variant);
239
+ const variant = normalizeDataGridVariant(rowData.variant);
257
240
  const row = document.createElement('div');
258
241
  row.className = `adw-data-grid-row variant-${variant}`;
259
242
  row.setAttribute('role', 'row');
@@ -266,25 +249,21 @@ export class AdwDataGrid extends HTMLElement {
266
249
  cell.className = 'adw-data-grid-cell align-start section-cell';
267
250
  cell.setAttribute('role', 'cell');
268
251
  cell.style.gridColumn = '1 / -1';
269
- cell.textContent = cellText(rowData[firstKey]);
252
+ cell.textContent = dataGridCellText(rowData[firstKey]);
270
253
  row.appendChild(cell);
271
254
  } else {
272
255
  for (const column of this._columns) {
273
256
  const cell = document.createElement('div');
274
257
  cell.className = this._columnClasses(column);
275
258
  cell.setAttribute('role', 'cell');
276
- cell.textContent = cellText(rowData[column.key]);
259
+ cell.textContent = dataGridCellText(rowData[column.key]);
277
260
  row.appendChild(cell);
278
261
  }
279
262
  }
280
263
 
281
264
  // A row is clickable only when it is a plain data row AND either the
282
265
  // 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) {
266
+ if (dataGridRowInteractive(variant, rowData.interactive, elementInteractive)) {
288
267
  row.classList.add('interactive');
289
268
  row.tabIndex = 0;
290
269
  row.addEventListener('click', () => this._activate(index, rowData));
@@ -305,31 +284,4 @@ export class AdwDataGrid extends HTMLElement {
305
284
  }
306
285
  }
307
286
 
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
287
  customElements.define('adw-data-grid', AdwDataGrid);
@@ -12,6 +12,21 @@
12
12
  // Escape and supports arrow-key navigation, Home/End, Enter to pick, and
13
13
  // type-ahead (typing jumps to the next matching option).
14
14
  //
15
+ // The popover is `<adw-popover>` and the dismissal/keyboard machine is
16
+ // `@gjsify/adwaita-core`'s (ADR 0004). So is the SELECTION: the options list,
17
+ // the index↔value mapping, the bounds guards and the
18
+ // programmatic-vs-interactive split are {@link ComboState}, the same state
19
+ // machine `<adw-combo-row>` composes — this element imported NOTHING from core
20
+ // and re-derived all of it, which is how the two selectors ended up accepting
21
+ // different option vocabularies (this one took `{value,label}` descriptors, the
22
+ // row only bare strings). What stays here is what is genuinely a DROP-DOWN: the
23
+ // search filter and the type-ahead buffer.
24
+ //
25
+ // The arrow/Home/End wrap arithmetic left earlier, with the popover lift — it
26
+ // was a second copy of `<adw-menu-button>`'s, and both had the same bug (ArrowUp
27
+ // from an unfocused list landed on the SECOND-TO-LAST option, which
28
+ // `enable-search` hit on every single open because it focuses the entry).
29
+ //
15
30
  // Attributes:
16
31
  // options / items — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
17
32
  // selected — the selected index (number).
@@ -28,72 +43,78 @@
28
43
  // `notify::selected` (CustomEvent, bubbles, detail = { selected }) — the index
29
44
  // changed (mirrors the Gtk.DropDown `notify::selected` GObject signal).
30
45
  // `change` (CustomEvent, bubbles, detail = { index, value, label }) —
31
- // a convenience event carrying the full selection.
46
+ // a convenience event carrying the full selection. Fires ONLY on a user
47
+ // pick — `ComboState`'s `interactive` flag is what tells the two apart.
32
48
  // Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss (popover surface)
33
49
  // Reference: refs/adwaita-web (adw-combo-row dropdown-arrow mask technique)
34
50
  // Copyright (c) GNOME contributors (libadwaita) / the GTK team. LGPLv2.1+.
35
51
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
36
52
 
37
- /** A dropdown option — `value` is the stable id, `label` the shown text. */
38
- export interface AdwDropDownOption {
39
- value: string;
40
- label: string;
41
- }
42
-
43
- type RawOption = string | { value?: unknown; label?: unknown };
53
+ // SIDE-EFFECT import, deliberately separate from the type import below: it is
54
+ // what guarantees `adw-popover` is defined before this module's own
55
+ // `customElements.define` can upgrade a server-rendered `<adw-drop-down>` and
56
+ // build one. A combined `import { AdwPopover }` would NOT do it — the binding is
57
+ // only ever used in type position here, and this package compiles without
58
+ // `verbatimModuleSyntax`, so TypeScript would elide the whole statement and take
59
+ // the registration with it.
60
+ import './adw-popover.js';
61
+ import type { AdwPopover } from './adw-popover.js';
62
+
63
+ import { ComboState, normalizeComboOptions } from '@gjsify/adwaita-core';
64
+ import type { AdwComboOption, AdwComboOptionInput } from '@gjsify/adwaita-core';
65
+
66
+ import { createAdwIcon } from './adw-icon.js';
67
+
68
+ /**
69
+ * A dropdown option — `value` is the stable id, `label` the shown text.
70
+ *
71
+ * An alias of core's {@link AdwComboOption}: the drop-down and the combo row are
72
+ * the same selection model behind two chromes, and they had two names for one
73
+ * descriptor. Kept exported so the published type name does not break.
74
+ */
75
+ export type AdwDropDownOption = AdwComboOption;
44
76
 
45
77
  export class AdwDropDown extends HTMLElement {
46
78
  private _buttonEl!: HTMLButtonElement;
47
79
  private _labelEl!: HTMLSpanElement;
48
- private _popoverEl!: HTMLDivElement;
80
+ private _popoverEl!: AdwPopover;
49
81
  private _listEl!: HTMLDivElement;
50
82
  private _searchEl: HTMLInputElement | null = null;
51
- private _options: AdwDropDownOption[] = [];
52
83
  private _itemButtons: HTMLButtonElement[] = [];
53
- private _selected = 0;
54
- private _active = false;
84
+ /** The headless options list + selectedIndex↔selectedValue machine (ADR 0004). */
85
+ private readonly _state = new ComboState();
55
86
  private _initialized = false;
56
87
  private _typeAheadBuffer = '';
57
88
  private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
58
89
 
59
- private _onDocumentPointerDown = (event: Event): void => {
60
- if (!this.contains(event.target as Node)) this._setActive(false);
61
- };
62
- private _onDocumentKeyDown = (event: KeyboardEvent): void => {
63
- if (event.key === 'Escape') {
64
- event.stopPropagation();
65
- this._setActive(false);
66
- this._buttonEl.focus();
67
- }
68
- };
69
-
70
90
  static get observedAttributes() {
71
91
  return ['options', 'items', 'selected', 'enable-search', 'disabled'];
72
92
  }
73
93
 
74
94
  /** The options list. Setting it rebuilds the popover and clamps the selection. */
75
95
  get options(): AdwDropDownOption[] {
76
- return this._options;
96
+ return this._state.options;
77
97
  }
78
98
 
79
- set options(value: ReadonlyArray<RawOption>) {
80
- this._options = normalizeOptions(value);
81
- if (this._selected >= this._options.length) this._selected = 0;
99
+ set options(value: ReadonlyArray<AdwComboOptionInput>) {
100
+ // `setOptions` resets the index to 0 when it no longer fits — the same
101
+ // clamp this element used to spell out.
102
+ this._state.setOptions(normalizeComboOptions(value));
82
103
  if (this._initialized) this._renderAll();
83
104
  }
84
105
 
85
106
  /** Alias of `options` (Gtk.DropDown's model is often called "items"). */
86
107
  get items(): AdwDropDownOption[] {
87
- return this._options;
108
+ return this._state.options;
88
109
  }
89
110
 
90
- set items(value: ReadonlyArray<RawOption>) {
111
+ set items(value: ReadonlyArray<AdwComboOptionInput>) {
91
112
  this.options = value;
92
113
  }
93
114
 
94
115
  /** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
95
116
  get selected(): number {
96
- return this._selected;
117
+ return this._state.selectedIndex;
97
118
  }
98
119
 
99
120
  set selected(value: number) {
@@ -102,17 +123,17 @@ export class AdwDropDown extends HTMLElement {
102
123
 
103
124
  /** The selected option's value, or '' when empty (Gtk.DropDown:selected-item). */
104
125
  get selectedValue(): string {
105
- return this._options[this._selected]?.value ?? '';
126
+ return this._state.selectedValue;
106
127
  }
107
128
 
108
129
  set selectedValue(value: string) {
109
- const index = this._options.findIndex((o) => o.value === value);
130
+ const index = this._state.options.findIndex((o) => o.value === value);
110
131
  if (index >= 0) this._selectIndex(index);
111
132
  }
112
133
 
113
134
  /** The selected option descriptor, or null when empty. */
114
135
  get selectedItem(): AdwDropDownOption | null {
115
- return this._options[this._selected] ?? null;
136
+ return this._state.options[this._state.selectedIndex] ?? null;
116
137
  }
117
138
 
118
139
  /** Whether the search entry is shown in the popover. */
@@ -127,7 +148,7 @@ export class AdwDropDown extends HTMLElement {
127
148
 
128
149
  /** Whether the popover is currently open. */
129
150
  get active(): boolean {
130
- return this._active;
151
+ return this._popoverEl?.open ?? false;
131
152
  }
132
153
 
133
154
  connectedCallback() {
@@ -145,48 +166,48 @@ export class AdwDropDown extends HTMLElement {
145
166
  this._labelEl = document.createElement('span');
146
167
  this._labelEl.className = 'adw-drop-down-label';
147
168
 
148
- const arrow = document.createElement('span');
149
- arrow.className = 'adw-icon adw-icon--go-down adw-drop-down-arrow';
150
- arrow.setAttribute('aria-hidden', 'true');
151
-
152
- this._buttonEl.append(this._labelEl, arrow);
169
+ this._buttonEl.append(this._labelEl, createAdwIcon('go-down', 'adw-drop-down-arrow'));
153
170
  this._buttonEl.addEventListener('click', () => {
154
171
  if (this.hasAttribute('disabled')) return;
155
- this._setActive(!this._active);
172
+ if (!this._popoverEl.open && this._state.count === 0) return;
173
+ this._popoverEl.open = !this._popoverEl.open;
156
174
  });
157
175
  this._buttonEl.addEventListener('keydown', (event) => this._onButtonKeyDown(event));
158
176
 
159
- this._popoverEl = document.createElement('div');
160
- this._popoverEl.className = 'adw-drop-down-popover';
177
+ this._popoverEl = document.createElement('adw-popover') as AdwPopover;
178
+ this._popoverEl.classList.add('adw-drop-down-popover');
161
179
  this._popoverEl.setAttribute('role', 'listbox');
162
- this._popoverEl.hidden = true;
163
- this._popoverEl.addEventListener('keydown', (event) => this._onPopoverKeyDown(event));
180
+ // A dropdown's popover is a `popover.menu` too — `dropdown { popover.menu
181
+ // { … } }` (_dropdowns.scss:22) — even though its rows are ARIA `option`s
182
+ // rather than `menuitem`s. The style class and the role answer different
183
+ // questions, so neither is derived from the other.
184
+ this._popoverEl.setAttribute('menu', '');
164
185
 
165
186
  this._listEl = document.createElement('div');
166
187
  this._listEl.className = 'adw-drop-down-list';
167
188
  this._popoverEl.appendChild(this._listEl);
168
189
 
169
- // Seed options from the property (already set) or the JSON attribute.
170
- if (this._options.length === 0) this._options = this._parseOptionsAttr();
190
+ // Seed options from the property (already set) or the JSON attribute,
191
+ // BEFORE anything subscribes, so building the initial DOM below is not
192
+ // driven by a change notification.
193
+ if (this._state.count === 0) this._state.setOptions(this._parseOptionsAttr());
171
194
  const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
172
- if (!Number.isNaN(attrSelected) && attrSelected >= 0 && attrSelected < this._options.length) {
173
- this._selected = attrSelected;
174
- }
195
+ if (this._state.hasIndex(attrSelected)) this._state.setSelectedIndex(attrSelected);
175
196
 
176
197
  this.replaceChildren(this._buttonEl, this._popoverEl);
198
+ this._popoverEl.anchor = this._buttonEl;
199
+ this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
200
+ // Type-ahead is the ONE key class the shared popover deliberately leaves
201
+ // to the renderer (`resolvePopoverKey` returns 'none' for printables), so
202
+ // it needs a listener of its own alongside the popover's.
203
+ this._popoverEl.addEventListener('keydown', (event) => this._onPopoverTypeAhead(event));
177
204
  this._renderAll();
178
205
  }
179
206
 
180
- disconnectedCallback() {
181
- document.removeEventListener('pointerdown', this._onDocumentPointerDown);
182
- document.removeEventListener('keydown', this._onDocumentKeyDown, true);
183
- }
184
-
185
207
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
186
208
  if (!this._initialized || !this._buttonEl) return;
187
209
  if (name === 'options' || name === 'items') {
188
- this._options = this._parseOptionsAttr();
189
- if (this._selected >= this._options.length) this._selected = 0;
210
+ this._state.setOptions(this._parseOptionsAttr());
190
211
  this._renderAll();
191
212
  } else if (name === 'selected') {
192
213
  const index = Number.parseInt(value ?? '', 10);
@@ -195,7 +216,7 @@ export class AdwDropDown extends HTMLElement {
195
216
  this._renderPopover();
196
217
  } else if (name === 'disabled') {
197
218
  this._buttonEl.disabled = this.hasAttribute('disabled');
198
- if (this.hasAttribute('disabled')) this._setActive(false);
219
+ if (this.hasAttribute('disabled')) this._popoverEl.popdown();
199
220
  }
200
221
  }
201
222
 
@@ -205,17 +226,24 @@ export class AdwDropDown extends HTMLElement {
205
226
  const raw = this.getAttribute('options') ?? this.getAttribute('items');
206
227
  if (!raw) return [];
207
228
  try {
208
- const parsed: unknown = JSON.parse(raw);
209
- return Array.isArray(parsed) ? normalizeOptions(parsed as RawOption[]) : [];
229
+ // `normalizeComboOptions` already guards a non-array, so a JSON
230
+ // object or scalar yields the empty model rather than a throw.
231
+ return normalizeComboOptions(JSON.parse(raw) as AdwComboOptionInput[]);
210
232
  } catch {
211
233
  return [];
212
234
  }
213
235
  }
214
236
 
215
237
  private _selectIndex(index: number, opts: { fromAttr?: boolean; fromUser?: boolean } = {}): void {
216
- if (!Number.isFinite(index) || index < 0 || index >= this._options.length) return;
217
- const changed = index !== this._selected;
218
- this._selected = index;
238
+ // The bounds arithmetic is `ComboState`'s; the POLICY of rejecting an
239
+ // out-of-range set is this element's published `selected` contract, and
240
+ // `<adw-combo-row>` deliberately answers it the other way — see
241
+ // `ComboState.hasIndex`.
242
+ if (!this._state.hasIndex(index)) return;
243
+ // Both core setters return "did it change", which is what gates the
244
+ // notify — no second copy of the current index is kept here to compare
245
+ // against, and `setOptions`' own label re-sync cannot masquerade as one.
246
+ const changed = opts.fromUser ? this._state.select(index) : this._state.setSelectedIndex(index);
219
247
  this._updateLabel();
220
248
  this._updateSelectedStates();
221
249
  // Keep the attribute in sync without re-entering the guarded callback.
@@ -223,7 +251,7 @@ export class AdwDropDown extends HTMLElement {
223
251
  this.setAttribute('selected', String(index));
224
252
  }
225
253
  if (changed) {
226
- const option = this._options[index];
254
+ const option = this._state.options[index];
227
255
  // `notify::selected` mirrors GObject property-notify: it fires on EVERY change,
228
256
  // programmatic included.
229
257
  this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
@@ -242,27 +270,17 @@ export class AdwDropDown extends HTMLElement {
242
270
  }
243
271
  }
244
272
 
245
- private _setActive(active: boolean): void {
246
- if (this._active === active) return;
247
- if (active && (this._options.length === 0 || this.hasAttribute('disabled'))) return;
248
- this._active = active;
249
- this._popoverEl.hidden = !active;
250
- this.classList.toggle('active', active);
251
- this._buttonEl.setAttribute('aria-expanded', String(active));
252
- if (active) {
253
- this._filter('');
254
- document.addEventListener('pointerdown', this._onDocumentPointerDown);
255
- // Capture Escape before it reaches the button/popover handlers.
256
- document.addEventListener('keydown', this._onDocumentKeyDown, true);
257
- if (this._searchEl) {
258
- this._searchEl.value = '';
259
- this._searchEl.focus();
260
- } else {
261
- (this._visibleItems()[this._selected] ?? this._visibleItems()[0])?.focus();
262
- }
273
+ private _onPopoverToggled(open: boolean): void {
274
+ this.classList.toggle('active', open);
275
+ this._buttonEl.setAttribute('aria-expanded', String(open));
276
+ if (!open) return;
277
+ this._filter('');
278
+ if (this._searchEl) {
279
+ this._searchEl.value = '';
280
+ this._searchEl.focus();
263
281
  } else {
264
- document.removeEventListener('pointerdown', this._onDocumentPointerDown);
265
- document.removeEventListener('keydown', this._onDocumentKeyDown, true);
282
+ const visible = this._visibleItems();
283
+ (visible[this._state.selectedIndex] ?? visible[0])?.focus();
266
284
  }
267
285
  }
268
286
 
@@ -270,35 +288,18 @@ export class AdwDropDown extends HTMLElement {
270
288
  if (this.hasAttribute('disabled')) return;
271
289
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
272
290
  event.preventDefault();
273
- this._setActive(true);
274
- } else if (event.key.length === 1 && !this._active) {
291
+ if (this._state.count > 0) this._popoverEl.popup();
292
+ } else if (event.key.length === 1 && !this._popoverEl.open) {
275
293
  // Type-ahead directly on the closed button (Gtk.DropDown behaviour).
276
294
  this._typeAhead(event.key);
277
295
  }
278
296
  }
279
297
 
280
- private _onPopoverKeyDown(event: KeyboardEvent): void {
281
- const items = this._visibleItems();
282
- if (items.length === 0) return;
283
- const current = items.indexOf(document.activeElement as HTMLButtonElement);
284
- if (event.key === 'ArrowDown') {
285
- event.preventDefault();
286
- items[(current + 1 + items.length) % items.length]?.focus();
287
- } else if (event.key === 'ArrowUp') {
288
- event.preventDefault();
289
- items[(current - 1 + items.length) % items.length]?.focus();
290
- } else if (event.key === 'Home') {
291
- event.preventDefault();
292
- items[0]?.focus();
293
- } else if (event.key === 'End') {
294
- event.preventDefault();
295
- items[items.length - 1]?.focus();
296
- } else if ((event.key === 'Enter' || event.key === ' ') && current >= 0) {
297
- event.preventDefault();
298
- items[current]?.click();
299
- } else if (!this._searchEl && event.key.length === 1) {
300
- this._typeAhead(event.key);
301
- }
298
+ /** Printable keys inside an OPEN popover, when there is no search entry to type into. */
299
+ private _onPopoverTypeAhead(event: KeyboardEvent): void {
300
+ if (this._searchEl) return;
301
+ if (event.key.length !== 1) return;
302
+ this._typeAhead(event.key);
302
303
  }
303
304
 
304
305
  /** Type-ahead: accumulate keys, focus/select the next matching option. */
@@ -306,9 +307,9 @@ export class AdwDropDown extends HTMLElement {
306
307
  this._typeAheadBuffer += char.toLowerCase();
307
308
  if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
308
309
  this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
309
- const match = this._options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
310
+ const match = this._state.options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
310
311
  if (match < 0) return;
311
- if (this._active) this._itemButtons[match]?.focus();
312
+ if (this._popoverEl.open) this._itemButtons[match]?.focus();
312
313
  else this._selectIndex(match, { fromUser: true });
313
314
  }
314
315
 
@@ -316,7 +317,7 @@ export class AdwDropDown extends HTMLElement {
316
317
  private _filter(query: string): void {
317
318
  const q = query.trim().toLowerCase();
318
319
  this._itemButtons.forEach((button, index) => {
319
- const match = q.length === 0 || (this._options[index]?.label.toLowerCase().includes(q) ?? false);
320
+ const match = q.length === 0 || (this._state.options[index]?.label.toLowerCase().includes(q) ?? false);
320
321
  button.hidden = !match;
321
322
  });
322
323
  }
@@ -328,7 +329,6 @@ export class AdwDropDown extends HTMLElement {
328
329
  private _renderAll(): void {
329
330
  this._buttonEl.disabled = this.hasAttribute('disabled');
330
331
  this.classList.toggle('disabled', this.hasAttribute('disabled'));
331
- if (this._selected >= this._options.length) this._selected = 0;
332
332
  this._renderPopover();
333
333
  this._updateLabel();
334
334
  }
@@ -337,12 +337,12 @@ export class AdwDropDown extends HTMLElement {
337
337
  // The setters (`options`/`selected`) may run before connectedCallback
338
338
  // builds the DOM — stay a no-op until the label element exists.
339
339
  if (!this._labelEl) return;
340
- this._labelEl.textContent = this._options[this._selected]?.label ?? '';
340
+ this._labelEl.textContent = this._state.selectedLabel;
341
341
  }
342
342
 
343
343
  private _updateSelectedStates(): void {
344
344
  this._itemButtons.forEach((button, index) => {
345
- const isSelected = index === this._selected;
345
+ const isSelected = index === this._state.selectedIndex;
346
346
  button.classList.toggle('selected', isSelected);
347
347
  button.setAttribute('aria-selected', String(isSelected));
348
348
  });
@@ -367,10 +367,10 @@ export class AdwDropDown extends HTMLElement {
367
367
  if (existing) existing.remove();
368
368
  }
369
369
 
370
- for (const [index, option] of this._options.entries()) {
370
+ for (const [index, option] of this._state.options.entries()) {
371
371
  const item = document.createElement('button');
372
372
  item.type = 'button';
373
- item.className = 'adw-drop-down-item';
373
+ item.className = 'adw-popover-item adw-drop-down-item';
374
374
  item.setAttribute('role', 'option');
375
375
  item.tabIndex = -1;
376
376
 
@@ -386,7 +386,7 @@ export class AdwDropDown extends HTMLElement {
386
386
  item.append(label, check);
387
387
  item.addEventListener('click', () => {
388
388
  this._selectIndex(index, { fromUser: true });
389
- this._setActive(false);
389
+ this._popoverEl.popdown();
390
390
  this._buttonEl.focus();
391
391
  });
392
392
  this._listEl.appendChild(item);
@@ -394,19 +394,8 @@ export class AdwDropDown extends HTMLElement {
394
394
  }
395
395
 
396
396
  this._updateSelectedStates();
397
- if (this._options.length === 0) this._setActive(false);
397
+ if (this._state.count === 0) this._popoverEl.popdown();
398
398
  }
399
399
  }
400
400
 
401
- /** Normalise raw options (strings or `{value,label}`) to a stable descriptor list. */
402
- function normalizeOptions(raw: ReadonlyArray<RawOption>): AdwDropDownOption[] {
403
- if (!Array.isArray(raw)) return [];
404
- return raw.map((entry) => {
405
- if (typeof entry === 'string') return { value: entry, label: entry };
406
- const value = entry.value !== undefined ? String(entry.value) : String(entry.label ?? '');
407
- const label = entry.label !== undefined ? String(entry.label) : value;
408
- return { value, label };
409
- });
410
- }
411
-
412
401
  customElements.define('adw-drop-down', AdwDropDown);