@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
@@ -1,120 +1,142 @@
1
- // <adw-password-entry-row> — A boxed-list entry row tailored for secrets: the
2
- // title floats as a label, the editable text is masked, and a trailing peek
3
- // button toggles between hiding and revealing the contents. A specialization of
4
- // <adw-entry-row> (Adw.PasswordEntryRow extends Adw.EntryRow).
5
- // Attributes: title, text, revealed (boolean — show the contents in clear text).
6
- // Property: text (get/set, proxies the inner input); revealed (get/set).
7
- // Events: `changed` (CustomEvent) on every edit — mirrors Adw.EntryRow's
8
- // `changed` signal so it drives the same code path as the GTK renderer.
9
- // `notify::revealed` (CustomEvent) when the peek toggle flips visibility.
1
+ // <adw-password-entry-row> — An entry row tailored for secrets: the value is
2
+ // masked, a trailing peek button reveals it, and a warning shows while Caps Lock
3
+ // is engaged.
4
+ //
5
+ // It now EXTENDS `<adw-entry-row>`, as `AdwPasswordEntryRow` extends
6
+ // `AdwEntryRow` in the C (adw-password-entry-row.c:50). This file used to be a
7
+ // near copy-paste of `adw-entry-row.ts` — a normalized diff showed the
8
+ // connectedCallback and attributeChangedCallback bodies were the same code — so
9
+ // every entry-row fix had to be made twice and one of the two always drifted.
10
+ // What is left here is only what the C subclass adds: the masked input, the peek
11
+ // toggle, and the caps-lock source.
12
+ //
13
+ // The reveal/caps-lock DERIVATION is HEADLESS and lives in
14
+ // `@gjsify/adwaita-core` (ADR 0004) as {@link PasswordEntryRowState}, which
15
+ // composes the parent's `EntryRowState` exactly like the C drives its
16
+ // parent through `adw_entry_row_set_show_indicator`. Two rules fall out of it
17
+ // that neither port had: peeking suppresses the caps-lock warning, and so does
18
+ // losing focus.
19
+ //
20
+ // Attributes: everything `<adw-entry-row>` observes, plus `revealed`.
21
+ // Properties: `revealed`, `capsLockOn` (+ everything inherited).
22
+ // Events: `notify::revealed` — only on a REAL change. The old implementation
23
+ // fired it on every `setAttribute`, including a redundant one, because
24
+ // attributeChangedCallback runs for a same-value write; the core's setter
25
+ // guard (the house rule at adw-entry-row.c:984/:1198/:1247) is what fixes it.
26
+ //
27
+ // Reference: refs/libadwaita/src/adw-password-entry-row.c
10
28
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/passwordentryrow.md
11
- // Reference: refs/libadwaita/src/adw-password-entry-row.c (peek toggle: the
12
- // view-reveal / view-conceal symbolic swap; CSS node `row` carries `.entry`
13
- // and `.password` style classes).
14
29
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
15
30
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
16
31
 
17
- export class AdwPasswordEntryRow extends HTMLElement {
18
- private _input!: HTMLInputElement;
19
- private _titleEl!: HTMLSpanElement;
20
- private _toggle!: HTMLButtonElement;
21
- private _toggleIcon!: HTMLSpanElement;
22
- private _initialized = false;
32
+ import { PasswordEntryRowState, type PasswordEntryRowRenderState } from '@gjsify/adwaita-core';
23
33
 
24
- static get observedAttributes() {
25
- return ['title', 'text', 'revealed'];
26
- }
34
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
35
+
36
+ import { AdwEntryRow } from './adw-entry-row.js';
37
+
38
+ export class AdwPasswordEntryRow extends AdwEntryRow {
39
+ /** The headless peek + caps-lock derivation, composing the parent row's state. */
40
+ private readonly _password = new PasswordEntryRowState(this._state);
41
+ private _toggle!: HTMLButtonElement;
42
+ private _toggleIcon!: AdwIcon;
27
43
 
28
- get text(): string {
29
- return this._input ? this._input.value : (this.getAttribute('text') ?? '');
44
+ static get observedAttributes(): string[] {
45
+ return [...AdwEntryRow.observedAttributes, 'revealed'];
30
46
  }
31
47
 
32
- set text(value: string) {
33
- if (this._input) this._input.value = value;
34
- else this.setAttribute('text', value);
48
+ // `_password_entry_row.scss` scopes its rules to `.adw-password-entry-row-*`,
49
+ // so the inherited DOM builder must name the parts that way.
50
+ protected override get _classPrefix(): string {
51
+ return 'adw-password-entry-row';
35
52
  }
36
53
 
37
- /** Whether the contents are shown in clear text (peek toggle is engaged). */
54
+ /** Whether the contents are shown in clear text (`GtkText:visibility`). */
38
55
  get revealed(): boolean {
39
- return this.hasAttribute('revealed');
56
+ return this._password.revealed;
40
57
  }
41
58
 
42
59
  set revealed(value: boolean) {
43
- if (value) this.setAttribute('revealed', '');
44
- else this.removeAttribute('revealed');
60
+ this._password.setRevealed(value);
45
61
  }
46
62
 
47
- connectedCallback() {
48
- if (this._initialized) return;
49
- this._initialized = true;
63
+ /** Whether Caps Lock is engaged, as last observed from a keyboard event. */
64
+ get capsLockOn(): boolean {
65
+ return this._password.capsLockOn;
66
+ }
50
67
 
51
- this._titleEl = document.createElement('span');
52
- this._titleEl.className = 'adw-row-title';
53
- this._titleEl.textContent = this.getAttribute('title') ?? '';
68
+ /**
69
+ * Feed the platform's Caps Lock state in — GDK gets it from the keyboard
70
+ * device (adw-password-entry-row.c:111-115). Public because a host that
71
+ * knows better than `KeyboardEvent.getModifierState` (a virtual keyboard, a
72
+ * test) can drive it directly.
73
+ */
74
+ setCapsLockOn(on: boolean): void {
75
+ this._password.setCapsLockOn(on);
76
+ }
54
77
 
55
- this._input = document.createElement('input');
56
- this._input.className = 'adw-password-entry-row-input';
57
- // Masked by default — Adw.PasswordEntryRow sets GTK_INPUT_PURPOSE_PASSWORD
58
- // and starts with visibility off.
59
- this._input.type = this.hasAttribute('revealed') ? 'text' : 'password';
60
- this._input.value = this.getAttribute('text') ?? '';
78
+ protected override _onConnected(): void {
79
+ // `gtk_text_set_visibility (…, FALSE)` + GTK_INPUT_PURPOSE_PASSWORD (C:158-160).
80
+ this._input.type = 'password';
61
81
  this._input.autocomplete = 'off';
62
- this._input.addEventListener('input', () => {
63
- this.dispatchEvent(new CustomEvent('changed', { bubbles: true, detail: { text: this._input.value } }));
64
- });
65
-
66
- // Title floats as a small label above the masked value, both left-aligned
67
- // in a column (the filled Adw.EntryRow look).
68
- const text = document.createElement('div');
69
- text.className = 'adw-password-entry-row-text';
70
- text.append(this._titleEl, this._input);
71
82
 
72
- // Trailing peek toggle — a flat icon button, vertically centred. Swaps
73
- // between the reveal / conceal symbolics like the native widget.
74
83
  this._toggle = document.createElement('button');
75
- this._toggle.className = 'adw-button flat circular icon-only adw-password-entry-row-toggle';
76
84
  this._toggle.type = 'button';
77
- this._toggleIcon = document.createElement('span');
78
- this._toggleIcon.setAttribute('aria-hidden', 'true');
79
- this._toggle.appendChild(this._toggleIcon);
80
- this._toggle.addEventListener('click', () => {
81
- this.revealed = !this.revealed;
85
+ this._toggle.className = `adw-button flat circular icon-only ${this._classPrefix}-toggle`;
86
+ this._toggleIcon = createAdwIcon(null);
87
+ this._toggle.append(this._toggleIcon);
88
+ this._toggle.addEventListener('click', () => this._password.togglePeek());
89
+ // C:152 — installed through add_suffix, so it is the FIRST suffix and a
90
+ // consumer's own suffix lands after it instead of replacing it.
91
+ this.addSuffix(this._toggle);
92
+
93
+ // GDK reads caps lock off the keyboard device; the browser exposes it
94
+ // only on keyboard events, so those are the source here.
95
+ const readCapsLock = (event: KeyboardEvent) => this._password.setCapsLockOn(event.getModifierState('CapsLock'));
96
+ this._input.addEventListener('keydown', readCapsLock);
97
+ this._input.addEventListener('keyup', readCapsLock);
98
+
99
+ // The indicator icon + tooltip are set once at init (C:169-171); their
100
+ // canonical names come from the core so both ports spell them alike.
101
+ const initial = this._password.state;
102
+ this.setIndicatorIconName(initial.indicatorIconName);
103
+ this.setIndicatorTooltip(initial.indicatorTooltip);
104
+
105
+ // Seed from the attribute BEFORE subscribing, so the first paint emits no
106
+ // `notify::revealed`.
107
+ if (this.hasAttribute('revealed')) this._password.setRevealed(this.getAttribute('revealed') !== 'false');
108
+ this._renderPassword(this._password.state);
109
+ this._password.subscribe((state) => {
110
+ this._renderPassword(state);
111
+ this.dispatchEvent(
112
+ new CustomEvent('notify::revealed', { bubbles: true, detail: { revealed: state.revealed } }),
113
+ );
82
114
  });
83
-
84
- // Trailing edit pencil (before the peek toggle), mirroring native.
85
- const edit = document.createElement('span');
86
- edit.className = 'adw-row-edit adw-icon adw-icon--document-edit';
87
- edit.setAttribute('aria-hidden', 'true');
88
- edit.addEventListener('click', () => this._input.focus());
89
-
90
- this.replaceChildren(text, edit, this._toggle);
91
- this._renderRevealed();
92
115
  }
93
116
 
94
- attributeChangedCallback(name: string, _old: string | null, value: string | null) {
95
- if (!this._initialized) return;
96
- if (name === 'title') {
97
- this._titleEl.textContent = value ?? '';
98
- } else if (name === 'text') {
99
- if (this._input.value !== (value ?? '')) this._input.value = value ?? '';
100
- } else if (name === 'revealed') {
101
- this._renderRevealed();
102
- this.dispatchEvent(
103
- new CustomEvent('notify::revealed', { bubbles: true, detail: { revealed: this.revealed } }),
104
- );
117
+ override attributeChangedCallback(name: string, oldValue: string | null, value: string | null) {
118
+ if (name !== 'revealed') {
119
+ super.attributeChangedCallback(name, oldValue, value);
120
+ return;
105
121
  }
122
+ if (!this._initialized) return;
123
+ // The core guard makes this idempotent, which is what keeps the reflection
124
+ // in `_renderPassword` from bouncing back as a second notification.
125
+ this._password.setRevealed(value !== null && value !== 'false');
106
126
  }
107
127
 
108
- private _renderRevealed() {
109
- const revealed = this.revealed;
110
- this._input.type = revealed ? 'text' : 'password';
111
- // view-conceal when revealed (offer to hide), view-reveal when masked.
112
- const icon = revealed ? 'view-conceal' : 'view-reveal';
113
- this._toggleIcon.className = `adw-icon adw-icon--${icon}`;
114
- const labelText = revealed ? 'Hide Password' : 'Show Password';
115
- this._toggle.title = labelText;
116
- this._toggle.setAttribute('aria-label', labelText);
117
- this._toggle.setAttribute('aria-pressed', String(revealed));
128
+ /** `notify_visibility_cb` (C:62-81), applied to the DOM. */
129
+ private _renderPassword(state: PasswordEntryRowRenderState): void {
130
+ this._input.type = state.revealed ? 'text' : 'password';
131
+ // The libadwaita name travels in `data-icon-name`; the mask class is the
132
+ // curated @gjsify/adwaita-icons spelling of the same symbolic.
133
+ this._toggleIcon.dataset.iconName = state.peekIconName;
134
+ this._toggleIcon.iconName = state.peekIconName;
135
+ this._toggle.title = state.peekLabel;
136
+ this._toggle.setAttribute('aria-label', state.peekLabel);
137
+ this._toggle.setAttribute('aria-pressed', String(state.revealed));
138
+ if (state.revealed) this.setAttribute('revealed', '');
139
+ else this.removeAttribute('revealed');
118
140
  }
119
141
  }
120
142
 
@@ -0,0 +1,283 @@
1
+ // <adw-popover> — the elevated surface that pops up next to an anchor, the web
2
+ // counterpart of GtkPopover as libadwaita styles it. It is the ONE popover in
3
+ // this package: `<adw-menu-button>`, `<adw-drop-down>` and `<adw-split-button>`
4
+ // each used to build their own, and no two agreed. The surface disagreed (12px /
5
+ // 12px / 9px radius against libadwaita's 15px; two correct three-layer shadows
6
+ // and one invented two-layer one) and so did the behaviour — the split button
7
+ // had neither Escape dismissal nor arrow-key navigation, while the other two
8
+ // spelled the same wrap arithmetic out twice.
9
+ //
10
+ // The dismissal + keyboard state machine lives in `@gjsify/adwaita-core`
11
+ // ({@link PopoverState}, {@link resolvePopoverKey}, ADR 0004); this element owns
12
+ // the DOM: the surface node, the document-level listeners, focus movement, and
13
+ // the `open` attribute reflection.
14
+ //
15
+ // PLACEMENT IS CSS, NOT SCRIPT. `_popover.scss` positions the surface off the
16
+ // `position` / `align` attributes; nothing here measures a rect or flips on
17
+ // overflow, exactly as the three copies never did. Core carries no positioner
18
+ // for the same reason — see the module header of `adwaita-core/src/popover.ts`.
19
+ //
20
+ // Attributes:
21
+ // open — boolean; reflects (and drives) the visible state.
22
+ // position — bottom | top | start | end (default bottom) — which side of the
23
+ // anchor the surface sits on. `GtkPopover:position`'s own
24
+ // autohide/has-arrow semantics are NOT verifiable in this tree
25
+ // (refs/gtk is empty, libadwaita vendors no adw-popover.c), so only
26
+ // the placement axis is modelled.
27
+ // align — start | end (default start) — which edge it lines up with.
28
+ // role — menu | listbox (default menu) — the ARIA role of the surface.
29
+ // An a11y fact ONLY; it does not pick the surface variant, see
30
+ // `menu` below.
31
+ // menu — boolean; libadwaita's `.menu` STYLE CLASS on the popover node
32
+ // (0 padding on the contents, `$menu_margin` on the item box —
33
+ // _menus.scss:58-66), opt-in exactly as it is in GTK.
34
+ // DO NOT infer it from `role`: `GtkDropDown`'s popover is a
35
+ // `popover.menu` too (`dropdown { popover.menu { … } }`,
36
+ // _dropdowns.scss:22) while its rows are `option`s, not
37
+ // `menuitem`s. Tying the surface to the role gets that backwards
38
+ // and pads the drop-down like a bare content popover.
39
+ // Properties:
40
+ // open — whether the popover is showing (get/set).
41
+ // anchor — the element the surface is positioned against and returns focus
42
+ // to. Defaults to `parentElement` (get/set).
43
+ // items — the navigable rows, in DOM order (get). Used for keyboard moves.
44
+ // Methods (GtkPopover's own vocabulary):
45
+ // popup() — show it. popdown() — hide it.
46
+ // Events:
47
+ // `notify::open` (CustomEvent, bubbles, detail = { open }) — mirrors the
48
+ // GObject property-notify; fires on every change, programmatic included.
49
+ // `popover-item-activated` (CustomEvent, bubbles, detail = { index }) — a row
50
+ // was chosen by keyboard. Click activation stays the row's own listener.
51
+ //
52
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_popovers.scss:7-28 (popover > contents)
53
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss:58-66, :134-156 (popover.menu, modelbutton)
54
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
55
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
56
+
57
+ import { PopoverState, resolvePopoverKey } from '@gjsify/adwaita-core';
58
+
59
+ /** Which side of the anchor the surface sits on. */
60
+ export type AdwPopoverPosition = 'bottom' | 'top' | 'start' | 'end';
61
+
62
+ /** Which edge of the anchor the surface lines up with. */
63
+ export type AdwPopoverAlign = 'start' | 'end';
64
+
65
+ /** The ARIA role of the surface — it decides the row role consumers give their items. */
66
+ export type AdwPopoverRole = 'menu' | 'listbox';
67
+
68
+ const POSITIONS: readonly AdwPopoverPosition[] = ['bottom', 'top', 'start', 'end'];
69
+ const ALIGNS: readonly AdwPopoverAlign[] = ['start', 'end'];
70
+ const ROLES: readonly AdwPopoverRole[] = ['menu', 'listbox'];
71
+
72
+ /** The row selector keyboard navigation walks. */
73
+ const ITEM_SELECTOR = '.adw-popover-item';
74
+
75
+ export class AdwPopover extends HTMLElement {
76
+ private readonly _state = new PopoverState();
77
+ private _anchor: HTMLElement | null = null;
78
+ private _initialized = false;
79
+ /** Guards the `open` attribute↔property loop, which would otherwise recurse. */
80
+ private _reflecting = false;
81
+
82
+ private _onDocumentPointerDown = (event: Event): void => {
83
+ const target = event.target as Node;
84
+ if (this.contains(target)) return;
85
+ // A click on the anchor is the anchor's own toggle — closing here too
86
+ // would close and immediately reopen (or reopen and immediately close).
87
+ if (this.anchor?.contains(target) === true) return;
88
+ this._state.dismiss();
89
+ };
90
+
91
+ private _onDocumentKeyDown = (event: KeyboardEvent): void => {
92
+ if (event.key !== 'Escape') return;
93
+ // Captured at the document, so a nested popover's Escape does not also
94
+ // close its parent.
95
+ event.stopPropagation();
96
+ this._state.dismiss();
97
+ this.anchor?.focus();
98
+ };
99
+
100
+ static get observedAttributes() {
101
+ return ['open', 'position', 'align', 'role', 'menu'];
102
+ }
103
+
104
+ /** Whether the popover is showing. */
105
+ get open(): boolean {
106
+ return this._state.open;
107
+ }
108
+
109
+ set open(value: boolean) {
110
+ if (value) this._state.popup();
111
+ else this._state.popdown();
112
+ }
113
+
114
+ /**
115
+ * The element the surface is positioned against and hands focus back to.
116
+ * Defaults to `parentElement`, which is the common case: the popover is the
117
+ * last child of the widget that owns it.
118
+ */
119
+ get anchor(): HTMLElement | null {
120
+ return this._anchor ?? this.parentElement;
121
+ }
122
+
123
+ set anchor(value: HTMLElement | null) {
124
+ this._anchor = value;
125
+ }
126
+
127
+ /** Which side of the anchor the surface sits on. */
128
+ get position(): AdwPopoverPosition {
129
+ const value = this.getAttribute('position');
130
+ return POSITIONS.includes(value as AdwPopoverPosition) ? (value as AdwPopoverPosition) : 'bottom';
131
+ }
132
+
133
+ set position(value: AdwPopoverPosition) {
134
+ this.setAttribute('position', value);
135
+ }
136
+
137
+ /** Which edge of the anchor the surface lines up with. */
138
+ get align(): AdwPopoverAlign {
139
+ const value = this.getAttribute('align');
140
+ return ALIGNS.includes(value as AdwPopoverAlign) ? (value as AdwPopoverAlign) : 'start';
141
+ }
142
+
143
+ set align(value: AdwPopoverAlign) {
144
+ this.setAttribute('align', value);
145
+ }
146
+
147
+ /** The surface's ARIA role. */
148
+ get popoverRole(): AdwPopoverRole {
149
+ const value = this.getAttribute('role');
150
+ return ROLES.includes(value as AdwPopoverRole) ? (value as AdwPopoverRole) : 'menu';
151
+ }
152
+
153
+ /** The navigable rows, in DOM order. Hidden rows are skipped — a filtered list navigates what it shows. */
154
+ get items(): HTMLElement[] {
155
+ return [...this.querySelectorAll<HTMLElement>(ITEM_SELECTOR)].filter((item) => !item.hidden);
156
+ }
157
+
158
+ /** Show the popover — `gtk_popover_popup()`. */
159
+ popup(): void {
160
+ this._state.popup();
161
+ }
162
+
163
+ /** Hide the popover — `gtk_popover_popdown()`. */
164
+ popdown(): void {
165
+ this._state.popdown();
166
+ }
167
+
168
+ /** Subscribe to open-state changes. Returns an unsubscribe function. */
169
+ subscribe(listener: (open: boolean) => void): () => void {
170
+ return this._state.subscribe((change) => listener(change.open));
171
+ }
172
+
173
+ connectedCallback() {
174
+ if (this._initialized) return;
175
+ this._initialized = true;
176
+
177
+ this.classList.add('adw-popover');
178
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'menu');
179
+ // The `menu` variant is NOT defaulted or inferred here — it is GTK's
180
+ // `.menu` style class, and the widget that owns the popover is what adds
181
+ // it, as in the C. See the attribute docs above for why role is the
182
+ // wrong thing to key it off.
183
+
184
+ this.addEventListener('keydown', (event) => this._onKeyDown(event));
185
+ this._state.subscribe((change) => this._onStateChange(change.open));
186
+
187
+ // The `open` attribute may already be present in server-rendered markup.
188
+ if (this.hasAttribute('open')) this._state.popup();
189
+ this._render();
190
+ }
191
+
192
+ disconnectedCallback() {
193
+ this._unbindDocument();
194
+ }
195
+
196
+ attributeChangedCallback(name: string, _previous: string | null, value: string | null) {
197
+ if (!this._initialized) return;
198
+ if (name === 'open') {
199
+ if (this._reflecting) return;
200
+ if (value !== null) this._state.popup();
201
+ else this._state.popdown();
202
+ return;
203
+ }
204
+ if (name === 'role' && value === null) {
205
+ // A removed role falls back to the default rather than leaving the
206
+ // surface unlabelled to assistive tech.
207
+ this.setAttribute('role', 'menu');
208
+ return;
209
+ }
210
+ this._render();
211
+ }
212
+
213
+ // ── internals ──────────────────────────────────────────────────────────
214
+
215
+ private _onStateChange(open: boolean): void {
216
+ if (open) this._bindDocument();
217
+ else this._unbindDocument();
218
+
219
+ this._reflecting = true;
220
+ if (open) this.setAttribute('open', '');
221
+ else this.removeAttribute('open');
222
+ this._reflecting = false;
223
+
224
+ this._render();
225
+ this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open } }));
226
+ }
227
+
228
+ private _bindDocument(): void {
229
+ document.addEventListener('pointerdown', this._onDocumentPointerDown);
230
+ // Capture, so Escape reaches us before a focused child swallows it.
231
+ document.addEventListener('keydown', this._onDocumentKeyDown, true);
232
+ }
233
+
234
+ private _unbindDocument(): void {
235
+ document.removeEventListener('pointerdown', this._onDocumentPointerDown);
236
+ document.removeEventListener('keydown', this._onDocumentKeyDown, true);
237
+ }
238
+
239
+ private _render(): void {
240
+ this.hidden = !this._state.open;
241
+ this.dataset.position = this.position;
242
+ this.dataset.align = this.align;
243
+ }
244
+
245
+ /**
246
+ * Arrow/Home/End/Enter/Space inside the surface. The arithmetic is core's
247
+ * ({@link resolvePopoverKey}); this only maps its answer onto the DOM.
248
+ *
249
+ * Escape is NOT handled here — it is bound at the document in capture phase
250
+ * so it works no matter where focus sits, including a search entry inside
251
+ * the popover.
252
+ */
253
+ private _onKeyDown(event: KeyboardEvent): void {
254
+ if (!this._state.open) return;
255
+ const items = this.items;
256
+ const active = document.activeElement as HTMLElement | null;
257
+ const currentIndex = active === null ? -1 : items.indexOf(active);
258
+ const hasSearch = this.querySelector('input, textarea') !== null;
259
+
260
+ const { action, index } = resolvePopoverKey(event.key, {
261
+ itemCount: items.length,
262
+ currentIndex,
263
+ hasSearch,
264
+ });
265
+
266
+ if (action === 'focus') {
267
+ event.preventDefault();
268
+ items[index]?.focus();
269
+ return;
270
+ }
271
+ if (action === 'activate') {
272
+ // preventDefault BEFORE the synthetic click: a focused <button>
273
+ // activates natively on Enter (keydown) and Space (keyup), so
274
+ // without it the row would fire twice.
275
+ event.preventDefault();
276
+ items[index]?.click();
277
+ this.dispatchEvent(new CustomEvent('popover-item-activated', { bubbles: true, detail: { index } }));
278
+ }
279
+ // 'close' arrives through the document capture handler; 'none' is not ours.
280
+ }
281
+ }
282
+
283
+ customElements.define('adw-popover', AdwPopover);