@gjsify/adwaita-web 0.31.0 → 0.32.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 (176) hide show
  1. package/dist/adwaita-web.css +786 -272
  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-data-grid.d.ts +11 -31
  30. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  31. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  32. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  33. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  34. package/lib/types/elements/adw-icon.d.ts +25 -0
  35. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -11
  36. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  37. package/lib/types/elements/adw-navigation-split-view.d.ts +6 -0
  38. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  39. package/lib/types/elements/adw-overlay-split-view.d.ts +38 -0
  40. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  41. package/lib/types/elements/adw-popover.d.ts +58 -0
  42. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  43. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  44. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  45. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  46. package/lib/types/elements/adw-split-button.d.ts +53 -7
  47. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  48. package/lib/types/elements/adw-switch.d.ts +15 -0
  49. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  50. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  51. package/lib/types/elements/adw-view-stack.d.ts +27 -19
  52. package/lib/types/elements/adw-view-switcher-bar.d.ts +23 -7
  53. package/lib/types/elements/adw-view-switcher.d.ts +22 -11
  54. package/lib/types/elements/adw-window-title.d.ts +3 -1
  55. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  56. package/lib/types/entry-rows.spec.d.ts +1 -0
  57. package/lib/types/index.d.ts +7 -0
  58. package/lib/types/navigation-view.spec.d.ts +1 -0
  59. package/lib/types/preferences-search.d.ts +20 -0
  60. package/lib/types/preferences.spec.d.ts +1 -0
  61. package/lib/types/split-button.spec.d.ts +1 -0
  62. package/lib/types/split-views.spec.d.ts +1 -0
  63. package/lib/types/styles.generated.d.ts +1 -1
  64. package/lib/types/tab-view.spec.d.ts +1 -0
  65. package/lib/types/view-stack.spec.d.ts +1 -0
  66. package/lib/types/view-switcher.spec.d.ts +1 -0
  67. package/package.json +6 -6
  68. package/scss/_banner.scss +13 -0
  69. package/scss/_bottom_sheet.scss +5 -6
  70. package/scss/_button.scss +3 -26
  71. package/scss/_button_content.scss +28 -0
  72. package/scss/_checks.scss +228 -0
  73. package/scss/_clamp.scss +9 -3
  74. package/scss/_data_grid.scss +15 -7
  75. package/scss/_drop_down.scss +12 -55
  76. package/scss/_expander_row.scss +5 -50
  77. package/scss/_icon.scss +51 -0
  78. package/scss/_icons.generated.scss +6 -0
  79. package/scss/_inline_view_switcher.scss +34 -0
  80. package/scss/_labels.scss +135 -0
  81. package/scss/_menu_button.scss +10 -70
  82. package/scss/_popover.scss +186 -0
  83. package/scss/_preferences.scss +29 -0
  84. package/scss/_preferences_dialog.scss +17 -0
  85. package/scss/_progress_bar.scss +161 -0
  86. package/scss/_reset.scss +18 -7
  87. package/scss/_spinner.scss +7 -3
  88. package/scss/_split_button.scss +6 -50
  89. package/scss/_switch.scss +90 -0
  90. package/scss/_switch_row.scss +6 -52
  91. package/scss/_tab_view.scss +64 -0
  92. package/scss/_theme.scss +10 -0
  93. package/scss/_toolbar_view.scss +74 -0
  94. package/scss/_variables.scss +34 -0
  95. package/scss/_view_switcher.scss +40 -0
  96. package/scss/_view_switcher_bar.scss +37 -0
  97. package/scss/_wrap_box.scss +56 -6
  98. package/scss/adwaita-skin.scss +23 -0
  99. package/src/adw-about-dialog.spec.ts +350 -0
  100. package/src/adw-action-rows.spec.ts +342 -0
  101. package/src/adw-avatar.spec.ts +139 -0
  102. package/src/adw-banner.spec.ts +152 -0
  103. package/src/adw-button-content.spec.ts +183 -0
  104. package/src/adw-button-row.spec.ts +12 -6
  105. package/src/adw-carousel.spec.ts +389 -0
  106. package/src/adw-checks.spec.ts +368 -0
  107. package/src/adw-drop-down.spec.ts +123 -0
  108. package/src/adw-header-bar.spec.ts +83 -0
  109. package/src/adw-icon.spec.ts +167 -0
  110. package/src/adw-progress-bar.spec.ts +173 -0
  111. package/src/adw-sidebar.spec.ts +332 -0
  112. package/src/adw-switch.spec.ts +242 -0
  113. package/src/adw-tab-view.spec.ts +12 -3
  114. package/src/adw-view-switcher-bar.spec.ts +61 -13
  115. package/src/adw-wrap-box.spec.ts +194 -0
  116. package/src/bottom-sheet.spec.ts +325 -0
  117. package/src/breakpoints.spec.ts +147 -0
  118. package/src/breakpoints.ts +67 -0
  119. package/src/chrome.spec.ts +301 -0
  120. package/src/elements/adw-about-dialog.ts +223 -163
  121. package/src/elements/adw-action-row.ts +120 -10
  122. package/src/elements/adw-avatar.ts +48 -54
  123. package/src/elements/adw-banner.ts +46 -10
  124. package/src/elements/adw-bottom-sheet.ts +145 -60
  125. package/src/elements/adw-button-content.ts +107 -33
  126. package/src/elements/adw-button-row.ts +61 -37
  127. package/src/elements/adw-button.ts +5 -7
  128. package/src/elements/adw-carousel.ts +467 -105
  129. package/src/elements/adw-checks.ts +299 -0
  130. package/src/elements/adw-clamp.ts +80 -10
  131. package/src/elements/adw-combo-row.ts +15 -7
  132. package/src/elements/adw-data-grid.ts +43 -91
  133. package/src/elements/adw-drop-down.ts +117 -128
  134. package/src/elements/adw-entry-row.ts +404 -36
  135. package/src/elements/adw-expander-row.ts +61 -29
  136. package/src/elements/adw-header-bar.ts +31 -6
  137. package/src/elements/adw-icon.ts +148 -0
  138. package/src/elements/adw-inline-view-switcher.ts +240 -129
  139. package/src/elements/adw-menu-button.ts +93 -70
  140. package/src/elements/adw-navigation-split-view.ts +48 -7
  141. package/src/elements/adw-navigation-view.ts +286 -148
  142. package/src/elements/adw-overlay-split-view.ts +164 -14
  143. package/src/elements/adw-password-entry-row.ts +111 -89
  144. package/src/elements/adw-popover.ts +283 -0
  145. package/src/elements/adw-preferences-dialog.ts +120 -21
  146. package/src/elements/adw-preferences-group.ts +128 -22
  147. package/src/elements/adw-progress-bar.ts +189 -0
  148. package/src/elements/adw-sidebar.ts +301 -142
  149. package/src/elements/adw-spin-row.ts +11 -5
  150. package/src/elements/adw-spinner.ts +23 -8
  151. package/src/elements/adw-split-button.ts +371 -92
  152. package/src/elements/adw-status-page.ts +9 -9
  153. package/src/elements/adw-switch-row.ts +100 -30
  154. package/src/elements/adw-switch.ts +129 -0
  155. package/src/elements/adw-tab-view.ts +682 -129
  156. package/src/elements/adw-toggle-group.ts +5 -7
  157. package/src/elements/adw-toolbar-view.ts +84 -21
  158. package/src/elements/adw-view-stack.ts +162 -85
  159. package/src/elements/adw-view-switcher-bar.ts +163 -81
  160. package/src/elements/adw-view-switcher.ts +231 -112
  161. package/src/elements/adw-window-title.ts +44 -11
  162. package/src/elements/adw-wrap-box.ts +131 -35
  163. package/src/elements/view-switcher-dom.ts +108 -0
  164. package/src/entry-rows.spec.ts +391 -0
  165. package/src/index.ts +23 -0
  166. package/src/navigation-view.spec.ts +435 -0
  167. package/src/preferences-search.ts +135 -0
  168. package/src/preferences.spec.ts +250 -0
  169. package/src/source-view/adw-source-view.ts +2 -5
  170. package/src/split-button.spec.ts +470 -0
  171. package/src/split-views.spec.ts +309 -0
  172. package/src/styles.generated.ts +1 -1
  173. package/src/tab-view.spec.ts +451 -0
  174. package/src/test.browser.mts +49 -0
  175. package/src/view-stack.spec.ts +272 -0
  176. package/src/view-switcher.spec.ts +446 -0
@@ -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);