@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
@@ -6,40 +6,86 @@
6
6
  // `navigation-sidebar`); in `page` mode it renders as boxed lists with a
7
7
  // chevron arrow on each row (the AdwPreferencesPage look the native widget
8
8
  // switches to when collapsed).
9
+ //
10
+ // The BEHAVIOUR — the flat index space, the out-of-range rule, which section
11
+ // headers render, the selection-vs-activation split, the filter and the empty
12
+ // state — is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
13
+ // {@link SidebarState}; this element only builds DOM for it and re-emits its
14
+ // changes as events. It used to carry its own copy, which clamped an
15
+ // out-of-range `selected` to the LAST row where libadwaita drops the selection
16
+ // entirely, keyed section headers off the DECLARATION index instead of the rows
17
+ // that actually render, and froze the row list after the first connect. The
18
+ // shared vectors in `@gjsify/adwaita-core/conformance` now pin this element to
19
+ // the C source — see `src/adw-sidebar.spec.ts`.
20
+ //
9
21
  // Sub-elements (declared as children, consumed at connect time — mirrors
10
22
  // adw-tab-page in adw-tab-view):
11
23
  // <adw-sidebar-section title="…"> — a titled group of items. An untitled
12
24
  // section renders a separator before its rows (first section: nothing),
13
25
  // mirroring AdwSidebar's stack header (title vs separator child).
14
- // <adw-sidebar-item title="…" subtitle="…" icon-name="…"> — one row.
26
+ // <adw-sidebar-item title="…" subtitle="…" icon-name="…" disabled hidden> —
27
+ // one row. `disabled` is AdwSidebarItem:enabled=false (bound to the row's
28
+ // `sensitive`), `hidden` is AdwSidebarItem:visible=false.
15
29
  // Attributes (on <adw-sidebar>):
16
30
  // mode (sidebar | page, default sidebar) — mirrors Adw.Sidebar:mode.
17
- // selected (zero-based flat item index, default 0; -1 = no selection,
31
+ // selected (zero-based flat item index; anything that is not a valid position
32
+ // — out of range, negative, fractional, unparseable — means NO selection,
18
33
  // mirroring Gtk.INVALID_LIST_POSITION) — mirrors Adw.Sidebar:selected.
34
+ // Properties: `sections` (the spec list), `filter` (Adw.Sidebar:filter).
19
35
  // Events:
20
- // `notify::selected` (CustomEvent, bubbles, `detail = { selected }`) when the
21
- // selected index changes — mirrors the Adw.Sidebar:selected property.
22
- // `activated` (CustomEvent, bubbles, `detail = { index }`) when an item is
23
- // activated (clicked) — mirrors the Adw.Sidebar::activated signal.
36
+ // `notify::selected` (CustomEvent, bubbles,
37
+ // `detail = { selected, previous, interactive }`) on EVERY selection change,
38
+ // programmatic ones included — mirrors g_object_notify_by_pspec (PROP_SELECTED).
39
+ // `activated` (CustomEvent, bubbles, `detail = { index }`) on every click of a
40
+ // row, including a re-click of the selected one — mirrors AdwSidebar::activated,
41
+ // the documented way to reveal a split view's content pane.
24
42
  // Reference: refs/libadwaita/src/adw-sidebar.c (AdwSidebar behaviour)
25
43
  // Reference: refs/libadwaita/src/adw-sidebar-item.h (AdwSidebarItem properties)
26
44
  // Reference: refs/libadwaita/src/stylesheet/widgets/_sidebars.scss (.navigation-sidebar, sidebar)
27
45
  // Copyright (c) 2025 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
28
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
29
-
30
- /** Index meaning "no selection" — the web mirror of Gtk.INVALID_LIST_POSITION. */
31
- const NO_SELECTION = -1;
32
-
33
- /** GTK symbolic name (e.g. "folder-symbolic") → adwaita-web icon class. */
34
- function iconClass(gtkName: string): string {
35
- const name = gtkName.replace(/-symbolic$/, '');
36
- return name ? `adw-icon adw-icon--${name}` : 'adw-icon';
37
- }
46
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
47
+ // icon nodes are <adw-icon>.
48
+
49
+ import {
50
+ ADW_SIDEBAR_NO_SELECTION,
51
+ SidebarState,
52
+ type AdwSidebarItemSpec,
53
+ type AdwSidebarSectionSpec,
54
+ type SidebarFlatItem,
55
+ type SidebarHeaderSpec,
56
+ type SidebarItemFilter,
57
+ type SidebarSelectionChange,
58
+ } from '@gjsify/adwaita-core';
59
+
60
+ import { createAdwIcon } from './adw-icon.js';
61
+
62
+ // The sidebar consumes its declared children and drops them from the tree, so a
63
+ // later `setAttribute` on one cannot find its sidebar with `closest()`. These
64
+ // keep the link — the web stand-in for the `g_object_bind_property` /
65
+ // `notify::icon-name` bindings that keep a live AdwSidebarItem's labels in sync
66
+ // (adw-sidebar.c:1393-1421). Before this, `observedAttributes` was declared on
67
+ // both child elements and no `attributeChangedCallback` existed to answer it.
68
+ const itemBindings = new WeakMap<AdwSidebarItem, { sidebar: AdwSidebar; spec: AdwSidebarItemSpec }>();
69
+ const sectionBindings = new WeakMap<AdwSidebarSection, { sidebar: AdwSidebar; spec: AdwSidebarSectionSpec }>();
38
70
 
39
71
  /** A single sidebar item. Child of <adw-sidebar-section>; consumed at connect time. */
40
72
  export class AdwSidebarItem extends HTMLElement {
41
73
  static get observedAttributes() {
42
- return ['title', 'subtitle', 'icon-name'];
74
+ return ['title', 'subtitle', 'icon-name', 'disabled', 'hidden'];
75
+ }
76
+
77
+ attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
78
+ const binding = itemBindings.get(this);
79
+ if (!binding) return;
80
+
81
+ const value = newValue ?? '';
82
+ if (name === 'title') binding.spec.title = value;
83
+ else if (name === 'subtitle') binding.spec.subtitle = value;
84
+ else if (name === 'icon-name') binding.spec.iconName = value;
85
+ else if (name === 'disabled') binding.spec.enabled = newValue === null;
86
+ else if (name === 'hidden') binding.spec.visible = newValue === null;
87
+
88
+ binding.sidebar.refresh();
43
89
  }
44
90
  }
45
91
 
@@ -48,14 +94,23 @@ export class AdwSidebarSection extends HTMLElement {
48
94
  static get observedAttributes() {
49
95
  return ['title'];
50
96
  }
97
+
98
+ attributeChangedCallback(_name: string, _oldValue: string | null, newValue: string | null) {
99
+ const binding = sectionBindings.get(this);
100
+ if (!binding) return;
101
+
102
+ binding.spec.title = newValue ?? '';
103
+ binding.sidebar.refresh();
104
+ }
51
105
  }
52
106
 
53
107
  export class AdwSidebar extends HTMLElement {
108
+ private readonly _state = new SidebarState();
54
109
  private _listEl!: HTMLDivElement;
55
- private _rows: HTMLButtonElement[] = [];
56
- private _selected = 0;
57
- private _mode: 'sidebar' | 'page' = 'sidebar';
110
+ private _rows: { el: HTMLButtonElement; index: number }[] = [];
58
111
  private _initialized = false;
112
+ /** Guards the `selected` attribute write we make ourselves from re-entering. */
113
+ private _reflecting = false;
59
114
 
60
115
  static get observedAttributes() {
61
116
  return ['mode', 'selected'];
@@ -63,172 +118,276 @@ export class AdwSidebar extends HTMLElement {
63
118
 
64
119
  /** Zero-based flat index of the selected item, or -1 for no selection. */
65
120
  get selected(): number {
66
- return this._selected;
121
+ return this._state.selected;
67
122
  }
68
123
 
69
124
  set selected(value: number) {
70
125
  this.setAttribute('selected', String(value));
71
126
  }
72
127
 
128
+ /** The selected item's spec, or undefined — Adw.Sidebar:selected-item. */
129
+ get selectedItem(): AdwSidebarItemSpec | undefined {
130
+ return this._state.selectedItem;
131
+ }
132
+
73
133
  /** 'sidebar' (flat navigation list) or 'page' (boxed lists). */
74
134
  get mode(): 'sidebar' | 'page' {
75
- return this._mode;
135
+ return this._state.mode;
76
136
  }
77
137
 
78
138
  set mode(value: 'sidebar' | 'page') {
79
139
  this.setAttribute('mode', value);
80
140
  }
81
141
 
142
+ /** The section/item model. Assigning replaces it, the way adw_sidebar_remove_all + append would. */
143
+ get sections(): readonly AdwSidebarSectionSpec[] {
144
+ return this._state.sections;
145
+ }
146
+
147
+ set sections(value: readonly AdwSidebarSectionSpec[]) {
148
+ this._state.setSections(value);
149
+ this._rebuild();
150
+ }
151
+
152
+ /** Adw.Sidebar:filter — decides which rows render, never the selection index space. */
153
+ get filter(): SidebarItemFilter | null {
154
+ return this._state.filter;
155
+ }
156
+
157
+ set filter(value: SidebarItemFilter | null) {
158
+ this._state.setFilter(value);
159
+ this._rebuild();
160
+ }
161
+
82
162
  connectedCallback() {
83
163
  if (this._initialized) return;
84
164
  this._initialized = true;
85
165
 
86
- // Snapshot the declared <adw-sidebar-section> children before we take
87
- // over the subtree — their title + <adw-sidebar-item> descendants become
88
- // the rendered sections and rows.
89
- const sections = Array.from(this.querySelectorAll('adw-sidebar-section')) as AdwSidebarSection[];
90
-
91
166
  this._listEl = document.createElement('div');
92
167
  this._listEl.className = 'adw-sidebar-list';
93
168
  this._listEl.setAttribute('role', 'listbox');
94
169
 
95
- this._rows = [];
96
-
97
- sections.forEach((section, sectionIndex) => {
98
- const title = section.getAttribute('title') ?? '';
99
- const items = Array.from(section.querySelectorAll('adw-sidebar-item')) as AdwSidebarItem[];
100
-
101
- // The section header — a heading when titled, an empty separator
102
- // otherwise (mirrors AdwSidebar's header stack: "title" vs
103
- // "separator" child). The very first section never draws a leading
104
- // separator (matches the `.header.first` rule).
105
- const header = document.createElement('div');
106
- header.className = 'adw-sidebar-section-header';
107
- if (title) {
108
- header.classList.add('has-title');
109
- const heading = document.createElement('span');
110
- heading.className = 'adw-sidebar-section-heading';
111
- heading.textContent = title;
112
- header.appendChild(heading);
113
- if (sectionIndex === 0) header.classList.add('first');
114
- } else {
115
- header.classList.add('separator');
116
- if (sectionIndex === 0) header.hidden = true;
117
- }
118
- this._listEl.appendChild(header);
119
-
120
- const sectionEl = document.createElement('div');
121
- sectionEl.className = 'adw-sidebar-section';
170
+ // Seed the model BEFORE subscribing: connecting is not a change anyone
171
+ // asked to hear about, and `setSections` runs the 0 → n auto-select.
172
+ this._state.setSections(this._readDeclaredSections());
173
+ this._state.setMode(this._readModeAttr());
174
+ // A declared `selected` wins over that auto-select. libadwaita would let
175
+ // the auto-select clobber a builder-set property (items arrive after
176
+ // properties, and items_changed_cb then selects 0); for a declarative
177
+ // element the authored attribute has to survive its own children.
178
+ if (this.hasAttribute('selected')) this._state.setSelected(this._readSelectedAttr());
122
179
 
123
- items.forEach((item) => {
124
- const flatIndex = this._rows.length;
125
- const itemTitle = item.getAttribute('title') ?? '';
126
- const subtitle = item.getAttribute('subtitle') ?? '';
127
- const icon = item.getAttribute('icon-name') ?? '';
128
-
129
- const row = document.createElement('button');
130
- row.type = 'button';
131
- row.className = 'adw-sidebar-item';
132
- row.setAttribute('role', 'option');
133
-
134
- const iconEl = document.createElement('span');
135
- iconEl.className = `${iconClass(icon)} adw-sidebar-item-icon`;
136
- iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
137
- iconEl.hidden = icon.length === 0;
138
- row.appendChild(iconEl);
139
-
140
- const textEl = document.createElement('span');
141
- textEl.className = 'adw-sidebar-item-text';
142
-
143
- const titleEl = document.createElement('span');
144
- titleEl.className = 'adw-sidebar-item-title';
145
- titleEl.textContent = itemTitle;
146
- titleEl.hidden = itemTitle.length === 0;
147
- textEl.appendChild(titleEl);
148
-
149
- const subtitleEl = document.createElement('span');
150
- subtitleEl.className = 'adw-sidebar-item-subtitle';
151
- subtitleEl.textContent = subtitle;
152
- subtitleEl.hidden = subtitle.length === 0;
153
- textEl.appendChild(subtitleEl);
154
-
155
- row.appendChild(textEl);
156
-
157
- // Page mode adds a trailing chevron on every row, the way the
158
- // boxed-list AdwActionRow carries a `go-next-symbolic` arrow.
159
- const arrowEl = document.createElement('span');
160
- arrowEl.className = 'adw-icon adw-icon--go-next adw-sidebar-item-arrow';
161
- arrowEl.setAttribute('aria-hidden', 'true');
162
- row.appendChild(arrowEl);
163
-
164
- row.addEventListener('click', () => this._activateIndex(flatIndex));
165
- sectionEl.appendChild(row);
166
- this._rows.push(row);
167
- });
168
-
169
- this._listEl.appendChild(sectionEl);
170
- });
180
+ this._state.subscribe((change) => this._onSelectionChanged(change));
171
181
 
172
182
  this.replaceChildren(this._listEl);
173
-
174
- this._mode = this._readModeAttr();
175
- this._selected = this._readSelectedAttr();
176
- this._render();
183
+ this._applyMode();
184
+ this._rebuild();
185
+ this._reflectSelected();
177
186
  }
178
187
 
179
188
  attributeChangedCallback(name: string) {
180
- if (!this._initialized) return;
189
+ if (!this._initialized || this._reflecting) return;
190
+
181
191
  if (name === 'selected') {
182
- const next = this._readSelectedAttr();
183
- if (next !== this._selected) {
184
- this._selected = next;
185
- this._render();
186
- }
192
+ this._state.setSelected(this._readSelectedAttr());
193
+ // Normalise even when nothing moved, so `selected="5"` on a 3-item
194
+ // sidebar does not keep claiming 5 while the property reads -1.
195
+ this._reflectSelected();
187
196
  return;
188
197
  }
189
- if (name === 'mode') {
190
- const next = this._readModeAttr();
191
- if (next !== this._mode) {
192
- this._mode = next;
193
- this._render();
198
+ if (name === 'mode' && this._state.setMode(this._readModeAttr())) {
199
+ this._applyMode();
200
+ this._applySelection();
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Re-derive after a declared `<adw-sidebar-item>` / `<adw-sidebar-section>`
206
+ * attribute changed. The item model keeps its shape, so the selection cannot
207
+ * move — exactly like the GObject property bindings this stands in for.
208
+ */
209
+ refresh(): void {
210
+ if (!this._initialized) return;
211
+ this._state.refresh();
212
+ this._rebuild();
213
+ }
214
+
215
+ /** Snapshot the declared children into specs, and bind them for live updates. */
216
+ private _readDeclaredSections(): AdwSidebarSectionSpec[] {
217
+ const sections: AdwSidebarSectionSpec[] = [];
218
+
219
+ for (const sectionEl of Array.from(this.querySelectorAll('adw-sidebar-section')) as AdwSidebarSection[]) {
220
+ const items: AdwSidebarItemSpec[] = [];
221
+
222
+ for (const itemEl of Array.from(sectionEl.querySelectorAll('adw-sidebar-item')) as AdwSidebarItem[]) {
223
+ const item: AdwSidebarItemSpec = {
224
+ title: itemEl.getAttribute('title') ?? '',
225
+ subtitle: itemEl.getAttribute('subtitle') ?? '',
226
+ iconName: itemEl.getAttribute('icon-name') ?? '',
227
+ enabled: !itemEl.hasAttribute('disabled'),
228
+ visible: !itemEl.hasAttribute('hidden'),
229
+ };
230
+ itemBindings.set(itemEl, { sidebar: this, spec: item });
231
+ items.push(item);
194
232
  }
233
+
234
+ const section: AdwSidebarSectionSpec = { title: sectionEl.getAttribute('title') ?? '', items };
235
+ sectionBindings.set(sectionEl, { sidebar: this, spec: section });
236
+ sections.push(section);
195
237
  }
238
+
239
+ return sections;
196
240
  }
197
241
 
198
242
  private _readModeAttr(): 'sidebar' | 'page' {
199
243
  return this.getAttribute('mode') === 'page' ? 'page' : 'sidebar';
200
244
  }
201
245
 
246
+ /**
247
+ * `parseFloat`, not `parseInt`: the core rejects a fractional position, and
248
+ * truncating `"1.5"` to row 1 here would hide that from it. An unparseable
249
+ * value is NaN, which the core turns into "no selection".
250
+ */
202
251
  private _readSelectedAttr(): number {
203
- const raw = Number.parseInt(this.getAttribute('selected') ?? '0', 10);
204
- if (Number.isNaN(raw)) return 0;
205
- if (raw < 0) return NO_SELECTION;
206
- const max = Math.max(0, this._rows.length - 1);
207
- return Math.min(raw, max);
208
- }
209
-
210
- /** Click handler — selects the row (if changed) and always emits `activated`. */
211
- private _activateIndex(index: number): void {
212
- if (index !== this._selected) {
213
- this._selected = index;
214
- // Keep the attribute in sync without re-entering via the guarded
215
- // attributeChangedCallback (value already matches, so it no-ops).
216
- this.setAttribute('selected', String(index));
217
- this._render();
218
- this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
219
- }
252
+ return Number.parseFloat(this.getAttribute('selected') ?? '');
253
+ }
254
+
255
+ private _reflectSelected(): void {
256
+ this._reflecting = true;
257
+ this.setAttribute('selected', String(this._state.selected));
258
+ this._reflecting = false;
259
+ }
260
+
261
+ private _onSelectionChanged(change: SidebarSelectionChange): void {
262
+ this._reflectSelected();
263
+ this._applySelection();
264
+ this.dispatchEvent(
265
+ new CustomEvent('notify::selected', {
266
+ bubbles: true,
267
+ detail: { selected: change.selected, previous: change.previous, interactive: change.interactive },
268
+ }),
269
+ );
270
+ }
271
+
272
+ /** Click handler — the core applies the selection, then `activated` fires. */
273
+ private _activate(index: number): void {
274
+ if (!this._state.activate(index).activated) return;
220
275
  this.dispatchEvent(new CustomEvent('activated', { bubbles: true, detail: { index } }));
221
276
  }
222
277
 
223
- private _render(): void {
224
- this.classList.toggle('page', this._mode === 'page');
225
- this.classList.toggle('mode-sidebar', this._mode === 'sidebar');
278
+ /** Rebuild the rendered rows + headers from the core's derived model. */
279
+ private _rebuild(): void {
280
+ const headers = new Map<number, SidebarHeaderSpec>();
281
+ for (const header of this._state.headers) headers.set(header.sectionIndex, header);
282
+
283
+ // Consecutive rendered rows of one section share a header and a container.
284
+ const groups: { sectionIndex: number; items: SidebarFlatItem[] }[] = [];
285
+ for (const flat of this._state.visibleItems) {
286
+ const last = groups[groups.length - 1];
287
+ if (last && last.sectionIndex === flat.sectionIndex) last.items.push(flat);
288
+ else groups.push({ sectionIndex: flat.sectionIndex, items: [flat] });
289
+ }
290
+
291
+ this._rows = [];
292
+ const children: HTMLElement[] = [];
293
+
294
+ for (const group of groups) {
295
+ const header = headers.get(group.sectionIndex);
296
+ if (header) children.push(this._createHeader(header));
297
+
298
+ const sectionEl = document.createElement('div');
299
+ sectionEl.className = 'adw-sidebar-section';
300
+ for (const flat of group.items) {
301
+ const row = this._createRow(flat);
302
+ sectionEl.appendChild(row);
303
+ this._rows.push({ el: row, index: flat.index });
304
+ }
305
+ children.push(sectionEl);
306
+ }
307
+
308
+ this._listEl.replaceChildren(...children);
309
+ this.classList.toggle('empty', this._state.isEmpty);
310
+ this._applySelection();
311
+ }
312
+
313
+ private _createHeader(header: SidebarHeaderSpec): HTMLDivElement {
314
+ const el = document.createElement('div');
315
+ el.className = 'adw-sidebar-section-header';
316
+
317
+ if (header.kind === 'title') {
318
+ el.classList.add('has-title');
319
+ const heading = document.createElement('span');
320
+ heading.className = 'adw-sidebar-section-heading';
321
+ heading.textContent = header.title;
322
+ el.appendChild(heading);
323
+ } else {
324
+ el.classList.add('separator');
325
+ }
326
+ if (header.first) el.classList.add('first');
327
+
328
+ return el;
329
+ }
330
+
331
+ private _createRow(flat: SidebarFlatItem): HTMLButtonElement {
332
+ const item = flat.item;
333
+
334
+ const row = document.createElement('button');
335
+ row.type = 'button';
336
+ row.className = 'adw-sidebar-item';
337
+ row.setAttribute('role', 'option');
338
+ // `visible` / `enabled` are bound to the row's `visible` / `sensitive`
339
+ // in create_row (adw-sidebar.c:1382-1383): the row still EXISTS and still
340
+ // owns its flat index, it just does not show or respond.
341
+ row.hidden = item.visible === false;
342
+ row.disabled = item.enabled === false;
343
+
344
+ const iconEl = createAdwIcon(item.iconName ?? null, 'adw-sidebar-item-icon');
345
+ iconEl.hidden = !flat.iconVisible;
346
+ row.appendChild(iconEl);
347
+
348
+ const textEl = document.createElement('span');
349
+ textEl.className = 'adw-sidebar-item-text';
350
+
351
+ const titleEl = document.createElement('span');
352
+ titleEl.className = 'adw-sidebar-item-title';
353
+ titleEl.textContent = item.title;
354
+ titleEl.hidden = !flat.titleVisible;
355
+ textEl.appendChild(titleEl);
356
+
357
+ const subtitleEl = document.createElement('span');
358
+ subtitleEl.className = 'adw-sidebar-item-subtitle';
359
+ subtitleEl.textContent = item.subtitle ?? '';
360
+ subtitleEl.hidden = !flat.subtitleVisible;
361
+ textEl.appendChild(subtitleEl);
362
+
363
+ row.appendChild(textEl);
364
+
365
+ // Page mode adds a trailing chevron on every row, the way the
366
+ // boxed-list AdwActionRow carries a `go-next-symbolic` arrow.
367
+ row.appendChild(createAdwIcon('go-next', 'adw-sidebar-item-arrow'));
368
+
369
+ row.addEventListener('click', () => this._activate(flat.index));
370
+
371
+ return row;
372
+ }
373
+
374
+ private _applyMode(): void {
375
+ const page = this._state.mode === 'page';
376
+ this.classList.toggle('page', page);
377
+ this.classList.toggle('mode-sidebar', !page);
378
+ }
226
379
 
227
- this._rows.forEach((row, index) => {
228
- const isSelected = index === this._selected;
229
- row.classList.toggle('selected', isSelected);
230
- row.setAttribute('aria-selected', String(isSelected));
231
- row.tabIndex = isSelected || (this._selected === NO_SELECTION && index === 0) ? 0 : -1;
380
+ private _applySelection(): void {
381
+ const selected = this._state.selected;
382
+ // Page mode tracks the selection but never paints it (adw-sidebar.c:2948-2951):
383
+ // its rows are plain boxed-list AdwActionRows with no selected state.
384
+ const paint = this._state.selectionVisible;
385
+
386
+ this._rows.forEach((row, position) => {
387
+ const isSelected = row.index === selected;
388
+ row.el.classList.toggle('selected', isSelected && paint);
389
+ row.el.setAttribute('aria-selected', String(isSelected));
390
+ row.el.tabIndex = isSelected || (selected === ADW_SIDEBAR_NO_SELECTION && position === 0) ? 0 : -1;
232
391
  });
233
392
  }
234
393
  }
@@ -17,7 +17,7 @@
17
17
  // title/subtitle text column added to match Adw.SpinRow; the adjustment state
18
18
  // machine composed from @gjsify/adwaita-core.
19
19
 
20
- import { SpinState } from '@gjsify/adwaita-core';
20
+ import { SpinState, deriveRowLabels } from '@gjsify/adwaita-core';
21
21
 
22
22
  export class AdwSpinRow extends HTMLElement {
23
23
  private _input!: HTMLInputElement;
@@ -123,10 +123,16 @@ export class AdwSpinRow extends HTMLElement {
123
123
  }
124
124
 
125
125
  private _renderText() {
126
- this._titleEl.textContent = this.getAttribute('title') ?? '';
127
- const subtitle = this.getAttribute('subtitle') ?? '';
128
- this._subtitleEl.textContent = subtitle;
129
- this._subtitleEl.hidden = subtitle.length === 0;
126
+ // The `string_is_not_empty` label rule, from core — this block was one of
127
+ // six hand-rolled copies, all of which omitted the TITLE half.
128
+ const labels = deriveRowLabels({
129
+ title: this.getAttribute('title'),
130
+ subtitle: this.getAttribute('subtitle'),
131
+ });
132
+ this._titleEl.textContent = labels.title;
133
+ this._titleEl.hidden = !labels.titleVisible;
134
+ this._subtitleEl.textContent = labels.subtitle;
135
+ this._subtitleEl.hidden = !labels.subtitleVisible;
130
136
  }
131
137
 
132
138
  /** A stepper press — the interactive path, so it emits `notify::value`. */
@@ -1,10 +1,22 @@
1
1
  // <adw-spinner> — A continuously-spinning loading indicator.
2
- // Attributes: size (px, default 24).
2
+ // Attributes: size (px; the measured minimum, 16, when absent or unusable).
3
+ //
4
+ // The ring geometry is `Adw.Spinner`'s, not a guess: the diameter and the stroke
5
+ // come from `@gjsify/adwaita-core`'s `spinnerGeometry`, which is
6
+ // `AdwSpinnerPaintable`'s `radius = min(floor(min(w, h) / 2), 32)` with
7
+ // `lineWidth = diameter / 8`. This element used to default to 24px and stroke
8
+ // `max(2, round(size / 12))`, which draws 2px where Adwaita draws 3px at 24, 4px
9
+ // against 6px at 48 and 5px against 8px at 64 — the ring was visibly thinner than
10
+ // GTK's at every size above the minimum.
11
+ //
12
+ // Reference: refs/libadwaita/src/adw-spinner.c (MIN_SIZE, adw_spinner_measure)
13
+ // Reference: refs/libadwaita/src/adw-spinner-paintable.c (MAX_RADIUS, calculate_line_width)
3
14
  // Reference: refs/adwaita-web/adwaita-web/scss/_spinner.scss
4
- // Reference: refs/libadwaita (Adw.Spinner)
5
15
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
6
16
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
7
17
 
18
+ import { resolveSpinnerSize, spinnerGeometry } from '@gjsify/adwaita-core';
19
+
8
20
  export class AdwSpinner extends HTMLElement {
9
21
  static get observedAttributes() {
10
22
  return ['size'];
@@ -19,12 +31,15 @@ export class AdwSpinner extends HTMLElement {
19
31
  }
20
32
 
21
33
  private _sync() {
22
- const size = parseFloat(this.getAttribute('size') || '24');
23
- this.style.width = `${size}px`;
24
- this.style.height = `${size}px`;
25
- // Scale the ring thickness with the diameter (libadwaita's arc thickens
26
- // with size); clamp so tiny spinners stay visible.
27
- this.style.borderWidth = `${Math.max(2, Math.round(size / 12))}px`;
34
+ // The element IS the ring, so it takes the DRAWN diameter rather than the
35
+ // requested box: a request below 16 is not representable (GTK never
36
+ // allocates below a widget's minimum) and one above 64 draws a 64px ring,
37
+ // which is Adwaita's documented "never larger than 64×64".
38
+ const size = resolveSpinnerSize(this.getAttribute('size'));
39
+ const { diameter, lineWidth } = spinnerGeometry(size, size);
40
+ this.style.width = `${diameter}px`;
41
+ this.style.height = `${diameter}px`;
42
+ this.style.borderWidth = `${lineWidth}px`;
28
43
  }
29
44
  }
30
45