@gjsify/adwaita-web 0.39.0 → 0.40.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.
@@ -0,0 +1 @@
1
+ export declare const AdwButtonTest: () => Promise<void>;
@@ -0,0 +1 @@
1
+ export declare const AdwEntryTest: () => Promise<void>;
@@ -0,0 +1 @@
1
+ export declare const AdwMenuButtonTest: () => Promise<void>;
@@ -1,9 +1,15 @@
1
1
  export declare class AdwEntry extends HTMLElement {
2
2
  private _input;
3
3
  private _initialized;
4
+ private _maxLength;
4
5
  static get observedAttributes(): string[];
5
6
  get value(): string;
6
7
  set value(v: string);
8
+ /** `Gtk.Entry:max-length` — 0 means unlimited. Counted in CODE POINTS. */
9
+ get maxLength(): number;
10
+ set maxLength(value: number);
11
+ /** `Gtk.Entry:text-length` — code points, not UTF-16 units. */
12
+ get textLength(): number;
7
13
  /** The inner native input (for focus/selection). */
8
14
  get input(): HTMLInputElement;
9
15
  connectedCallback(): void;
@@ -1,12 +1,13 @@
1
+ import './adw-window-title.js';
1
2
  export declare class AdwHeaderBar extends HTMLElement {
2
3
  private _initialized;
3
4
  private _startEl;
4
5
  private _centerEl;
5
6
  private _endEl;
6
7
  /**
7
- * The generated title span, or `null` when a `slot="center"` title-widget
8
- * took the centre. `Adw.HeaderBar` has the same either/or: setting
9
- * `title-widget` replaces the derived `AdwWindowTitle` outright.
8
+ * The derived `<adw-window-title>`, or `null` when a `slot="center"`
9
+ * title-widget took the centre. `Adw.HeaderBar` has the same either/or:
10
+ * setting `title-widget` replaces the derived `AdwWindowTitle` outright.
10
11
  */
11
12
  private _titleEl;
12
13
  static get observedAttributes(): string[];
@@ -24,6 +25,6 @@ export declare class AdwHeaderBar extends HTMLElement {
24
25
  * kept whatever it was created with. `Adw.HeaderBar`'s derived title widget
25
26
  * is bound to the property and re-renders on every change.
26
27
  */
27
- attributeChangedCallback(name: string): void;
28
+ attributeChangedCallback(): void;
28
29
  private _renderTitle;
29
30
  }
@@ -1,11 +1,14 @@
1
- import type { SplitButtonDirection } from '@gjsify/adwaita-core';
1
+ import type { AdwMenuEntry, SplitButtonDirection } from '@gjsify/adwaita-core';
2
2
  import './adw-popover.js';
3
- /** A menu entry. `id` defaults to `label` on activation; `icon` is optional. */
4
- export interface AdwMenuItem {
5
- id?: string;
6
- label: string;
7
- icon?: string;
8
- }
3
+ /**
4
+ * A menu entry — `@gjsify/adwaita-core`'s {@link AdwMenuEntry}, re-exported under the
5
+ * name this element has always used.
6
+ *
7
+ * It used to be a SECOND declaration of the same shape, and the NativeScript menu
8
+ * button declared a THIRD that was missing `icon` entirely. One type, so a consumer
9
+ * writing menu entries writes the same object for either renderer.
10
+ */
11
+ export type AdwMenuItem = AdwMenuEntry;
9
12
  export declare class AdwMenuButton extends HTMLElement {
10
13
  private _buttonEl;
11
14
  private _popoverEl;
@@ -27,6 +30,14 @@ export declare class AdwMenuButton extends HTMLElement {
27
30
  set direction(value: SplitButtonDirection);
28
31
  connectedCallback(): void;
29
32
  attributeChangedCallback(name: string): void;
33
+ /**
34
+ * The `menu` attribute, through the core parser the split button already used.
35
+ *
36
+ * The copy this replaces was weaker in a way markup can reach: it kept `id` and
37
+ * `icon` WHATEVER their runtime type, so `{"label":"Open","id":7}` produced an
38
+ * entry whose `id` is a number in a field typed `string` — and `id` is what the
39
+ * activation event reports. The core parser keeps only string-typed extras.
40
+ */
30
41
  private _parseMenuAttr;
31
42
  private _onPopoverToggled;
32
43
  private _render;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -41,14 +41,14 @@
41
41
  "@codemirror/language": "^6.12.4",
42
42
  "@codemirror/state": "^6.7.0",
43
43
  "@codemirror/view": "^6.43.4",
44
- "@gjsify/adwaita-core": "^0.39.0",
45
- "@gjsify/adwaita-fonts": "^0.39.0",
46
- "@gjsify/adwaita-icons": "^0.39.0",
44
+ "@gjsify/adwaita-core": "^0.40.0",
45
+ "@gjsify/adwaita-fonts": "^0.40.0",
46
+ "@gjsify/adwaita-icons": "^0.40.0",
47
47
  "@lezer/highlight": "^1.2.3"
48
48
  },
49
49
  "devDependencies": {
50
- "@gjsify/cli": "^0.39.0",
51
- "@gjsify/unit": "^0.39.0",
50
+ "@gjsify/cli": "^0.40.0",
51
+ "@gjsify/unit": "^0.40.0",
52
52
  "sass": "^1.101.0",
53
53
  "typescript": "^6.0.3"
54
54
  },
@@ -0,0 +1,59 @@
1
+ // DOM-level tests for <adw-button>'s style classes.
2
+ //
3
+ // The element and its NativeScript twin each kept their own attribute→class table,
4
+ // and the two disagreed: `circular` was here and not there. The table now lives in
5
+ // `@gjsify/adwaita-core`, and these drive its vectors through the REAL element, so a
6
+ // renderer that goes back to a private copy fails on the row that names the class.
7
+
8
+ import { describe, expect, it } from '@gjsify/unit';
9
+ import { BUTTON_STYLE_CLASS_VECTORS } from '@gjsify/adwaita-core/conformance';
10
+
11
+ /** The boolean attributes this element exposes — the short spellings. */
12
+ const WEB_ATTRIBUTES = new Set(['flat', 'suggested', 'destructive', 'circular', 'pill']);
13
+
14
+ /** Mount a button carrying the given boolean attributes; return the inner classes. */
15
+ function mountWithStyles(names: readonly string[]): string[] {
16
+ const el = document.createElement('adw-button');
17
+ el.setAttribute('label', 'Click me');
18
+ for (const name of names) el.setAttribute(name, '');
19
+ document.body.appendChild(el);
20
+ const btn = el.querySelector('button') as HTMLButtonElement;
21
+ return Array.from(btn.classList).filter((cls) => cls !== 'adw-button');
22
+ }
23
+
24
+ function unmountAll(): void {
25
+ for (const el of Array.from(document.querySelectorAll('adw-button'))) el.remove();
26
+ }
27
+
28
+ export const AdwButtonTest = async () => {
29
+ // Rows naming a class the LONG way (`suggested-action`) are the NativeScript
30
+ // variant spelling — this element has no such attribute. They are partitioned
31
+ // out here rather than skipped inside the loop, and the partition is asserted,
32
+ // so a row cannot fall out of coverage unnoticed.
33
+ const reachable = BUTTON_STYLE_CLASS_VECTORS.filter((v) => v.names.every((n) => WEB_ATTRIBUTES.has(n)));
34
+ const unreachable = BUTTON_STYLE_CLASS_VECTORS.filter((v) => !v.names.every((n) => WEB_ATTRIBUTES.has(n)));
35
+
36
+ await describe('<adw-button> style classes', async () => {
37
+ for (const { names, classes, rule } of reachable) {
38
+ await it(`[${names.join(', ')}] → [${classes.join(', ')}] — ${rule}`, () => {
39
+ expect(mountWithStyles(names)).toStrictEqual([...classes]);
40
+ unmountAll();
41
+ });
42
+ }
43
+
44
+ await it('the rows this element cannot express are exactly the long spellings', () => {
45
+ const names = unreachable.flatMap((v) => v.names).filter((n) => !WEB_ATTRIBUTES.has(n));
46
+ expect(names).toStrictEqual(['suggested-action', 'suggessted', 'not-a-style']);
47
+ });
48
+
49
+ await it('a style attribute added later is picked up', () => {
50
+ const el = document.createElement('adw-button');
51
+ el.setAttribute('label', 'Send');
52
+ document.body.appendChild(el);
53
+ el.setAttribute('suggested', '');
54
+ const btn = el.querySelector('button') as HTMLButtonElement;
55
+ expect(btn.classList.contains('suggested-action')).toBe(true);
56
+ unmountAll();
57
+ });
58
+ });
59
+ };
@@ -0,0 +1,73 @@
1
+ // DOM-level tests for <adw-entry>'s length arithmetic.
2
+ //
3
+ // The element had NO max length and no text length, while its NativeScript twin has
4
+ // composed `@gjsify/adwaita-core`'s since it shipped — so the same consumer got a
5
+ // limit on one renderer and none on the other. These drive the core's own vectors
6
+ // against the real element, which is what makes the delegation checkable rather
7
+ // than merely claimed: a re-implementation that counts UTF-16 units instead of code
8
+ // points fails on the row that names it.
9
+
10
+ import { describe, expect, it } from '@gjsify/unit';
11
+ import { ENTRY_MAX_LENGTH_VECTORS, ENTRY_TEXT_LENGTH_VECTORS } from '@gjsify/adwaita-core/conformance';
12
+
13
+ import type { AdwEntry } from './elements/adw-entry.js';
14
+
15
+ /** Mount an entry with the given attributes. */
16
+ function mountEntry(attrs: Record<string, string> = {}): AdwEntry {
17
+ const entry = document.createElement('adw-entry') as AdwEntry;
18
+ for (const [name, value] of Object.entries(attrs)) entry.setAttribute(name, value);
19
+ document.body.appendChild(entry);
20
+ return entry;
21
+ }
22
+
23
+ function unmountAll(): void {
24
+ for (const entry of Array.from(document.querySelectorAll('adw-entry'))) entry.remove();
25
+ }
26
+
27
+ export const AdwEntryTest = async () => {
28
+ await describe('<adw-entry> text length', async () => {
29
+ for (const { text, length, rule } of ENTRY_TEXT_LENGTH_VECTORS) {
30
+ await it(`${JSON.stringify(text)} → ${length} — ${rule}`, () => {
31
+ const entry = mountEntry();
32
+ entry.value = text;
33
+ expect(entry.textLength).toBe(length);
34
+ unmountAll();
35
+ });
36
+ }
37
+ });
38
+
39
+ await describe('<adw-entry> max length', async () => {
40
+ for (const { text, maxLength, clamped, length, rule } of ENTRY_MAX_LENGTH_VECTORS) {
41
+ await it(`${JSON.stringify(text)} @ ${maxLength} — ${rule}`, () => {
42
+ const entry = mountEntry({ maxlength: String(maxLength) });
43
+ entry.value = text;
44
+ expect(entry.value).toBe(clamped);
45
+ expect(entry.textLength).toBe(length);
46
+ unmountAll();
47
+ });
48
+ }
49
+
50
+ await it('clamps what the ATTRIBUTE writes, not only the property', () => {
51
+ const entry = mountEntry({ maxlength: '5' });
52
+ entry.setAttribute('value', 'Ada Lovelace');
53
+ expect(entry.value).toBe('Ada L');
54
+ unmountAll();
55
+ });
56
+
57
+ await it('re-clamps when the limit is lowered under the current text', () => {
58
+ const entry = mountEntry();
59
+ entry.value = 'Ada Lovelace';
60
+ entry.maxLength = 3;
61
+ expect(entry.value).toBe('Ada');
62
+ unmountAll();
63
+ });
64
+
65
+ await it('an unusable limit means unlimited, never "truncate to empty"', () => {
66
+ const entry = mountEntry({ maxlength: 'nonsense' });
67
+ entry.value = 'Ada Lovelace';
68
+ expect(entry.maxLength).toBe(0);
69
+ expect(entry.value).toBe('Ada Lovelace');
70
+ unmountAll();
71
+ });
72
+ });
73
+ };
@@ -78,4 +78,39 @@ export const AdwHeaderBarTest = async () => {
78
78
  unmountAll();
79
79
  });
80
80
  });
81
+
82
+ // The centre is the `<adw-window-title>` Adw.HeaderBar itself derives, so the three
83
+ // rules that element holds through @gjsify/adwaita-core reach the header bar too.
84
+ // None of them did while the centre was a bare span.
85
+ await describe('<adw-header-bar> derives an <adw-window-title>', async () => {
86
+ await it('builds one rather than a bare span', () => {
87
+ const bar = mountHeaderBar({ title: 'Documents' });
88
+ expect(bar.querySelector('adw-window-title')).toBeTruthy();
89
+ unmountAll();
90
+ });
91
+
92
+ await it('renders a subtitle, which it could not before', () => {
93
+ const bar = mountHeaderBar({ title: 'Documents', subtitle: '12 items' });
94
+ expect(centerText(bar)).toContain('Documents');
95
+ expect(centerText(bar)).toContain('12 items');
96
+ unmountAll();
97
+ });
98
+
99
+ await it('hides the title line when the title is empty — the blank-line bug', () => {
100
+ // adw-window-title.c:207-208. A header bar with only a subtitle used to
101
+ // reserve an empty line above it, because the span was always there.
102
+ const bar = mountHeaderBar({ title: '', subtitle: 'Loading…' });
103
+ const titleEl = bar.querySelector('.adw-window-title-title') as HTMLElement | null;
104
+ expect(titleEl?.hidden).toBe(true);
105
+ unmountAll();
106
+ });
107
+
108
+ await it('a subtitle survives a title write', () => {
109
+ const bar = mountHeaderBar({ title: 'Documents', subtitle: '12 items' });
110
+ bar.setAttribute('title', 'Pictures');
111
+ expect(centerText(bar)).toContain('Pictures');
112
+ expect(centerText(bar)).toContain('12 items');
113
+ unmountAll();
114
+ });
115
+ });
81
116
  };
@@ -0,0 +1,52 @@
1
+ // DOM-level tests for <adw-menu-button>'s `menu` attribute.
2
+ //
3
+ // The element parsed the JSON itself, and the copy was weaker than the core parser
4
+ // the split button next to it already used: it kept `id` and `icon` whatever their
5
+ // runtime type, so author markup could put a number in a field typed `string` — and
6
+ // `id` is exactly what the activation event reports. These drive the core's parse
7
+ // vectors against the real element.
8
+
9
+ import { describe, expect, it } from '@gjsify/unit';
10
+ import { SPLIT_BUTTON_MENU_PARSE_VECTORS } from '@gjsify/adwaita-core/conformance';
11
+
12
+ /** Mount a menu button carrying `menu` as JSON, and return its rendered item labels. */
13
+ function mountWithMenu(json: string): { el: HTMLElement; labels: string[] } {
14
+ const el = document.createElement('adw-menu-button');
15
+ el.setAttribute('menu', json);
16
+ document.body.appendChild(el);
17
+ const labels = Array.from(el.querySelectorAll('.adw-popover button')).map((b) => b.textContent?.trim() ?? '');
18
+ return { el, labels };
19
+ }
20
+
21
+ function unmountAll(): void {
22
+ for (const el of Array.from(document.querySelectorAll('adw-menu-button'))) el.remove();
23
+ }
24
+
25
+ export const AdwMenuButtonTest = async () => {
26
+ await describe('<adw-menu-button> menu attribute', async () => {
27
+ for (const { json, entries, rule } of SPLIT_BUTTON_MENU_PARSE_VECTORS) {
28
+ await it(`${rule}`, () => {
29
+ const { labels } = mountWithMenu(json ?? '');
30
+ expect(labels).toStrictEqual(entries.map((entry) => entry.label));
31
+ unmountAll();
32
+ });
33
+ }
34
+
35
+ await it('drops a non-string id instead of leaking a number into the event', () => {
36
+ const el = document.createElement('adw-menu-button');
37
+ el.setAttribute('menu', '[{"label":"Open","id":7}]');
38
+ document.body.appendChild(el);
39
+
40
+ let seen: unknown = 'not fired';
41
+ el.addEventListener('menu-item-activated', (e) => {
42
+ seen = (e as CustomEvent<{ id: string }>).detail.id;
43
+ });
44
+ (el.querySelector('.adw-popover button') as HTMLElement | null)?.click();
45
+
46
+ // `id` fell back to the label, which is the documented default — rather
47
+ // than arriving as the number 7 in a field every consumer reads as text.
48
+ expect(seen).toBe('Open');
49
+ unmountAll();
50
+ });
51
+ });
52
+ };
@@ -9,15 +9,12 @@
9
9
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
10
10
  // icon node is <adw-icon>.
11
11
 
12
+ import { buttonStyleClasses } from '@gjsify/adwaita-core';
13
+
12
14
  import { createAdwIcon } from './adw-icon.js';
13
15
 
14
- const VARIANT_CLASSES: Record<string, string> = {
15
- flat: 'flat',
16
- suggested: 'suggested-action',
17
- destructive: 'destructive-action',
18
- circular: 'circular',
19
- pill: 'pill',
20
- };
16
+ /** The boolean attributes that select a style class; the mapping lives in the core. */
17
+ const STYLE_ATTRIBUTES = ['flat', 'suggested', 'destructive', 'circular', 'pill'] as const;
21
18
 
22
19
  export class AdwButton extends HTMLElement {
23
20
  private _button!: HTMLButtonElement;
@@ -50,9 +47,10 @@ export class AdwButton extends HTMLElement {
50
47
  private _render() {
51
48
  const btn = this._button;
52
49
  btn.className = 'adw-button';
53
- for (const [attr, cls] of Object.entries(VARIANT_CLASSES)) {
54
- if (this.hasAttribute(attr)) btn.classList.add(cls);
55
- }
50
+ // The attribute → class mapping is `@gjsify/adwaita-core`'s, so this element
51
+ // and the NativeScript one cannot disagree about which classes exist —
52
+ // `circular` was in this table and missing from that one.
53
+ btn.classList.add(...buttonStyleClasses(STYLE_ATTRIBUTES.filter((attr) => this.hasAttribute(attr))));
56
54
 
57
55
  const icon = this.getAttribute('icon');
58
56
  const label = (this.getAttribute('label') ?? this._label).trim();
@@ -1,18 +1,33 @@
1
1
  // <adw-entry> — Adwaita single-line text entry (e.g. a browser URL bar).
2
- // Attributes: value, placeholder, type, disabled.
3
- // Property: value (get/set, proxies the inner input).
2
+ // Attributes: value, placeholder, type, disabled, maxlength.
3
+ // Properties: value (get/set, proxies the inner input), maxLength, textLength.
4
4
  // Events: native `input` bubbles from the inner input; `activate` (CustomEvent)
5
5
  // fires on Enter — mirroring Gtk.Entry's `activate` signal.
6
+ //
7
+ // The character arithmetic is HEADLESS and lives in `@gjsify/adwaita-core`
8
+ // (ADR 0004): `entryTextLength` counts CODE POINTS and `clampEntryText` truncates
9
+ // on them. `@gjsify/adwaita-nativescript`'s AdwEntry has composed the same two
10
+ // since it shipped; this element counted nothing at all, so the same consumer
11
+ // got a max length on one renderer and none on the other — and the native
12
+ // `maxlength` attribute would not have closed the gap, because the browser
13
+ // counts UTF-16 code units: `'🔒é'` is 2 characters to GTK and to NativeScript,
14
+ // and 3 to `input.maxLength`. That is why the clamp is applied here rather than
15
+ // handed to the platform.
16
+ //
6
17
  // Reference: refs/libadwaita/src/stylesheet/widgets/_entries.scss
7
18
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
8
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
19
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
20
+ // length arithmetic composed from @gjsify/adwaita-core.
21
+
22
+ import { ENTRY_ROW_MAX_LENGTH_LIMIT, clampEntryText, entryTextLength } from '@gjsify/adwaita-core';
9
23
 
10
24
  export class AdwEntry extends HTMLElement {
11
25
  private _input!: HTMLInputElement;
12
26
  private _initialized = false;
27
+ private _maxLength = 0;
13
28
 
14
29
  static get observedAttributes() {
15
- return ['value', 'placeholder', 'type', 'disabled'];
30
+ return ['value', 'placeholder', 'type', 'disabled', 'maxlength'];
16
31
  }
17
32
 
18
33
  get value(): string {
@@ -20,8 +35,26 @@ export class AdwEntry extends HTMLElement {
20
35
  }
21
36
 
22
37
  set value(v: string) {
23
- if (this._input) this._input.value = v;
24
- else this.setAttribute('value', v);
38
+ const clamped = clampEntryText(v ?? '', this._maxLength);
39
+ if (this._input) this._input.value = clamped;
40
+ else this.setAttribute('value', clamped);
41
+ }
42
+
43
+ /** `Gtk.Entry:max-length` — 0 means unlimited. Counted in CODE POINTS. */
44
+ get maxLength(): number {
45
+ return this._maxLength;
46
+ }
47
+
48
+ set maxLength(value: number) {
49
+ this._maxLength = Number.isFinite(value)
50
+ ? Math.min(ENTRY_ROW_MAX_LENGTH_LIMIT, Math.max(0, Math.trunc(value)))
51
+ : 0;
52
+ if (this._input) this._input.value = clampEntryText(this._input.value, this._maxLength);
53
+ }
54
+
55
+ /** `Gtk.Entry:text-length` — code points, not UTF-16 units. */
56
+ get textLength(): number {
57
+ return entryTextLength(this.value);
25
58
  }
26
59
 
27
60
  /** The inner native input (for focus/selection). */
@@ -33,12 +66,20 @@ export class AdwEntry extends HTMLElement {
33
66
  if (this._initialized) return;
34
67
  this._initialized = true;
35
68
 
69
+ this.maxLength = Number(this.getAttribute('maxlength') ?? 0);
70
+
36
71
  const input = document.createElement('input');
37
72
  input.className = 'adw-entry';
38
73
  input.type = this.getAttribute('type') || 'text';
39
- input.value = this.getAttribute('value') ?? '';
74
+ input.value = clampEntryText(this.getAttribute('value') ?? '', this._maxLength);
40
75
  input.placeholder = this.getAttribute('placeholder') ?? '';
41
76
  input.disabled = this.hasAttribute('disabled');
77
+ // Typing past the limit is clamped here, on the way in — the same place
78
+ // NativeScript clamps it, so both renderers refuse the same character.
79
+ input.addEventListener('input', () => {
80
+ const clamped = clampEntryText(input.value, this._maxLength);
81
+ if (clamped !== input.value) input.value = clamped;
82
+ });
42
83
  input.addEventListener('keydown', (e) => {
43
84
  if (e.key === 'Enter') {
44
85
  this.dispatchEvent(new CustomEvent('activate', { bubbles: true, detail: { value: input.value } }));
@@ -50,8 +91,12 @@ export class AdwEntry extends HTMLElement {
50
91
  }
51
92
 
52
93
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
94
+ if (name === 'maxlength') {
95
+ this.maxLength = Number(value ?? 0);
96
+ return;
97
+ }
53
98
  if (!this._input) return;
54
- if (name === 'value') this._input.value = value ?? '';
99
+ if (name === 'value') this._input.value = clampEntryText(value ?? '', this._maxLength);
55
100
  else if (name === 'placeholder') this._input.placeholder = value ?? '';
56
101
  else if (name === 'type') this._input.type = value || 'text';
57
102
  else if (name === 'disabled') this._input.disabled = value !== null;
@@ -1,22 +1,37 @@
1
1
  // <adw-header-bar> — Adwaita header bar with centered title and start/end button slots.
2
+ //
3
+ // The derived centre is an `<adw-window-title>`, which is what `Adw.HeaderBar`
4
+ // itself puts there (`adw-header-bar.c`: the title widget it creates when none is
5
+ // given IS an `AdwWindowTitle`). It used to be a bare span with
6
+ // `textContent = title ?? ''`, and that span carried none of the three rules the
7
+ // window title already held in `@gjsify/adwaita-core`: an EMPTY title still
8
+ // reserved a blank line, re-setting the same value repainted, and there was no
9
+ // subtitle at all. So a header bar could not show what its own NativeScript twin
10
+ // could, and the fix is delegation rather than a fourth copy of the derivation.
11
+ //
12
+ // Reference: refs/libadwaita/src/adw-header-bar.c
2
13
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
3
14
  // Copyright (c) 2025 csm. MIT License.
4
15
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web.
5
16
 
17
+ // Registers <adw-window-title>: the derived centre is one, so importing the bar
18
+ // alone must still define it.
19
+ import './adw-window-title.js';
20
+
6
21
  export class AdwHeaderBar extends HTMLElement {
7
22
  private _initialized = false;
8
23
  private _startEl: HTMLDivElement | null = null;
9
24
  private _centerEl: HTMLDivElement | null = null;
10
25
  private _endEl: HTMLDivElement | null = null;
11
26
  /**
12
- * The generated title span, or `null` when a `slot="center"` title-widget
13
- * took the centre. `Adw.HeaderBar` has the same either/or: setting
14
- * `title-widget` replaces the derived `AdwWindowTitle` outright.
27
+ * The derived `<adw-window-title>`, or `null` when a `slot="center"`
28
+ * title-widget took the centre. `Adw.HeaderBar` has the same either/or:
29
+ * setting `title-widget` replaces the derived `AdwWindowTitle` outright.
15
30
  */
16
- private _titleEl: HTMLSpanElement | null = null;
31
+ private _titleEl: HTMLElement | null = null;
17
32
 
18
33
  static get observedAttributes() {
19
- return ['title'];
34
+ return ['title', 'subtitle'];
20
35
  }
21
36
 
22
37
  /** The start (left) section container — append buttons/widgets here. */
@@ -56,7 +71,7 @@ export class AdwHeaderBar extends HTMLElement {
56
71
  if (centerChildren.length > 0) {
57
72
  for (const child of centerChildren) this._centerEl.appendChild(child);
58
73
  } else {
59
- this._titleEl = document.createElement('span');
74
+ this._titleEl = document.createElement('adw-window-title');
60
75
  this._titleEl.className = 'adw-header-bar-title';
61
76
  this._centerEl.appendChild(this._titleEl);
62
77
  }
@@ -77,15 +92,23 @@ export class AdwHeaderBar extends HTMLElement {
77
92
  * kept whatever it was created with. `Adw.HeaderBar`'s derived title widget
78
93
  * is bound to the property and re-renders on every change.
79
94
  */
80
- attributeChangedCallback(name: string) {
81
- if (this._initialized && name === 'title') this._renderTitle();
95
+ attributeChangedCallback() {
96
+ if (this._initialized) this._renderTitle();
82
97
  }
83
98
 
84
99
  private _renderTitle() {
85
100
  // A `slot="center"` widget replaced the derived title, so there is
86
101
  // nothing for the attribute to write to — the same either/or as
87
102
  // `Adw.HeaderBar:title-widget`.
88
- if (this._titleEl) this._titleEl.textContent = this.getAttribute('title') ?? '';
103
+ if (!this._titleEl) return;
104
+ // Forwarded as ATTRIBUTES, so the window title's own change detection and
105
+ // empty-string collapse do the work. Removing rather than writing `''`
106
+ // keeps "unset" distinguishable from "set to empty" on the child.
107
+ for (const name of ['title', 'subtitle']) {
108
+ const value = this.getAttribute(name);
109
+ if (value === null) this._titleEl.removeAttribute(name);
110
+ else this._titleEl.setAttribute(name, value);
111
+ }
89
112
  }
90
113
  }
91
114
 
@@ -20,8 +20,8 @@
20
20
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
21
21
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
22
22
 
23
- import { isSplitButtonDirection, menuButtonPopupDirection } from '@gjsify/adwaita-core';
24
- import type { SplitButtonDirection } from '@gjsify/adwaita-core';
23
+ import { isSplitButtonDirection, menuButtonPopupDirection, parseMenuEntries } from '@gjsify/adwaita-core';
24
+ import type { AdwMenuEntry, SplitButtonDirection } from '@gjsify/adwaita-core';
25
25
 
26
26
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
27
27
  // `adw-popover` is defined before this module's `customElements.define` can upgrade a
@@ -34,12 +34,15 @@ import type { AdwPopover } from './adw-popover.js';
34
34
 
35
35
  import { createAdwIcon } from './adw-icon.js';
36
36
 
37
- /** A menu entry. `id` defaults to `label` on activation; `icon` is optional. */
38
- export interface AdwMenuItem {
39
- id?: string;
40
- label: string;
41
- icon?: string;
42
- }
37
+ /**
38
+ * A menu entry — `@gjsify/adwaita-core`'s {@link AdwMenuEntry}, re-exported under the
39
+ * name this element has always used.
40
+ *
41
+ * It used to be a SECOND declaration of the same shape, and the NativeScript menu
42
+ * button declared a THIRD that was missing `icon` entirely. One type, so a consumer
43
+ * writing menu entries writes the same object for either renderer.
44
+ */
45
+ export type AdwMenuItem = AdwMenuEntry;
43
46
 
44
47
  /**
45
48
  * Where the popover sits, per `GtkArrowType`. `menuButtonPopupDirection` folds `none`
@@ -152,18 +155,16 @@ export class AdwMenuButton extends HTMLElement {
152
155
  this._render();
153
156
  }
154
157
 
158
+ /**
159
+ * The `menu` attribute, through the core parser the split button already used.
160
+ *
161
+ * The copy this replaces was weaker in a way markup can reach: it kept `id` and
162
+ * `icon` WHATEVER their runtime type, so `{"label":"Open","id":7}` produced an
163
+ * entry whose `id` is a number in a field typed `string` — and `id` is what the
164
+ * activation event reports. The core parser keeps only string-typed extras.
165
+ */
155
166
  private _parseMenuAttr(): AdwMenuItem[] {
156
- const raw = this.getAttribute('menu');
157
- if (!raw) return [];
158
- try {
159
- const parsed: unknown = JSON.parse(raw);
160
- if (!Array.isArray(parsed)) return [];
161
- return parsed
162
- .filter((e): e is AdwMenuItem => typeof e === 'object' && e !== null && 'label' in e)
163
- .map((e) => ({ id: e.id, label: String(e.label), icon: e.icon }));
164
- } catch {
165
- return [];
166
- }
167
+ return parseMenuEntries(this.getAttribute('menu'));
167
168
  }
168
169
 
169
170
  private _onPopoverToggled(open: boolean): void {
@@ -7,6 +7,8 @@
7
7
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
8
8
  // icon node is <adw-icon>.
9
9
 
10
+ import { stringIsNotEmpty } from '@gjsify/adwaita-core';
11
+
10
12
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
11
13
 
12
14
  export class AdwStatusPage extends HTMLElement {
@@ -51,13 +53,16 @@ export class AdwStatusPage extends HTMLElement {
51
53
  this._iconEl.iconName = this.getAttribute('icon');
52
54
  this._iconEl.hidden = this._iconEl.resolvedIconName === '';
53
55
 
56
+ // `string_is_not_empty` from the core, not a local `.length === 0`: the same
57
+ // closure the NativeScript port binds its labels to, and the reason a title of
58
+ // `' '` stays VISIBLE — it reads the first byte, it never trims.
54
59
  const title = this.getAttribute('title') ?? '';
55
60
  this._titleEl.textContent = title;
56
- this._titleEl.hidden = title.length === 0;
61
+ this._titleEl.hidden = !stringIsNotEmpty(title);
57
62
 
58
63
  const description = this.getAttribute('description') ?? '';
59
64
  this._descEl.textContent = description;
60
- this._descEl.hidden = description.length === 0;
65
+ this._descEl.hidden = !stringIsNotEmpty(description);
61
66
 
62
67
  this._childEl.hidden = this._childEl.childElementCount === 0;
63
68
  }
@@ -33,6 +33,9 @@ import { AdwViewSwitcherBarTest } from './adw-view-switcher-bar.spec.js';
33
33
  import { AdwStyleIsolationTest } from './style-isolation.spec.js';
34
34
  import { AdwWrapBoxTest } from './adw-wrap-box.spec.js';
35
35
  import { AdwHeaderBarTest } from './adw-header-bar.spec.js';
36
+ import { AdwEntryTest } from './adw-entry.spec.js';
37
+ import { AdwMenuButtonTest } from './adw-menu-button.spec.js';
38
+ import { AdwButtonTest } from './adw-button.spec.js';
36
39
  import { AdwBannerTest } from './adw-banner.spec.js';
37
40
  import { AdwButtonContentTest } from './adw-button-content.spec.js';
38
41
  import { AdwIconTest } from './adw-icon.spec.js';
@@ -77,6 +80,9 @@ run({
77
80
  AdwStyleIsolationTest,
78
81
  AdwWrapBoxTest,
79
82
  AdwHeaderBarTest,
83
+ AdwEntryTest,
84
+ AdwMenuButtonTest,
85
+ AdwButtonTest,
80
86
  AdwViewStackTest,
81
87
  AdwNavigationViewTest,
82
88
  AdwSidebarTest,