@gjsify/adwaita-web 0.30.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
@@ -1,68 +1,436 @@
1
1
  // <adw-entry-row> — A boxed-list row that is itself a text entry: the title
2
- // floats as a label and the editable text sits inline on the row.
3
- // Attributes: title, text.
4
- // Property: text (get/set, proxies the inner input).
5
- // Events: `changed` (CustomEvent) on every edit — mirrors Adw.EntryRow's
6
- // `changed` signal so it drives the same code path as the GTK renderer.
2
+ // doubles as placeholder text and the editable value sits inline on the row.
3
+ //
4
+ // The DERIVATION behind all of that — `update_empty`'s five-output truth table,
5
+ // the `text_changed` apply latch with both of its reset paths, character-counted
6
+ // max-length truncation and the two-way <kbd>Enter</kbd> dispatch — is HEADLESS
7
+ // and lives in `@gjsify/adwaita-core` (ADR 0004) as {@link EntryRowState}. This
8
+ // element composes it and keeps only the DOM half; `@gjsify/adwaita-nativescript`
9
+ // composes the same state machine, so both ports share one behaviour and are
10
+ // held to one table (`@gjsify/adwaita-core/conformance`).
11
+ //
12
+ // Attributes: title, text, editable, max-length, show-apply-button,
13
+ // activates-default. `text-length` is REFLECTED (read-only, mirrors
14
+ // `Adw.EntryRow:text-length`).
15
+ // Properties: text, editable, maxLength, textLength, showApplyButton,
16
+ // activatesDefault, editing.
17
+ // Events:
18
+ // `changed` + `notify::text` — every buffer change. libadwaita implements
19
+ // GtkEditable through a delegate (adw-entry-row.c:87-93, :821-834) so it
20
+ // emits BOTH spellings; the two ports had each picked one and drifted, so
21
+ // this emits both and code written against either runs against the other.
22
+ // `notify::text-length` — on_length_changed (C:268-273).
23
+ // `apply` — the apply button, or Enter while it shows (C:240).
24
+ // `entry-activated` — Enter otherwise (C:256), AFTER the default was activated.
25
+ //
26
+ // Reference: refs/libadwaita/src/adw-entry-row.c, adw-entry-row.ui
7
27
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/entryrow.md
8
28
  // Reference: refs/libadwaita/src/stylesheet/widgets/_entries.scss
9
29
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
10
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
30
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
31
+ // edit icon node is <adw-icon>.
32
+
33
+ import {
34
+ ENTRY_ROW_APPLY_ICON_NAME,
35
+ ENTRY_ROW_APPLY_TOOLTIP,
36
+ ENTRY_ROW_EDIT_ICON_NAME,
37
+ EntryRowState,
38
+ type EntryRowRenderState,
39
+ } from '@gjsify/adwaita-core';
40
+
41
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
42
+
43
+ /**
44
+ * Show/hide a rendered part.
45
+ *
46
+ * `hidden` carries the semantics and is what the conformance suite reads, but
47
+ * author CSS beats the UA `[hidden]` rule: `.adw-icon` already ships its own
48
+ * `&[hidden] { display: none }` override in `_icon.scss` and `.adw-button`
49
+ * does not, so a hidden apply button would still paint. The inline `display`
50
+ * rides along until the entry-row SCSS pass lands.
51
+ */
52
+ function setPartVisible(part: HTMLElement, visible: boolean): void {
53
+ part.hidden = !visible;
54
+ part.style.display = visible ? '' : 'none';
55
+ }
56
+
57
+ /** Read a boolean attribute the HTML way, with an explicit `="false"` opt-out. */
58
+ function boolAttribute(value: string | null): boolean {
59
+ return value !== null && value !== 'false';
60
+ }
11
61
 
12
62
  export class AdwEntryRow extends HTMLElement {
13
- private _input!: HTMLInputElement;
14
- private _titleEl!: HTMLSpanElement;
15
- private _initialized = false;
63
+ /** The headless `update_empty` derivation + apply latch (ADR 0004). */
64
+ protected readonly _state = new EntryRowState();
65
+ protected _input!: HTMLInputElement;
66
+ /** The large placeholder title (`empty_title` in adw-entry-row.ui). */
67
+ protected _emptyTitle!: HTMLSpanElement;
68
+ /** The small label above the value (`title` in adw-entry-row.ui). */
69
+ protected _title!: HTMLSpanElement;
70
+ /** The `editable-area` gizmo: both titles plus the input. */
71
+ protected _area!: HTMLDivElement;
72
+ protected _editIcon!: AdwIcon;
73
+ protected _indicator!: HTMLSpanElement;
74
+ protected _applyButton!: HTMLButtonElement;
75
+ protected _prefixes!: HTMLDivElement;
76
+ protected _suffixes!: HTMLDivElement;
77
+ protected _initialized = false;
78
+ /** Light-DOM children captured at build time, adopted after `_onConnected`. */
79
+ private _authored: Element[] = [];
80
+ private _lastText = '';
81
+ private _lastLength = 0;
82
+
83
+ static get observedAttributes(): string[] {
84
+ return ['title', 'text', 'editable', 'max-length', 'show-apply-button', 'activates-default'];
85
+ }
16
86
 
17
- static get observedAttributes() {
18
- return ['title', 'text'];
87
+ /**
88
+ * Class-name prefix for this row's parts. `<adw-password-entry-row>`
89
+ * overrides it so `_password_entry_row.scss` keeps matching its own parts —
90
+ * the two SCSS partials are near-identical but each scopes to its own tag.
91
+ */
92
+ protected get _classPrefix(): string {
93
+ return 'adw-entry-row';
19
94
  }
20
95
 
96
+ /** The editable text. */
21
97
  get text(): string {
22
- return this._input ? this._input.value : (this.getAttribute('text') ?? '');
98
+ return this._state.text;
23
99
  }
24
100
 
25
101
  set text(value: string) {
26
- if (this._input) this._input.value = value;
27
- else this.setAttribute('text', value);
102
+ this._state.setText(value ?? '');
103
+ }
104
+
105
+ /** `Adw.EntryRow:text-length` — the number of CHARACTERS, not UTF-16 units. */
106
+ get textLength(): number {
107
+ return this._state.textLength;
108
+ }
109
+
110
+ /** `Adw.EntryRow:max-length` — maximum number of characters, `0` = unlimited. */
111
+ get maxLength(): number {
112
+ return this._state.maxLength;
113
+ }
114
+
115
+ set maxLength(value: number) {
116
+ this._state.setMaxLength(value);
117
+ }
118
+
119
+ /** Whether the entry accepts edits (`GtkEditable:editable`). */
120
+ get editable(): boolean {
121
+ return this._state.editable;
122
+ }
123
+
124
+ set editable(value: boolean) {
125
+ this._state.setEditable(value);
126
+ }
127
+
128
+ /** `Adw.EntryRow:show-apply-button` — typing reveals an apply button. */
129
+ get showApplyButton(): boolean {
130
+ return this._state.showApplyButton;
131
+ }
132
+
133
+ set showApplyButton(value: boolean) {
134
+ this._state.setShowApplyButton(value);
135
+ }
136
+
137
+ /** `Adw.EntryRow:activates-default` — Enter may submit the surrounding form. */
138
+ get activatesDefault(): boolean {
139
+ return this._state.activatesDefault;
140
+ }
141
+
142
+ set activatesDefault(value: boolean) {
143
+ this._state.setActivatesDefault(value);
144
+ }
145
+
146
+ /** Whether the embedded entry currently has focus. */
147
+ get editing(): boolean {
148
+ return this._state.editing;
28
149
  }
29
150
 
30
151
  connectedCallback() {
31
152
  if (this._initialized) return;
32
153
  this._initialized = true;
33
154
 
34
- this._titleEl = document.createElement('span');
35
- this._titleEl.className = 'adw-row-title';
36
- this._titleEl.textContent = this.getAttribute('title') ?? '';
155
+ this._build();
156
+
157
+ // Seed from the attributes BEFORE subscribing, so building the initial
158
+ // DOM is not driven by change notifications (the adw-spin-row pattern).
159
+ // `text` is only taken when the attribute is present, so a property set
160
+ // before the element was connected survives.
161
+ this._state.setMaxLength(Number.parseInt(this.getAttribute('max-length') ?? '', 10));
162
+ this._state.setEditable(this.getAttribute('editable') !== 'false');
163
+ this._state.setShowApplyButton(boolAttribute(this.getAttribute('show-apply-button')));
164
+ this._state.setActivatesDefault(boolAttribute(this.getAttribute('activates-default')));
165
+ if (this.hasAttribute('text')) this._state.setText(this.getAttribute('text') ?? '');
166
+ this._renderTitle();
167
+
168
+ // Subclass parts first, THEN the author's children: `AdwPasswordEntryRow`
169
+ // installs its peek toggle from its own `init` (C:152), so a consumer
170
+ // suffix lands after it and both remain.
171
+ this._onConnected();
172
+ this._adoptAuthored();
173
+
174
+ this._lastText = this._state.text;
175
+ this._lastLength = this._state.textLength;
176
+ this._render(this._state.state);
177
+ this._state.subscribe((state) => this._onStateChanged(state));
178
+ }
179
+
180
+ attributeChangedCallback(name: string, _old: string | null, value: string | null) {
181
+ if (!this._initialized) return;
182
+ switch (name) {
183
+ case 'title':
184
+ this._renderTitle();
185
+ break;
186
+ case 'text':
187
+ this._state.setText(value ?? '');
188
+ break;
189
+ case 'editable':
190
+ this._state.setEditable(value !== 'false');
191
+ break;
192
+ case 'max-length':
193
+ this._state.setMaxLength(Number.parseInt(value ?? '', 10));
194
+ break;
195
+ case 'show-apply-button':
196
+ this._state.setShowApplyButton(boolAttribute(value));
197
+ break;
198
+ case 'activates-default':
199
+ this._state.setActivatesDefault(boolAttribute(value));
200
+ break;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Add a widget after the editable area — `adw_entry_row_add_suffix`
206
+ * (adw-entry-row.c:885-899), which APPENDS into a box, so several suffixes
207
+ * coexist and the password row's own peek toggle survives a consumer's.
208
+ */
209
+ addSuffix(node: Node): void {
210
+ this._suffixes.append(node);
211
+ setPartVisible(this._suffixes, true);
212
+ }
213
+
214
+ /**
215
+ * Add a widget before the editable area — `adw_entry_row_add_prefix`
216
+ * (C:864-880). PREPENDS, like the `gtk_box_prepend` it mirrors, so several
217
+ * prefixes stack in reverse call order exactly as they do in GTK.
218
+ */
219
+ addPrefix(node: Node): void {
220
+ this._prefixes.prepend(node);
221
+ setPartVisible(this._prefixes, true);
222
+ }
223
+
224
+ /**
225
+ * Drive the trailing indicator — `adw_entry_row_set_show_indicator`
226
+ * (adw-entry-row-private.h, C:1281-1296). Private-to-the-library upstream,
227
+ * public here because `<adw-password-entry-row>` is a separate custom element
228
+ * rather than a C subclass with access to the private header.
229
+ */
230
+ setShowIndicator(show: boolean): void {
231
+ this._state.setShowIndicator(show);
232
+ }
233
+
234
+ /** `adw_entry_row_set_indicator_icon_name` (C:1263-1273). */
235
+ setIndicatorIconName(iconName: string): void {
236
+ this._indicator.dataset.iconName = iconName;
237
+ }
238
+
239
+ /** `adw_entry_row_set_indicator_tooltip` (C:1275-1279). */
240
+ setIndicatorTooltip(tooltip: string): void {
241
+ this._indicator.title = tooltip;
242
+ this._indicator.setAttribute('aria-label', tooltip);
243
+ }
244
+
245
+ /** Apply the pending edit — the apply button click (C:229-241). */
246
+ apply(): void {
247
+ this._state.apply();
248
+ this.dispatchEvent(new CustomEvent('apply', { bubbles: true }));
249
+ }
250
+
251
+ /** Focus the entry without selecting — `adw_entry_row_grab_focus_without_selecting` (C:1379-1389). */
252
+ grabFocusWithoutSelecting(): boolean {
253
+ this._input.focus();
254
+ return this._state.editing;
255
+ }
256
+
257
+ /** Subclass hook: runs after the DOM exists and the state is seeded, before the first render. */
258
+ protected _onConnected(): void {}
259
+
260
+ private _build(): void {
261
+ const prefix = this._classPrefix;
262
+
263
+ // Author-supplied light-DOM children are KEPT: `replaceChildren` used to
264
+ // discard them silently, so `<adw-entry-row><adw-button>Go</adw-button>`
265
+ // rendered without its button. Elements only — GtkBuildable also only
266
+ // ever adopts widgets, and keeping stray whitespace would make an empty
267
+ // affordance box measure as non-empty.
268
+ this._authored = [...this.children];
269
+ this._prefixes = document.createElement('div');
270
+ this._prefixes.className = `${prefix}-prefixes`;
271
+ this._suffixes = document.createElement('div');
272
+ this._suffixes.className = `${prefix}-suffixes`;
273
+
274
+ // TWO title labels, exactly like adw-entry-row.ui: `empty_title` is the
275
+ // large placeholder shown while the row is empty, `title` the small label
276
+ // above the value. GTK cross-fades them over EMPTY_ANIMATION_DURATION_MS
277
+ // (C:433-439); until the SCSS pass lands this is a hard swap at the two
278
+ // animation endpoints, which is why the render reads `emptyTarget`.
279
+ this._emptyTitle = document.createElement('span');
280
+ this._emptyTitle.className = `adw-row-title ${prefix}-empty-title`;
281
+ this._title = document.createElement('span');
282
+ // `adw-row-title` is what the SCSS styles; the prefixed class is how the
283
+ // conformance suite tells the two titles apart.
284
+ this._title.className = `adw-row-title ${prefix}-title`;
37
285
 
38
286
  this._input = document.createElement('input');
39
- this._input.className = 'adw-entry-row-input';
287
+ this._input.className = `${prefix}-input`;
40
288
  this._input.type = 'text';
41
- this._input.value = this.getAttribute('text') ?? '';
42
289
  this._input.addEventListener('input', () => {
43
- this.dispatchEvent(new CustomEvent('changed', { bubbles: true, detail: { text: this._input.value } }));
290
+ // Route through the core so truncation counts CHARACTERS —
291
+ // `input.maxLength` counts UTF-16 units and is deliberately unset.
292
+ this._state.setText(this._input.value);
293
+ if (this._input.value !== this._state.text) this._input.value = this._state.text;
44
294
  });
295
+ this._input.addEventListener('focus', () => this._state.setEditing(true));
296
+ this._input.addEventListener('blur', () => this._state.setEditing(false));
297
+ this._input.addEventListener('keydown', (event) => {
298
+ if (event.key !== 'Enter') return;
299
+ event.preventDefault();
300
+ this._activate();
301
+ });
302
+
303
+ this._area = document.createElement('div');
304
+ this._area.className = `${prefix}-text`;
305
+ this._area.append(this._emptyTitle, this._title, this._input);
306
+
307
+ // The caps-lock warning slot. `caps-lock-symbolic` is not in the curated
308
+ // @gjsify/adwaita-icons set, so the glyph stands in and the canonical
309
+ // name travels in `data-icon-name` — the same fallback the dialog close
310
+ // buttons use.
311
+ this._indicator = document.createElement('span');
312
+ this._indicator.className = `${prefix}-indicator`;
313
+ this._indicator.setAttribute('role', 'img');
314
+ this._indicator.textContent = '⇪';
45
315
 
46
- // Title floats as a small label above the value (the filled
47
- // Adw.EntryRow look), both left-aligned in a text column.
48
- const text = document.createElement('div');
49
- text.className = 'adw-entry-row-text';
50
- text.append(this._titleEl, this._input);
316
+ this._applyButton = document.createElement('button');
317
+ this._applyButton.type = 'button';
318
+ this._applyButton.className = `adw-button suggested-action circular icon-only ${prefix}-apply`;
319
+ this._applyButton.dataset.iconName = ENTRY_ROW_APPLY_ICON_NAME;
320
+ this._applyButton.title = ENTRY_ROW_APPLY_TOOLTIP;
321
+ this._applyButton.setAttribute('aria-label', ENTRY_ROW_APPLY_TOOLTIP);
322
+ this._applyButton.textContent = '✓';
323
+ this._applyButton.addEventListener('click', () => this.apply());
51
324
 
52
- // Trailing edit affordance, mirroring Adw.EntryRow's pencil; clicking
53
- // it focuses the entry.
54
- const edit = document.createElement('span');
55
- edit.className = 'adw-row-edit adw-icon adw-icon--document-edit';
56
- edit.setAttribute('aria-hidden', 'true');
57
- edit.addEventListener('click', () => this._input.focus());
325
+ // The libadwaita name travels in `data-icon-name`; the drawn glyph is
326
+ // the curated @gjsify/adwaita-icons spelling of the same symbolic, which
327
+ // is a different string (`adw-entry-edit-symbolic` vs `document-edit`).
328
+ this._editIcon = createAdwIcon('document-edit', 'adw-row-edit', `${prefix}-edit`);
329
+ this._editIcon.dataset.iconName = ENTRY_ROW_EDIT_ICON_NAME;
58
330
 
59
- this.replaceChildren(text, edit);
331
+ // Snapshot order: indicator → apply → edit icon inside the editable area
332
+ // (C:429-431), with the prefixes/suffixes boxes outside it.
333
+ this.replaceChildren(
334
+ this._prefixes,
335
+ this._area,
336
+ this._indicator,
337
+ this._applyButton,
338
+ this._editIcon,
339
+ this._suffixes,
340
+ );
341
+ setPartVisible(this._prefixes, false);
342
+ setPartVisible(this._suffixes, false);
343
+
344
+ this.addEventListener('click', (event) => this._maybeFocus(event));
60
345
  }
61
346
 
62
- attributeChangedCallback(name: string, _old: string | null, value: string | null) {
63
- if (!this._initialized) return;
64
- if (name === 'title') this._titleEl.textContent = value ?? '';
65
- else if (name === 'text' && this._input.value !== (value ?? '')) this._input.value = value ?? '';
347
+ /**
348
+ * Re-home the children the author wrote. libadwaita's buildable maps an
349
+ * untyped `<child>` to a suffix (C:796-812); `slot="prefix"` is the web
350
+ * spelling of its `type="prefix"`.
351
+ */
352
+ private _adoptAuthored(): void {
353
+ for (const node of this._authored) {
354
+ if (node.getAttribute('slot') === 'prefix') this.addPrefix(node);
355
+ else this.addSuffix(node);
356
+ }
357
+ this._authored = [];
358
+ }
359
+
360
+ /**
361
+ * `pressed_cb` (C:201-227): a click landing on the row, the editable area,
362
+ * the indicator or either affordance box grabs focus; anything else (a suffix
363
+ * control, the apply button) is left alone. The pencil is `can-target=False`
364
+ * in the .ui, so a click on it picks the editable area — hence it focuses too.
365
+ */
366
+ private _maybeFocus(event: MouseEvent): void {
367
+ const target = event.target;
368
+ if (
369
+ target === this ||
370
+ target === this._area ||
371
+ target === this._emptyTitle ||
372
+ target === this._title ||
373
+ target === this._editIcon ||
374
+ target === this._indicator ||
375
+ target === this._prefixes ||
376
+ target === this._suffixes
377
+ ) {
378
+ this._input.focus();
379
+ }
380
+ }
381
+
382
+ /** `text_activated_cb` (C:243-266) — exactly one of the two signals. */
383
+ private _activate(): void {
384
+ const activation = this._state.activate();
385
+ if (activation.signal === 'apply') {
386
+ this.dispatchEvent(new CustomEvent('apply', { bubbles: true }));
387
+ return;
388
+ }
389
+ // C:253-256 — the default widget is activated BEFORE `entry-activated`.
390
+ // Implicit form submission is the web's default-widget activation.
391
+ if (activation.activateDefault) this.closest('form')?.requestSubmit();
392
+ this.dispatchEvent(new CustomEvent('entry-activated', { bubbles: true }));
393
+ }
394
+
395
+ private _renderTitle(): void {
396
+ const title = this.getAttribute('title') ?? '';
397
+ this._emptyTitle.textContent = title;
398
+ this._title.textContent = title;
399
+ }
400
+
401
+ /** Paint one render snapshot. Overridden (via `super`) by the password row. */
402
+ protected _render(state: EntryRowRenderState): void {
403
+ if (this._input.value !== state.text) this._input.value = state.text;
404
+ this._input.readOnly = !state.editable;
405
+ this.classList.toggle('focused', state.editing);
406
+ this.classList.toggle('empty', state.empty);
407
+ // The two titles are the animation's endpoints; a renderer with a real
408
+ // cross-fade would lerp between them over EMPTY_ANIMATION_DURATION_MS.
409
+ setPartVisible(this._emptyTitle, state.emptyTarget === 0);
410
+ setPartVisible(this._title, state.emptyTarget === 1);
411
+ setPartVisible(this._editIcon, state.editIconVisible);
412
+ this._editIcon.classList.toggle('disabled', !state.editIconSensitive);
413
+ this._editIcon.setAttribute('aria-disabled', String(!state.editIconSensitive));
414
+ setPartVisible(this._indicator, state.indicatorVisible);
415
+ setPartVisible(this._applyButton, state.applyButtonVisible);
416
+ this.setAttribute('text-length', String(state.textLength));
417
+ }
418
+
419
+ private _onStateChanged(state: EntryRowRenderState): void {
420
+ this._render(state);
421
+
422
+ if (state.text !== this._lastText) {
423
+ this._lastText = state.text;
424
+ const detail = { text: state.text };
425
+ this.dispatchEvent(new CustomEvent('changed', { bubbles: true, detail }));
426
+ this.dispatchEvent(new CustomEvent('notify::text', { bubbles: true, detail }));
427
+ }
428
+ if (state.textLength !== this._lastLength) {
429
+ this._lastLength = state.textLength;
430
+ this.dispatchEvent(
431
+ new CustomEvent('notify::text-length', { bubbles: true, detail: { textLength: state.textLength } }),
432
+ );
433
+ }
66
434
  }
67
435
  }
68
436
 
@@ -18,27 +18,50 @@
18
18
  // `notify::expanded` event. `@gjsify/adwaita-nativescript` composes the same
19
19
  // state machine, so both ports share one behaviour.
20
20
  //
21
+ // The optional enable switch is `<adw-switch>`. Both the markup and the
22
+ // stylesheet block behind it used to be hand-built copies of
23
+ // `<adw-switch-row>`'s — `_expander_row.scss:73-121` was character-for-character
24
+ // `_switch_row.scss:15-63` — because `.adw-switch` was nested inside
25
+ // `adw-switch-row {` and unusable from here.
26
+ //
21
27
  // Reference: refs/adwaita-web/adwaita-web/scss/_expander_row.scss
22
28
  // Reference: refs/libadwaita/src/stylesheet/widgets/_expander-row.scss
23
29
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
24
30
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
25
- // disclosure state machine composed from @gjsify/adwaita-core.
26
-
27
- import { ExpanderState } from '@gjsify/adwaita-core';
31
+ // disclosure state machine composed from @gjsify/adwaita-core, the enable
32
+ // switch from <adw-switch> and the chevron from <adw-icon>.
33
+
34
+ import { ExpanderState, deriveRowLabels } from '@gjsify/adwaita-core';
35
+
36
+ // SIDE-EFFECT imports, deliberately separate from the type import below: they
37
+ // are what guarantee `adw-switch` / `adw-icon` are defined before this module's
38
+ // own `customElements.define` can upgrade a server-rendered `<adw-expander-row>`
39
+ // and build them. A combined `import { AdwSwitch }` would NOT do it — the
40
+ // binding is only ever used in type position here, and this package compiles
41
+ // without `verbatimModuleSyntax`, so TypeScript would elide the whole statement
42
+ // and take the registration with it.
43
+ import './adw-switch.js';
44
+ import type { AdwSwitch } from './adw-switch.js';
45
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
28
46
 
29
47
  export class AdwExpanderRow extends HTMLElement {
30
48
  private _headerEl!: HTMLDivElement;
31
49
  private _titleEl!: HTMLSpanElement;
32
50
  private _subtitleEl!: HTMLSpanElement;
33
- private _enableSwitch!: HTMLInputElement;
34
- private _enableLabel!: HTMLLabelElement;
35
- private _chevronEl!: HTMLSpanElement;
51
+ private _enableSwitch!: AdwSwitch;
52
+ private _chevronEl!: AdwIcon;
36
53
  private _contentEl!: HTMLDivElement;
37
54
  private _prefixEl!: HTMLDivElement;
38
55
  private _suffixEl!: HTMLDivElement;
39
56
  /** The headless expanded/collapsed disclosure state machine (ADR 0004). */
40
57
  private readonly _state = new ExpanderState();
41
58
  private _initialized = false;
59
+ /**
60
+ * Guards the enable switch's `notify::active` while WE are the ones writing
61
+ * it. Only a user toggle publishes `notify::enable-expansion`; a programmatic
62
+ * `enableExpansion = …` re-syncs the control silently, as it always has.
63
+ */
64
+ private _reflectingEnable = false;
42
65
 
43
66
  constructor() {
44
67
  super();
@@ -114,17 +137,11 @@ export class AdwExpanderRow extends HTMLElement {
114
137
 
115
138
  // Optional enable switch (the same toggle Adw.ExpanderRow shows when
116
139
  // show-enable-switch is set). Clicking it does NOT toggle the disclosure.
117
- this._enableLabel = document.createElement('label');
118
- this._enableLabel.className = 'adw-switch adw-expander-row-enable-switch';
119
- this._enableSwitch = document.createElement('input');
120
- this._enableSwitch.type = 'checkbox';
121
- this._enableSwitch.checked = this.enableExpansion;
122
- const slider = document.createElement('span');
123
- slider.className = 'adw-switch-slider';
124
- this._enableLabel.append(this._enableSwitch, slider);
125
-
126
- this._chevronEl = document.createElement('span');
127
- this._chevronEl.className = 'adw-icon adw-icon--go-down adw-expander-row-chevron';
140
+ this._enableSwitch = document.createElement('adw-switch') as AdwSwitch;
141
+ this._enableSwitch.classList.add('adw-expander-row-enable-switch');
142
+ this._enableSwitch.active = this.enableExpansion;
143
+
144
+ this._chevronEl = createAdwIcon('go-down', 'adw-expander-row-chevron');
128
145
 
129
146
  // Header prefix / suffix widget slots (Adw.ExpanderRow add_prefix/add_suffix).
130
147
  this._prefixEl = document.createElement('div');
@@ -135,7 +152,7 @@ export class AdwExpanderRow extends HTMLElement {
135
152
  this._suffixEl.className = 'adw-expander-row-suffix';
136
153
  for (const child of suffixChildren) this._suffixEl.appendChild(child);
137
154
 
138
- this._headerEl.append(this._prefixEl, textEl, this._suffixEl, this._enableLabel, this._chevronEl);
155
+ this._headerEl.append(this._prefixEl, textEl, this._suffixEl, this._enableSwitch, this._chevronEl);
139
156
 
140
157
  // Disclosed content — a nested listbox holding the rows.
141
158
  this._contentEl = document.createElement('div');
@@ -146,7 +163,7 @@ export class AdwExpanderRow extends HTMLElement {
146
163
 
147
164
  this._headerEl.addEventListener('click', (event) => {
148
165
  // Clicks on the enable switch toggle expansion-enable, not disclosure.
149
- if (this._enableLabel.contains(event.target as Node)) return;
166
+ if (this._enableSwitch.contains(event.target as Node)) return;
150
167
  // The core state machine flips + reflects + re-renders; the event is
151
168
  // emitted here so only a USER disclosure notifies (a programmatic
152
169
  // `expanded = …` stays silent, as it always has on this port).
@@ -157,12 +174,19 @@ export class AdwExpanderRow extends HTMLElement {
157
174
  }
158
175
  });
159
176
 
160
- this._enableSwitch.addEventListener('change', () => {
161
- this.setAttribute('enable-expansion', this._enableSwitch.checked ? 'true' : 'false');
177
+ this._enableSwitch.addEventListener('notify::active', (event) => {
178
+ // The switch's own notify is stopped at the row: the row publishes
179
+ // `notify::enable-expansion`, and letting an identically-shaped
180
+ // `notify::active` past would put a second, differently-named event
181
+ // for the same user action on every ancestor listener.
182
+ event.stopPropagation();
183
+ if (this._reflectingEnable) return;
184
+ const enabled = this._enableSwitch.active;
185
+ this.setAttribute('enable-expansion', enabled ? 'true' : 'false');
162
186
  this.dispatchEvent(
163
187
  new CustomEvent('notify::enable-expansion', {
164
188
  bubbles: true,
165
- detail: { enableExpansion: this._enableSwitch.checked },
189
+ detail: { enableExpansion: enabled },
166
190
  }),
167
191
  );
168
192
  });
@@ -182,15 +206,23 @@ export class AdwExpanderRow extends HTMLElement {
182
206
  }
183
207
 
184
208
  private _render() {
185
- const title = this.getAttribute('title') ?? '';
186
- const subtitle = this.getAttribute('subtitle') ?? '';
187
- this._titleEl.textContent = title;
188
- this._subtitleEl.textContent = subtitle;
189
- this._subtitleEl.hidden = subtitle.length === 0;
209
+ // The `string_is_not_empty` label rule, from core — this block was one of
210
+ // six hand-rolled copies, all of which omitted the TITLE half.
211
+ const labels = deriveRowLabels({
212
+ title: this.getAttribute('title'),
213
+ subtitle: this.getAttribute('subtitle'),
214
+ });
215
+ this._titleEl.textContent = labels.title;
216
+ this._titleEl.hidden = !labels.titleVisible;
217
+ this._subtitleEl.textContent = labels.subtitle;
218
+ this._subtitleEl.hidden = !labels.subtitleVisible;
190
219
 
191
220
  const showSwitch = this.hasAttribute('show-enable-switch');
192
- this._enableLabel.hidden = !showSwitch;
193
- this._enableSwitch.checked = this.enableExpansion;
221
+ this._enableSwitch.hidden = !showSwitch;
222
+ // OUR write, not the user's — see `_reflectingEnable`.
223
+ this._reflectingEnable = true;
224
+ this._enableSwitch.active = this.enableExpansion;
225
+ this._reflectingEnable = false;
194
226
 
195
227
  this._prefixEl.hidden = this._prefixEl.childElementCount === 0;
196
228
  this._suffixEl.hidden = this._suffixEl.childElementCount === 0;