@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
@@ -0,0 +1,299 @@
1
+ // <adw-checkbox> and <adw-radio> — the two form-control primitives, in ONE
2
+ // module, for the same reason `scss/_checks.scss` is one partial: upstream
3
+ // merges them (`refs/libadwaita/src/stylesheet/widgets/_checks.scss` has no
4
+ // `_radio.scss` beside it), and everything but the corner radius, the glyph and
5
+ // the group is shared. `refs/adwaita-web` split them into two files and the two
6
+ // promptly drifted apart.
7
+ //
8
+ // WHAT LIVES IN `@gjsify/adwaita-core`, AND WHAT DOES NOT
9
+ //
10
+ // The checkbox has no core class: it is one boolean plus a third state, which
11
+ // ADR 0004 calls out as too trivial to model. What IS core is the pair of
12
+ // pieces a second renderer would otherwise re-invent:
13
+ //
14
+ // - {@link RadioGroupState} — group exclusivity. The browser gives the inner
15
+ // `<input type="radio" name="…">` exclusivity for free, and STOPS THERE: it
16
+ // unchecks the sibling INPUT and leaves the sibling `<adw-radio>` host's
17
+ // `checked` attribute — the published state, and the selector
18
+ // `_checks.scss` paints from — stale, so a group left to the browser draws
19
+ // two selected radios. The shared state is what repaints the loser.
20
+ // - {@link resolveCheckState} — indeterminate outranks checked, which is read
21
+ // off the cascade in `_checks.scss:79-81` and is what `aria-checked` needs.
22
+ //
23
+ // WHAT IS NOT VERIFIABLE IN THIS TREE. `GtkCheckButton` is a GTK widget:
24
+ // `refs/gtk` is EMPTY here and libadwaita vendors no `adw-checkbox.c`, so its
25
+ // group semantics (`gtk_check_button_set_group`, whether a fresh group starts
26
+ // with a member active) and the exact meaning of `GtkCheckButton:inconsistent`
27
+ // are NOT verifiable and are not guessed at. Where a rule was needed the HTML
28
+ // one is used and cited as such — clearing `indeterminate` on activation is the
29
+ // HTML pre-click activation step, not a reading of GTK.
30
+ //
31
+ // NAMING. The state attribute is `checked`, not GtkCheckButton's `active`:
32
+ // these elements wrap a real `<input>`, `checked` is the spelling every author
33
+ // already writes, and it is what the stylesheet selects on. The published event
34
+ // follows the attribute (`notify::checked`), not the GObject property.
35
+ //
36
+ // Attributes (both):
37
+ // checked — boolean; reflects (and drives) the checked state.
38
+ // indeterminate — boolean; the third state, drawn as a dash. Outranks checked.
39
+ // disabled — boolean; the control does not respond and is dimmed.
40
+ // label — the text beside the indicator. When absent, the element's
41
+ // existing child nodes become the label (so a server-rendered
42
+ // `<adw-checkbox>Enable</adw-checkbox>` upgrades intact).
43
+ // Attributes (<adw-radio> only):
44
+ // name — the group. Exclusivity is scoped to it, exactly as `name`
45
+ // scopes `<input type="radio">` — including the empty case:
46
+ // HTML forms a radio button group only from a NON-EMPTY name,
47
+ // so a nameless radio checks itself and unchecks nothing.
48
+ // value — this member's value. Defaults to the label text, so a group
49
+ // written without values still has distinguishable members.
50
+ // Properties: checked / indeterminate / disabled / label (+ name / value).
51
+ // Events:
52
+ // `notify::checked` (CustomEvent, bubbles, detail = { checked }) — fires on
53
+ // every change, programmatic included, on EVERY member a radio pick moves.
54
+ //
55
+ // A11Y: the inner input carries the role (native `checkbox` / `radio`) and an
56
+ // explicit `aria-checked` derived from {@link resolveCheckState}, so the
57
+ // indeterminate state announces as `mixed` for BOTH — HTML has a native mixed
58
+ // checkbox (`input.indeterminate`) but no mixed radio.
59
+ //
60
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_checks.scss
61
+ // Reference: refs/adwaita-web/adwaita-web/scss/{_checkbox,_radio}.scss
62
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
63
+ // Copyright (c) 2025 csm (adwaita-web). MIT License.
64
+ // Modifications: Implemented as Web Components for @gjsify/adwaita-web.
65
+
66
+ import { RadioGroupState, resolveCheckState } from '@gjsify/adwaita-core';
67
+
68
+ /**
69
+ * The document's radio groups. ONE registry, as global as a bare
70
+ * `<input type="radio" name="x">` outside a form is — two independent groups
71
+ * therefore need two names.
72
+ */
73
+ const radioGroups = new RadioGroupState();
74
+
75
+ /** The markup + state shared by `<adw-checkbox>` and `<adw-radio>`. */
76
+ abstract class AdwCheckBase extends HTMLElement {
77
+ protected _input!: HTMLInputElement;
78
+ protected _indicator!: HTMLSpanElement;
79
+ protected _label!: HTMLSpanElement;
80
+ private _initialized = false;
81
+
82
+ /** `checkbox` or `radio` — the native control this element is built around. */
83
+ protected abstract get inputType(): 'checkbox' | 'radio';
84
+
85
+ /** What a click / Space / arrow key on the control means for this kind. */
86
+ protected abstract activate(): void;
87
+
88
+ /** Whether the control is checked. */
89
+ get checked(): boolean {
90
+ return this.hasAttribute('checked');
91
+ }
92
+
93
+ set checked(value: boolean) {
94
+ // `toggleAttribute` with a force flag is a no-op when the attribute
95
+ // already holds that state, so re-setting the current value mutates
96
+ // nothing and the notify never runs.
97
+ this.toggleAttribute('checked', !!value);
98
+ }
99
+
100
+ /** Whether the control is in the third, inconsistent state. */
101
+ get indeterminate(): boolean {
102
+ return this.hasAttribute('indeterminate');
103
+ }
104
+
105
+ set indeterminate(value: boolean) {
106
+ this.toggleAttribute('indeterminate', !!value);
107
+ }
108
+
109
+ /** Whether the control is inert. */
110
+ get disabled(): boolean {
111
+ return this.hasAttribute('disabled');
112
+ }
113
+
114
+ set disabled(value: boolean) {
115
+ this.toggleAttribute('disabled', !!value);
116
+ }
117
+
118
+ /** The label text beside the indicator. */
119
+ get label(): string {
120
+ return this.getAttribute('label') ?? this._label?.textContent ?? '';
121
+ }
122
+
123
+ set label(value: string) {
124
+ this.setAttribute('label', value);
125
+ }
126
+
127
+ connectedCallback() {
128
+ if (this._initialized) {
129
+ this.onConnected();
130
+ return;
131
+ }
132
+ this._initialized = true;
133
+
134
+ // Captured BEFORE the element takes over its own children: a
135
+ // server-rendered `<adw-checkbox>Enable</adw-checkbox>` keeps its text.
136
+ const authored = [...this.childNodes];
137
+
138
+ this._input = document.createElement('input');
139
+ this._input.type = this.inputType;
140
+ this._input.className = 'adw-check-input';
141
+
142
+ this._indicator = document.createElement('span');
143
+ this._indicator.className = 'adw-check-indicator';
144
+
145
+ this._label = document.createElement('span');
146
+ this._label.className = 'adw-check-label';
147
+ const authoredLabel = this.getAttribute('label');
148
+ if (authoredLabel === null) this._label.append(...authored);
149
+ else this._label.textContent = authoredLabel;
150
+
151
+ this.replaceChildren(this._input, this._indicator, this._label);
152
+
153
+ // Keyboard (Space, and the arrow keys that move within a radio group)
154
+ // and a direct programmatic `input.click()` both surface as `change`.
155
+ this._input.addEventListener('change', () => this.activate());
156
+
157
+ // The indicator and the label cover the control; the input is 0x0, so
158
+ // every pointer click lands on one of them. A click that DID reach the
159
+ // input is already handled above — activating again would undo it.
160
+ this.addEventListener('click', (event) => {
161
+ if (this.disabled) return;
162
+ if (event.target === this._input) return;
163
+ this.activate();
164
+ });
165
+
166
+ this.onConnected();
167
+ this.render();
168
+ }
169
+
170
+ disconnectedCallback() {
171
+ this.onDisconnected();
172
+ }
173
+
174
+ attributeChangedCallback(name: string) {
175
+ if (!this._initialized) return;
176
+ if (name === 'label') {
177
+ this._label.textContent = this.getAttribute('label') ?? '';
178
+ }
179
+ this.render();
180
+ if (name === 'checked') {
181
+ this.dispatchEvent(
182
+ new CustomEvent('notify::checked', { bubbles: true, detail: { checked: this.checked } }),
183
+ );
184
+ }
185
+ }
186
+
187
+ /** Extra wiring for a subclass on connect. */
188
+ protected onConnected(): void {}
189
+
190
+ /** Extra teardown for a subclass on disconnect. */
191
+ protected onDisconnected(): void {}
192
+
193
+ protected render(): void {
194
+ const state = resolveCheckState(this.checked, this.indeterminate);
195
+ this._input.checked = state === 'checked';
196
+ this._input.indeterminate = state === 'indeterminate';
197
+ // Explicit, because HTML has a native mixed CHECKBOX (the property
198
+ // above) but no mixed radio — this is the one spelling that announces
199
+ // the third state for both.
200
+ this._input.setAttribute('aria-checked', state === 'indeterminate' ? 'mixed' : String(state === 'checked'));
201
+ this._input.disabled = this.disabled;
202
+ this._label.hidden = this._label.childNodes.length === 0;
203
+ }
204
+ }
205
+
206
+ /** The Adwaita checkbox — `checkbutton > check` as a custom element. */
207
+ export class AdwCheckbox extends AdwCheckBase {
208
+ static get observedAttributes() {
209
+ return ['checked', 'indeterminate', 'disabled', 'label'];
210
+ }
211
+
212
+ protected get inputType(): 'checkbox' {
213
+ return 'checkbox';
214
+ }
215
+
216
+ protected activate(): void {
217
+ // HTML's pre-click activation steps clear `indeterminate` before the
218
+ // toggle, so a user can always reach a definite state. That rule is the
219
+ // HTML spec's, not a reading of GtkCheckButton:inconsistent, whose
220
+ // behaviour is not verifiable in this tree.
221
+ this.indeterminate = false;
222
+ this.checked = !this.checked;
223
+ }
224
+ }
225
+
226
+ /** The Adwaita radio — `checkbutton > radio`, exclusive within its `name`. */
227
+ export class AdwRadio extends AdwCheckBase {
228
+ private _unsubscribe: (() => void) | null = null;
229
+
230
+ static get observedAttributes() {
231
+ return ['checked', 'indeterminate', 'disabled', 'label', 'name', 'value'];
232
+ }
233
+
234
+ protected get inputType(): 'radio' {
235
+ return 'radio';
236
+ }
237
+
238
+ /** The group this member belongs to. */
239
+ get name(): string {
240
+ return this.getAttribute('name') ?? '';
241
+ }
242
+
243
+ set name(value: string) {
244
+ this.setAttribute('name', value);
245
+ }
246
+
247
+ /**
248
+ * This member's value. Falls back to the label text so a group authored
249
+ * without values still has members the group state can tell apart.
250
+ */
251
+ get value(): string {
252
+ return this.getAttribute('value') ?? this.label.trim();
253
+ }
254
+
255
+ set value(value: string) {
256
+ this.setAttribute('value', value);
257
+ }
258
+
259
+ protected activate(): void {
260
+ this.indeterminate = false;
261
+ // An UNNAMED radio is not in a group — HTML forms a radio button group
262
+ // only from a non-empty `name`, so a nameless one checks itself and
263
+ // never unchecks anything. Without this guard every nameless radio in
264
+ // the document would join one `''` group and fight over it.
265
+ if (this.name === '') {
266
+ this.checked = true;
267
+ return;
268
+ }
269
+ // The pick goes through the shared state, never straight onto this
270
+ // element: the member that LOSES the selection is a different element
271
+ // and only the state knows which one it is.
272
+ radioGroups.select(this.name, this.value);
273
+ }
274
+
275
+ protected onConnected(): void {
276
+ this._input.name = this.name;
277
+ if (this.name === '') return; // ungrouped — nothing to subscribe to
278
+ this._unsubscribe ??= radioGroups.subscribe((change) => {
279
+ if (change.name !== this.name) return;
280
+ this.checked = change.selected === this.value;
281
+ });
282
+ // A member declared checked seeds the group, so the first user pick
283
+ // knows what it is deselecting.
284
+ if (this.checked) radioGroups.select(this.name, this.value);
285
+ }
286
+
287
+ protected onDisconnected(): void {
288
+ this._unsubscribe?.();
289
+ this._unsubscribe = null;
290
+ }
291
+
292
+ protected render(): void {
293
+ super.render();
294
+ this._input.name = this.name;
295
+ }
296
+ }
297
+
298
+ customElements.define('adw-checkbox', AdwCheckbox);
299
+ customElements.define('adw-radio', AdwRadio);
@@ -1,27 +1,97 @@
1
- // <adw-clamp> — Constrains its child to a maximum width and centers it.
2
- // Attributes: maximum-size (px, default 600), tightening-threshold (unused;
3
- // accepted for API parity with Adw.Clamp).
1
+ // <adw-clamp> — Constrains its children to a maximum width and centers them.
2
+ // Attributes: maximum-size (px, default 600), tightening-threshold (px, default 400).
3
+ //
4
+ // The clamp is NOT a `max-width`, which is what this element used to be. Adwaita
5
+ // eases the child from "all the available width" into the cap across a tightening
6
+ // region, and stamps a `small`/`medium`/`large` class on it — the arithmetic lives
7
+ // in `@gjsify/adwaita-core` (`clampAllocate`) so this element and the NativeScript
8
+ // widget produce the same width for the same viewport.
9
+ //
10
+ // The cap now sits on the CHILDREN, not on this element. The element has to keep
11
+ // reporting the AVAILABLE width — it is what the curve is evaluated against — and
12
+ // an element that caps itself reports the capped width back to its own
13
+ // ResizeObserver, which is a feedback loop, not a measurement.
14
+ //
15
+ // `childMin` is passed as 0 rather than measured. libadwaita's
16
+ // `lower = MAX (…, child_min)` says a child is never squeezed below its own
17
+ // minimum, and the browser already applies that rule at the other end: `min-width`
18
+ // wins over `max-width` (CSS 2.1 §10.4), so a child that declares a minimum keeps
19
+ // it whatever cap this element writes. A child that declares none has a minimum of
20
+ // 0, which is the value passed here — measuring `min-content` per child on every
21
+ // resize would cost a synchronous reflow to arrive at the same layout.
22
+ //
23
+ // Reference: refs/libadwaita/src/adw-clamp-layout.c (child_size_from_clamp, allocate)
4
24
  // Reference: refs/adwaita-web/adwaita-web/docs/widgets/clamp.md
5
- // Reference: refs/libadwaita (Adw.Clamp)
6
25
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
7
26
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
8
27
 
28
+ import { ADW_CLAMP_DEFAULTS, ADW_CLAMP_SIZE_CLASSES, clampAllocate, normalizeClampSize } from '@gjsify/adwaita-core';
29
+
9
30
  export class AdwClamp extends HTMLElement {
31
+ private _resize: ResizeObserver | null = null;
32
+ private _mutations: MutationObserver | null = null;
33
+
10
34
  static get observedAttributes() {
11
- return ['maximum-size'];
35
+ return ['maximum-size', 'tightening-threshold'];
12
36
  }
13
37
 
14
38
  connectedCallback() {
15
- this._sync();
39
+ // ResizeObserver delivers an initial observation on observe(), so the
40
+ // first allocation lands before paint without a separate seeding pass.
41
+ this._resize = new ResizeObserver(() => this._allocate());
42
+ this._resize.observe(this);
43
+ // Children appended AFTER connect are clamped too. Without this they
44
+ // would keep whatever width they came with — the same "evaluated once at
45
+ // connect time" bug the toolbar view's empty-bar handling still has.
46
+ this._mutations = new MutationObserver(() => this._allocate());
47
+ this._mutations.observe(this, { childList: true });
48
+ this._allocate();
49
+ }
50
+
51
+ disconnectedCallback() {
52
+ this._resize?.disconnect();
53
+ this._resize = null;
54
+ this._mutations?.disconnect();
55
+ this._mutations = null;
16
56
  }
17
57
 
18
58
  attributeChangedCallback() {
19
- this._sync();
59
+ this._allocate();
20
60
  }
21
61
 
22
- private _sync() {
23
- const max = parseFloat(this.getAttribute('maximum-size') || '600');
24
- this.style.maxWidth = `${max}px`;
62
+ private _allocate(): void {
63
+ const params = {
64
+ maximumSize: normalizeClampSize(this.getAttribute('maximum-size'), ADW_CLAMP_DEFAULTS.maximumSize),
65
+ tighteningThreshold: normalizeClampSize(
66
+ this.getAttribute('tightening-threshold'),
67
+ ADW_CLAMP_DEFAULTS.tighteningThreshold,
68
+ ),
69
+ childMin: 0,
70
+ childNat: 0,
71
+ };
72
+
73
+ const available = this.clientWidth;
74
+ const children = Array.from(this.children) as HTMLElement[];
75
+
76
+ // No box yet (disconnected, or measured before the first layout). GTK's
77
+ // equivalent is `for_size = -1`, where the child reports its natural size
78
+ // rather than being squeezed to nothing — so leave the children alone
79
+ // instead of capping them at 0 and collapsing the page.
80
+ if (available <= 0) {
81
+ for (const child of children) {
82
+ child.style.maxWidth = '';
83
+ child.style.marginInline = '';
84
+ for (const cls of ADW_CLAMP_SIZE_CLASSES) child.classList.remove(cls);
85
+ }
86
+ return;
87
+ }
88
+
89
+ const { childSize, sizeClass } = clampAllocate(available, params);
90
+ for (const child of children) {
91
+ child.style.maxWidth = `${childSize}px`;
92
+ child.style.marginInline = 'auto';
93
+ for (const cls of ADW_CLAMP_SIZE_CLASSES) child.classList.toggle(cls, cls === sizeClass);
94
+ }
25
95
  }
26
96
  }
27
97
 
@@ -17,7 +17,7 @@
17
17
  // title/subtitle text column added to match Adw.ComboRow; the selection state
18
18
  // machine composed from @gjsify/adwaita-core.
19
19
 
20
- import { ComboState } from '@gjsify/adwaita-core';
20
+ import { ComboState, deriveRowLabels, normalizeComboOptions } from '@gjsify/adwaita-core';
21
21
 
22
22
  export class AdwComboRow extends HTMLElement {
23
23
  private _select!: HTMLSelectElement;
@@ -46,8 +46,10 @@ export class AdwComboRow extends HTMLElement {
46
46
 
47
47
  const items: string[] = JSON.parse(this.getAttribute('items') || '[]');
48
48
  // Seed the headless state BEFORE subscribing, so building the initial DOM
49
- // below is not driven by a change notification.
50
- this._state.setOptions(items.map((item) => ({ label: item, value: item })));
49
+ // below is not driven by a change notification. The string→descriptor
50
+ // mapping is core's: `<adw-drop-down>` had its own, richer copy, so the
51
+ // two selectors accepted different option vocabularies.
52
+ this._state.setOptions(normalizeComboOptions(items));
51
53
  this._state.setSelectedIndex(parseInt(this.getAttribute('selected') || '0', 10));
52
54
  const selectedIdx = this._state.selectedIndex;
53
55
 
@@ -114,10 +116,16 @@ export class AdwComboRow extends HTMLElement {
114
116
  }
115
117
 
116
118
  private _renderText() {
117
- this._titleEl.textContent = this.getAttribute('title') ?? '';
118
- const subtitle = this.getAttribute('subtitle') ?? '';
119
- this._subtitleEl.textContent = subtitle;
120
- this._subtitleEl.hidden = subtitle.length === 0;
119
+ // The `string_is_not_empty` label rule, from core — this block was one of
120
+ // six hand-rolled copies, all of which omitted the TITLE half.
121
+ const labels = deriveRowLabels({
122
+ title: this.getAttribute('title'),
123
+ subtitle: this.getAttribute('subtitle'),
124
+ });
125
+ this._titleEl.textContent = labels.title;
126
+ this._titleEl.hidden = !labels.titleVisible;
127
+ this._subtitleEl.textContent = labels.subtitle;
128
+ this._subtitleEl.hidden = !labels.subtitleVisible;
121
129
  }
122
130
  }
123
131
 
@@ -51,46 +51,36 @@
51
51
  // Responsive: the grid scrolls horizontally within its OWN container on narrow
52
52
  // viewports (it never overflows the page); it sits inside an <adw-card>/boxed
53
53
  // surface and follows light + dark via the adwaita-web CSS variables.
54
+ // The derivations are HEADLESS and live in `@gjsify/adwaita-core` (ADR 0004):
55
+ // the column tracks, the cell classes, the two normalisers, the cell text and
56
+ // the activation rule. `@gjsify/adwaita-nativescript` renders the same widget
57
+ // over `GridLayout` `ItemSpec`s — NativeScript has no subgrid — so the TRACK
58
+ // rule is emitted as a renderer-neutral descriptor and only the mapping onto
59
+ // `grid-template-columns` stays here. `@gjsify/adwaita-core/conformance`'s
60
+ // `DATA_GRID_*_VECTORS` is the table both ports are held to.
54
61
  // Reference: Gtk.Grid usage in the Buchhaltung BWA view (native financial grid).
55
62
  // Reference: refs/libadwaita/src/stylesheet/_colors.scss (separator / card tokens).
56
63
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
57
64
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
58
65
 
59
- /** Horizontal alignment of a column's header + cells. */
60
- export type AdwDataGridAlign = 'start' | 'end' | 'center';
61
-
62
- /** Accounting row emphasis. `normal` is the default plain data row. */
63
- export type AdwDataGridRowVariant = 'normal' | 'section' | 'subtotal' | 'total';
64
-
65
- /** A column descriptor — what to read (`key`), how to label + align it. */
66
- export interface AdwDataGridColumn {
67
- /** The row-object key this column renders. */
68
- key: string;
69
- /** Header text (defaults to `key`). */
70
- label?: string;
71
- /** 'start' | 'end' | 'center'. Defaults to 'start' (or 'end' when numeric). */
72
- align?: AdwDataGridAlign;
73
- /** Fixed CSS track size (e.g. '80px', '6rem') — pins the column width. */
74
- width?: string;
75
- /** Fractional track weight (→ `<flex>fr`) — lets the column grow. */
76
- flex?: number;
77
- /** Render cells in a monospace family (tabular figures apply regardless). */
78
- monospace?: boolean;
79
- /** Convenience: right-align + tabular-nums (numeric columns of figures). */
80
- numeric?: boolean;
81
- }
82
-
83
- /** A row: cell values keyed by column key + optional accounting metadata. */
84
- export interface AdwDataGridRow {
85
- /** Accounting emphasis. Default 'normal'. */
86
- variant?: AdwDataGridRowVariant;
87
- /** Make just this row clickable (overrides the element-level flag). */
88
- interactive?: boolean;
89
- /** Cell values keyed by column key (pre-formatted strings, or numbers). */
90
- [key: string]: string | number | boolean | AdwDataGridRowVariant | undefined;
91
- }
92
-
93
- const VARIANTS: ReadonlySet<string> = new Set(['normal', 'section', 'subtotal', 'total']);
66
+ import {
67
+ dataGridCellText,
68
+ dataGridColumnClasses,
69
+ dataGridRowInteractive,
70
+ dataGridTrackTemplate,
71
+ dataGridTracks,
72
+ normalizeDataGridColumns,
73
+ normalizeDataGridVariant,
74
+ } from '@gjsify/adwaita-core';
75
+ import type { AdwDataGridColumn, AdwDataGridRow } from '@gjsify/adwaita-core';
76
+
77
+ export type {
78
+ AdwDataGridAlign,
79
+ AdwDataGridCellValue,
80
+ AdwDataGridColumn,
81
+ AdwDataGridRow,
82
+ AdwDataGridRowVariant,
83
+ } from '@gjsify/adwaita-core';
94
84
 
95
85
  export class AdwDataGrid extends HTMLElement {
96
86
  private _captionEl!: HTMLDivElement;
@@ -110,7 +100,7 @@ export class AdwDataGrid extends HTMLElement {
110
100
  }
111
101
 
112
102
  set columns(value: ReadonlyArray<AdwDataGridColumn>) {
113
- this._columns = normalizeColumns(value);
103
+ this._columns = normalizeDataGridColumns(value);
114
104
  if (this._initialized) this._render();
115
105
  }
116
106
 
@@ -163,7 +153,7 @@ export class AdwDataGrid extends HTMLElement {
163
153
  this._scrollEl.appendChild(this._tableEl);
164
154
 
165
155
  // Seed from JSON attributes only when the properties were not set.
166
- if (this._columns.length === 0) this._columns = this._parseJsonAttr('columns', normalizeColumns);
156
+ if (this._columns.length === 0) this._columns = this._parseJsonAttr('columns', normalizeDataGridColumns);
167
157
  if (this._rows.length === 0) this._rows = this._parseJsonAttr('rows', (v) => (Array.isArray(v) ? v : []));
168
158
 
169
159
  this.replaceChildren(this._captionEl, this._scrollEl);
@@ -173,7 +163,7 @@ export class AdwDataGrid extends HTMLElement {
173
163
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
174
164
  if (!this._initialized) return;
175
165
  if (name === 'columns') {
176
- this._columns = this._parseJsonAttr('columns', normalizeColumns);
166
+ this._columns = this._parseJsonAttr('columns', normalizeDataGridColumns);
177
167
  this._render();
178
168
  } else if (name === 'rows') {
179
169
  this._rows = this._parseJsonAttr('rows', (v) => (Array.isArray(v) ? (v as AdwDataGridRow[]) : []));
@@ -204,28 +194,21 @@ export class AdwDataGrid extends HTMLElement {
204
194
  this._captionEl.hidden = caption.length === 0;
205
195
  }
206
196
 
207
- /** The CSS `grid-template-columns` value derived from the column descriptors. */
197
+ /**
198
+ * The CSS `grid-template-columns` value derived from the column descriptors.
199
+ *
200
+ * The DERIVATION is core's (`dataGridTracks`), because NativeScript needs the
201
+ * same one for its `ItemSpec`s and can do nothing with a CSS string; the
202
+ * mapping onto CSS text — including the `minmax(0px, 1fr)` slack track whose
203
+ * `0px` must keep its unit to round-trip through the CSSOM — is
204
+ * `dataGridTrackTemplate`.
205
+ */
208
206
  private _trackTemplate(): string {
209
- const anyExplicit = this._columns.some((c) => c.width !== undefined || c.flex !== undefined);
210
- return this._columns
211
- .map((column, index) => {
212
- if (column.width !== undefined) return column.width;
213
- if (column.flex !== undefined) return `${column.flex}fr`;
214
- // With no explicit sizing anywhere, the first (label) column
215
- // absorbs slack so trailing numeric columns hug the right edge.
216
- // `0px` (not `0`) is the CSSOM-canonical form so it round-trips.
217
- if (!anyExplicit && index === 0) return 'minmax(0px, 1fr)';
218
- return 'auto';
219
- })
220
- .join(' ');
207
+ return dataGridTrackTemplate(dataGridTracks(this._columns));
221
208
  }
222
209
 
223
210
  private _columnClasses(column: AdwDataGridColumn): string {
224
- const align: AdwDataGridAlign = column.align ?? (column.numeric ? 'end' : 'start');
225
- const classes = ['adw-data-grid-cell', `align-${align}`];
226
- if (column.numeric) classes.push('numeric');
227
- if (column.monospace) classes.push('mono');
228
- return classes.join(' ');
211
+ return dataGridColumnClasses(column).join(' ');
229
212
  }
230
213
 
231
214
  private _render(): void {
@@ -253,7 +236,7 @@ export class AdwDataGrid extends HTMLElement {
253
236
  const elementInteractive = this.hasAttribute('interactive');
254
237
 
255
238
  this._rows.forEach((rowData, index) => {
256
- const variant = normalizeVariant(rowData.variant);
239
+ const variant = normalizeDataGridVariant(rowData.variant);
257
240
  const row = document.createElement('div');
258
241
  row.className = `adw-data-grid-row variant-${variant}`;
259
242
  row.setAttribute('role', 'row');
@@ -266,25 +249,21 @@ export class AdwDataGrid extends HTMLElement {
266
249
  cell.className = 'adw-data-grid-cell align-start section-cell';
267
250
  cell.setAttribute('role', 'cell');
268
251
  cell.style.gridColumn = '1 / -1';
269
- cell.textContent = cellText(rowData[firstKey]);
252
+ cell.textContent = dataGridCellText(rowData[firstKey]);
270
253
  row.appendChild(cell);
271
254
  } else {
272
255
  for (const column of this._columns) {
273
256
  const cell = document.createElement('div');
274
257
  cell.className = this._columnClasses(column);
275
258
  cell.setAttribute('role', 'cell');
276
- cell.textContent = cellText(rowData[column.key]);
259
+ cell.textContent = dataGridCellText(rowData[column.key]);
277
260
  row.appendChild(cell);
278
261
  }
279
262
  }
280
263
 
281
264
  // A row is clickable only when it is a plain data row AND either the
282
265
  // element opts everything in or the row opts itself in.
283
- const rowInteractive =
284
- variant === 'normal' &&
285
- rowData.interactive !== false &&
286
- (rowData.interactive === true || elementInteractive);
287
- if (rowInteractive) {
266
+ if (dataGridRowInteractive(variant, rowData.interactive, elementInteractive)) {
288
267
  row.classList.add('interactive');
289
268
  row.tabIndex = 0;
290
269
  row.addEventListener('click', () => this._activate(index, rowData));
@@ -305,31 +284,4 @@ export class AdwDataGrid extends HTMLElement {
305
284
  }
306
285
  }
307
286
 
308
- /** Coerce a cell value to display text — pre-formatted strings pass through. */
309
- function cellText(value: string | number | boolean | AdwDataGridRowVariant | undefined): string {
310
- if (value === undefined || value === null) return '';
311
- return String(value);
312
- }
313
-
314
- /** Clamp an arbitrary variant to the known set (unknown → 'normal'). */
315
- function normalizeVariant(variant: unknown): AdwDataGridRowVariant {
316
- return typeof variant === 'string' && VARIANTS.has(variant) ? (variant as AdwDataGridRowVariant) : 'normal';
317
- }
318
-
319
- /** Normalise raw column descriptors to a stable list (drops malformed entries). */
320
- function normalizeColumns(raw: unknown): AdwDataGridColumn[] {
321
- if (!Array.isArray(raw)) return [];
322
- return raw
323
- .filter((c): c is AdwDataGridColumn => typeof c === 'object' && c !== null && 'key' in c)
324
- .map((c) => ({
325
- key: String(c.key),
326
- label: c.label !== undefined ? String(c.label) : undefined,
327
- align: c.align,
328
- width: c.width !== undefined ? String(c.width) : undefined,
329
- flex: typeof c.flex === 'number' ? c.flex : undefined,
330
- monospace: c.monospace === true,
331
- numeric: c.numeric === true,
332
- }));
333
- }
334
-
335
287
  customElements.define('adw-data-grid', AdwDataGrid);