@gjsify/adwaita-web 0.55.0 → 0.56.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 (71) hide show
  1. package/README.md +8 -0
  2. package/dist/adwaita-web.css +129 -27
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/adw-status-page.spec.d.ts +1 -0
  5. package/lib/types/attributes.d.ts +19 -0
  6. package/lib/types/elements/adw-action-row.d.ts +4 -0
  7. package/lib/types/elements/adw-combo-row.d.ts +4 -0
  8. package/lib/types/elements/adw-entry-row.d.ts +6 -0
  9. package/lib/types/elements/adw-expander-row.d.ts +2 -0
  10. package/lib/types/elements/adw-header-bar.d.ts +6 -0
  11. package/lib/types/elements/adw-overlay-split-view.d.ts +13 -0
  12. package/lib/types/elements/adw-sidebar.d.ts +3 -2
  13. package/lib/types/elements/adw-spin-row.d.ts +6 -0
  14. package/lib/types/elements/adw-switch-row.d.ts +4 -0
  15. package/lib/types/elements/adw-toggle-group.d.ts +17 -0
  16. package/lib/types/elements/gtk-scrolled-window.d.ts +10 -2
  17. package/lib/types/elements/gtk-window.d.ts +8 -2
  18. package/lib/types/index.d.ts +1 -1
  19. package/lib/types/row-line-clamp.d.ts +20 -0
  20. package/lib/types/row-tooltip.d.ts +8 -0
  21. package/lib/types/row-tooltip.spec.d.ts +1 -0
  22. package/lib/types/shared-tree-builder.d.ts +49 -19
  23. package/lib/types/shared-tree-mount.spec.d.ts +1 -0
  24. package/lib/types/styles.generated.d.ts +1 -1
  25. package/lib/types/widget-size.spec.d.ts +1 -0
  26. package/package.json +8 -8
  27. package/scss/_action_row.scss +39 -15
  28. package/scss/_button_row.scss +5 -0
  29. package/scss/_expander_row.scss +36 -3
  30. package/scss/_headerbar.scss +2 -1
  31. package/scss/_preferences_row.scss +9 -0
  32. package/scss/_row.scss +16 -0
  33. package/scss/_status_page.scss +1 -1
  34. package/scss/_toggle_group.scss +28 -0
  35. package/scss/_widget.scss +14 -3
  36. package/scss/_window.scss +13 -1
  37. package/src/adw-action-rows.spec.ts +255 -0
  38. package/src/adw-header-bar.spec.ts +18 -0
  39. package/src/adw-status-page.spec.ts +124 -0
  40. package/src/attributes.ts +52 -0
  41. package/src/capabilities.mjs +3 -0
  42. package/src/construct-vectors.spec.ts +1 -1
  43. package/src/elements/adw-action-row.ts +31 -2
  44. package/src/elements/adw-alert-dialog.ts +4 -1
  45. package/src/elements/adw-button-row.ts +7 -1
  46. package/src/elements/adw-combo-row.ts +26 -1
  47. package/src/elements/adw-entry-row.ts +22 -12
  48. package/src/elements/adw-expander-row.ts +34 -1
  49. package/src/elements/adw-header-bar.ts +18 -4
  50. package/src/elements/adw-overlay-split-view.ts +29 -6
  51. package/src/elements/adw-preferences-row.ts +5 -2
  52. package/src/elements/adw-sidebar.ts +33 -5
  53. package/src/elements/adw-spin-row.ts +37 -2
  54. package/src/elements/adw-status-page.ts +10 -2
  55. package/src/elements/adw-switch-row.ts +25 -1
  56. package/src/elements/adw-toggle-group.ts +119 -10
  57. package/src/elements/adw-window.ts +9 -4
  58. package/src/elements/gtk-scrolled-window.ts +18 -7
  59. package/src/elements/gtk-window.ts +9 -3
  60. package/src/gtk-window.spec.ts +12 -0
  61. package/src/index.ts +1 -1
  62. package/src/keyboard-operable.spec.ts +47 -11
  63. package/src/row-line-clamp.ts +74 -0
  64. package/src/row-tooltip.spec.ts +313 -0
  65. package/src/row-tooltip.ts +52 -0
  66. package/src/shared-tree-builder.ts +88 -35
  67. package/src/shared-tree-mount.spec.ts +130 -0
  68. package/src/shared-trees.spec.ts +15 -0
  69. package/src/styles.generated.ts +1 -1
  70. package/src/test.browser.mts +8 -0
  71. package/src/widget-size.spec.ts +117 -0
@@ -91,7 +91,7 @@ import type { AdwAdjustment } from '@gjsify/adwaita-core';
91
91
  import { AdwScrollShading } from '../scroll-shading.js';
92
92
  import { bindSlottedChildren } from '../slotted-children.js';
93
93
  import type { GtkScrollType } from './gtk-scrollbar.js';
94
- import { GtkViewport } from './gtk-viewport.js';
94
+ import type { GtkViewport } from './gtk-viewport.js';
95
95
 
96
96
  /**
97
97
  * `INDICATOR_FADE_OUT_DELAY` — how long an indicator waits, after the last scroll, before
@@ -198,6 +198,8 @@ function resolveScrollKey(event: KeyboardEvent): { scroll: GtkScrollType; horizo
198
198
  export class GtkScrolledWindow extends HTMLElement {
199
199
  private _viewport!: GtkViewport;
200
200
  private _bars: Record<'horizontal' | 'vertical', HTMLElement> | null = null;
201
+ /** The size clamps `_writeSize` last wrote, which are the only ones it may clear. */
202
+ private readonly _wroteSizes = new Set<string>();
201
203
  private _shading: AdwScrollShading | null = null;
202
204
  private _observer: ResizeObserver | null = null;
203
205
  private _hoverable: MediaQueryList | null = null;
@@ -447,10 +449,10 @@ export class GtkScrolledWindow extends HTMLElement {
447
449
  this.classList.toggle('overlay-scrolling', indicators);
448
450
  // `gtk_scrolled_window_measure`'s four clamps, as the four CSS size requests they
449
451
  // are: an unwritten one (-1) sets nothing at all.
450
- this.style.minWidth = this._size('min-content-width');
451
- this.style.minHeight = this._size('min-content-height');
452
- this.style.maxWidth = this._size('max-content-width');
453
- this.style.maxHeight = this._size('max-content-height');
452
+ this._writeSize('min-width', 'min-content-width');
453
+ this._writeSize('min-height', 'min-content-height');
454
+ this._writeSize('max-width', 'max-content-width');
455
+ this._writeSize('max-height', 'max-content-height');
454
456
  for (const axis of AXES) {
455
457
  const bar = bars[axis.bar];
456
458
  const policy = normalizePolicy(this.getAttribute(axis.policy));
@@ -481,9 +483,18 @@ export class GtkScrolledWindow extends HTMLElement {
481
483
  if (!indicators) this._clearFade();
482
484
  }
483
485
 
484
- private _size(attribute: string): string {
486
+ /**
487
+ * One of the four clamps as inline style. An unwritten clamp clears only what THIS element
488
+ * wrote: `min-width` / `min-height` are also where a tree's `width-request` /
489
+ * `height-request` land (`GTK_WIDGET_SIZE_CSS`), and assigning '' unconditionally erased it.
490
+ * Where both are set the content clamp wins, which is the one the widget measures itself.
491
+ */
492
+ private _writeSize(property: string, attribute: string): void {
485
493
  const size = sizeRequest(this.getAttribute(attribute));
486
- return size === null ? '' : `${size}px`;
494
+ if (size !== null) this.style.setProperty(property, `${size}px`);
495
+ else if (this._wroteSizes.has(property)) this.style.removeProperty(property);
496
+ if (size !== null) this._wroteSizes.add(property);
497
+ else this._wroteSizes.delete(property);
487
498
  }
488
499
 
489
500
  /** The viewport's adjustment for one axis, which is where GTK reads the scrollbar's. */
@@ -106,9 +106,15 @@ export class GtkWindow extends HTMLElement {
106
106
  ];
107
107
  }
108
108
 
109
- /** `Gtk.Window:title` — the window's title. */
110
- get title(): string | null {
111
- return this.getAttribute('title');
109
+ /**
110
+ * `Gtk.Window:title` — the window's title.
111
+ *
112
+ * The getter answers `''` for no title, not `null`: a `string | null` getter is not assignable to
113
+ * `HTMLElement.title`, and the emitted `.d.ts` then fails every consumer that does not set
114
+ * `skipLibCheck`. The setter still takes `null` to clear it, as GTK does.
115
+ */
116
+ get title(): string {
117
+ return this.getAttribute('title') ?? '';
112
118
  }
113
119
 
114
120
  set title(value: string | null) {
@@ -37,6 +37,18 @@ export const GtkWindowTest = async () => {
37
37
  host.remove();
38
38
  });
39
39
 
40
+ await it('answers an unset title as a string and clears it with null', async () => {
41
+ // `HTMLElement.title` is a `string`; a `string | null` getter broke every consumer's
42
+ // type check of the emitted `.d.ts`.
43
+ const { el, host } = mount();
44
+ expect(el.title).toBe('');
45
+ el.title = 'Learn6502';
46
+ expect(el.title).toBe('Learn6502');
47
+ el.title = null;
48
+ expect(el.title).toBe('');
49
+ host.remove();
50
+ });
51
+
40
52
  await it('routes every unslotted child into the content box, in document order', async () => {
41
53
  const { el, host } = mount();
42
54
  const first = document.createElement('span');
package/src/index.ts CHANGED
@@ -81,7 +81,7 @@ export type { AdwUndershootEdges } from './scroll-shading.js';
81
81
  // spec itself — has a route to it that is not a `.spec.ts` import; see that file's header
82
82
  // for why `mountSharedTree` and not `buildSharedTree` alone is the instantiation half.
83
83
  export { buildSharedTree, mountSharedTree } from './shared-tree-builder.js';
84
- export type { MountedSharedTree, SizeSource } from './shared-tree-builder.js';
84
+ export type { MountOptions, MountedSharedTree, SizeSource } from './shared-tree-builder.js';
85
85
  export { registerTemplateClass } from './template-classes.js';
86
86
 
87
87
  // The stylesheet compiles a chosen SUBSET of `@gjsify/adwaita-icons` (the whole set is
@@ -622,21 +622,57 @@ export const AdwKeyboardOperableTest = async () => {
622
622
  // Prose cannot hold that: this line fails the commit that adds it.
623
623
  // `<adw-inline-view-switcher>` has the same gap (status/open-todos/README.md).
624
624
  // `active-name` joined the list without touching the axis: it picks a toggle.
625
- expect([...AdwToggleGroup.observedAttributes]).toStrictEqual(['active', 'active-name', 'flat', 'round']);
625
+ // `sensitive` joined it for the same reason — it gates every button's `disabled`,
626
+ // never the axis. `keyboard-operable.spec.ts` below now pins the roving walk
627
+ // against a disabled toggle, which is what made that attribute safe to add.
628
+ expect([...AdwToggleGroup.observedAttributes]).toStrictEqual([
629
+ 'active',
630
+ 'active-name',
631
+ 'flat',
632
+ 'round',
633
+ 'sensitive',
634
+ ]);
626
635
  });
627
636
 
628
637
  await it('adw-toggle has no state a roving walk would have to skip', async () => {
629
- // `<adw-toggle-group>` passes its buttons to `attachRovingFocus` UNFILTERED,
630
- // which is only safe while no `<adw-toggle>` attribute can produce a disabled
631
- // or hidden button. That is a decision recorded in two comments and a ledger
632
- // entry, and this is the line that fails when it stops being true — the first
633
- // disabled toggle is otherwise a `focus()` the browser refuses, with nothing
634
- // in the walk to step over it. Grow this list and add the filter and its spec
635
- // in the same change (status/open-todos/README.md, `<adw-toggle>` has no `enabled`).
638
+ // `<adw-toggle-group>` FILTERS its buttons for `attachRovingFocus`, which is only
639
+ // necessary once some `<adw-toggle>` attribute can produce a disabled button.
640
+ // `enabled` is that attribute (`AdwToggle:enabled`), so this list is now the
641
+ // record of WHICH attributes can — `hidden` cannot, and no other one can.
642
+ // Grow it and the filter has to grow with it: an unskipped disabled toggle is a
643
+ // `focus()` the browser refuses, with nothing in the walk to step over it. The
644
+ // skip itself is pinned in the case below.
636
645
  //
637
- // `tooltip` joined the list and is NOT a counterexample: it sets `title` and
638
- // `aria-label`, which change neither reachability nor rendering.
639
- expect([...AdwToggle.observedAttributes]).toStrictEqual(['label', 'icon-name', 'tooltip']);
646
+ // `tooltip` is not a counterexample: it sets `title` and `aria-label`, which
647
+ // change neither reachability nor rendering.
648
+ expect([...AdwToggle.observedAttributes]).toStrictEqual(['label', 'icon-name', 'tooltip', 'enabled']);
649
+ });
650
+
651
+ await it('adw-toggle-group arrow skips a disabled toggle', async () => {
652
+ // `AdwToggle:enabled` reached the web element with #1818, and the filter
653
+ // `roving-focus.ts` asks of every caller is the price of it — the same obligation
654
+ // `<adw-sidebar>` already pays ("arrow skips a disabled row", above). A disabled
655
+ // `<button>` cannot take focus, so leaving it in the walk strands the user on a
656
+ // `focus()` the browser refuses.
657
+ withWidget(
658
+ () => {
659
+ const el = document.createElement('adw-toggle-group');
660
+ el.innerHTML =
661
+ '<adw-toggle label="One"></adw-toggle>' +
662
+ '<adw-toggle label="Two" enabled="false"></adw-toggle>' +
663
+ '<adw-toggle label="Three"></adw-toggle>';
664
+ return el;
665
+ },
666
+ (el) => {
667
+ const items = rovingCase('adw-toggle-group').items(el);
668
+ expect(items.length).toBe(3);
669
+ expect((items[1] as HTMLButtonElement).disabled).toBe(true);
670
+
671
+ items[0].focus();
672
+ press(items[0], 'ArrowRight');
673
+ expect(document.activeElement).toBe(items[2]);
674
+ },
675
+ );
640
676
  });
641
677
 
642
678
  await it('adw-toggle-group notifies once per arrow, through the click path', async () => {
@@ -0,0 +1,74 @@
1
+ // Row line-clamping utilities — shared by adw-action-row, adw-switch-row,
2
+ // adw-combo-row, adw-spin-row, adw-expander-row.
3
+ //
4
+ // The clamping uses the package's established pattern: CSS custom properties
5
+ // (--adw-row-title-lines, --adw-row-subtitle-lines) + a class toggle
6
+ // (.adw-row-clamp on the text container). See gtk-label.ts for the same
7
+ // pattern with --gtk-label-lines and .adw-label-clamp.
8
+ //
9
+ // Upstream semantics (AdwActionRow, AdwExpanderRow):
10
+ // - title-lines / subtitle-lines default to 0
11
+ // - 0 = unlimited lines (wrap freely, no ellipsis)
12
+ // - n > 0 = clamp to n lines with ellipsis
13
+ // - Negative values are invalid per the pspec (range 0..G_MAXINT) and
14
+ // treated as 0 (unlimited) — the property system clamps them.
15
+
16
+ export interface RowLineClampConfig {
17
+ titleEl: HTMLElement;
18
+ subtitleEl: HTMLElement;
19
+ textContainerEl: HTMLElement; // The element that gets .adw-row-clamp
20
+ getTitleLines: () => number;
21
+ getSubtitleLines: () => number;
22
+ }
23
+
24
+ /**
25
+ * Apply or remove line clamping based on title-lines/subtitle-lines values.
26
+ * Uses CSS custom properties and class toggle (not inline styles).
27
+ */
28
+ export function applyRowLineClamp(config: RowLineClampConfig): void {
29
+ const { titleEl, subtitleEl, textContainerEl, getTitleLines, getSubtitleLines } = config;
30
+
31
+ const titleLines = getTitleLines();
32
+ const subtitleLines = getSubtitleLines();
33
+
34
+ const titleClamped = titleLines > 0;
35
+ const subtitleClamped = subtitleLines > 0;
36
+ const anyClamped = titleClamped || subtitleClamped;
37
+
38
+ // Toggle the clamping class on the text container
39
+ textContainerEl.classList.toggle('adw-row-clamp', anyClamped);
40
+
41
+ // Set custom properties for the clamp line counts
42
+ if (titleClamped) {
43
+ titleEl.style.setProperty('--adw-row-title-lines', String(titleLines));
44
+ } else {
45
+ titleEl.style.removeProperty('--adw-row-title-lines');
46
+ }
47
+
48
+ if (subtitleClamped) {
49
+ subtitleEl.style.setProperty('--adw-row-subtitle-lines', String(subtitleLines));
50
+ } else {
51
+ subtitleEl.style.removeProperty('--adw-row-subtitle-lines');
52
+ }
53
+
54
+ // The ellipsize overflow is shared (end ellipsis for all non-none modes)
55
+ // Set it on the text container so the clamp rule can read it
56
+ if (anyClamped) {
57
+ textContainerEl.style.setProperty('--adw-row-ellipsize-overflow', 'ellipsis');
58
+ } else {
59
+ textContainerEl.style.removeProperty('--adw-row-ellipsize-overflow');
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Parse a lines attribute value following AdwActionRow semantics:
65
+ * - Missing/null/empty → 0 (unlimited)
66
+ * - Invalid/non-numeric → 0 (unlimited)
67
+ * - n > 0 → n (clamp to n lines)
68
+ * - n <= 0 → 0 (unlimited; pspec range is 0..G_MAXINT)
69
+ */
70
+ export function parseRowLinesAttribute(value: string | null): number {
71
+ if (value === null || value === '') return 0;
72
+ const parsed = Number.parseInt(value, 10);
73
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
74
+ }
@@ -0,0 +1,313 @@
1
+ // `Gtk.Widget:tooltip-text` on the boxed-list rows, and the two sensitivity doors
2
+ // `<adw-toggle-group>` gained with it.
3
+ //
4
+ // THE COLLISION THESE TESTS EXIST FOR: a row's `title` attribute is its LABEL
5
+ // (`Adw.PreferencesRow:title`), and `HTMLElement.title` reflects the same attribute — so
6
+ // the browser's own tooltip channel cannot be written on the host without blanking the
7
+ // label. `src/row-tooltip.ts` puts it on the row's PARTS instead, and Firefox resolves a
8
+ // tooltip by walking up from the hovered node until an element carries a non-empty
9
+ // `title`. What the tests pin is that consequence: the parts carry the text, the host's
10
+ // label survives, and a part the element gave a tooltip of its own keeps it.
11
+ import { describe, expect, it } from '@gjsify/unit';
12
+
13
+ import type { AdwToggleGroup } from './elements/adw-toggle-group.js';
14
+
15
+ /** Connect, run, disconnect — the same helper `keyboard-operable.spec.ts` drives with. */
16
+ function withWidget(make: () => HTMLElement, body: (el: HTMLElement) => void): void {
17
+ const el = make();
18
+ document.body.appendChild(el);
19
+ try {
20
+ body(el);
21
+ } finally {
22
+ el.remove();
23
+ }
24
+ }
25
+
26
+ /** The parts `applyRowTooltip` is expected to have written `tooltip-text` onto. */
27
+ const TOOLTIPPED_PARTS: Record<string, string[]> = {
28
+ 'adw-action-row': ['.adw-action-row-prefix', '.adw-row-text', '.adw-action-row-suffix'],
29
+ 'adw-button-row': ['.adw-button-row-contents'],
30
+ 'adw-combo-row': ['.adw-row-text', '.adw-row-value', 'select'],
31
+ 'adw-expander-row': ['.adw-expander-row-header'],
32
+ 'adw-preferences-row': ['.adw-row-title', '.adw-preferences-row-content'],
33
+ 'adw-spin-row': ['.adw-row-text', '.adw-spin-control'],
34
+ 'adw-switch-row': ['.adw-switch-row-prefix', '.adw-row-text'],
35
+ };
36
+
37
+ /** Every `title` attribute inside `el`, as `selector → value`, absent ones as `null`. */
38
+ function partTitles(el: Element): Record<string, string | null> {
39
+ const titles: Record<string, string | null> = {};
40
+ for (const selector of TOOLTIPPED_PARTS[el.localName]) {
41
+ titles[selector] = el.querySelector(selector)?.getAttribute('title') ?? null;
42
+ }
43
+ return titles;
44
+ }
45
+
46
+ /**
47
+ * Measure the vertical coverage of tooltip-marked parts against the row's bounding box.
48
+ * Returns the ratio of covered height to row height (1.0 = full coverage).
49
+ */
50
+ function tooltipCoverageRatio(el: HTMLElement): number {
51
+ const rowRect = el.getBoundingClientRect();
52
+ const parts = TOOLTIPPED_PARTS[el.localName] ?? [];
53
+ if (parts.length === 0) return 0;
54
+
55
+ // Union of all tooltip parts' bounding boxes
56
+ let top = Infinity;
57
+ let bottom = -Infinity;
58
+ for (const selector of parts) {
59
+ const part = el.querySelector(selector) as HTMLElement | null;
60
+ if (!part) continue;
61
+ const rect = part.getBoundingClientRect();
62
+ top = Math.min(top, rect.top);
63
+ bottom = Math.max(bottom, rect.bottom);
64
+ }
65
+ if (!isFinite(top) || !isFinite(bottom)) return 0;
66
+ const covered = bottom - top;
67
+ return covered / rowRect.height;
68
+ }
69
+
70
+ export const AdwRowTooltipTest = async () => {
71
+ await describe('row tooltip-text (Gtk.Widget:tooltip-text)', async () => {
72
+ // One case per row family, and the assertion is the SAME for all of them: the
73
+ // parts carry the text and the host keeps its label. A row that wrote the tooltip
74
+ // on the host instead would blank `title` here, which is the whole failure mode.
75
+ for (const [tag, selectors] of Object.entries(TOOLTIPPED_PARTS)) {
76
+ await it(`${tag} puts tooltip-text on its parts and keeps its title label`, () => {
77
+ withWidget(
78
+ () => {
79
+ const el = document.createElement(tag);
80
+ el.setAttribute('title', 'Wi-Fi');
81
+ el.setAttribute('tooltip-text', 'Connects to the office network');
82
+ return el;
83
+ },
84
+ (el) => {
85
+ expect(el.getAttribute('title')).toBe('Wi-Fi');
86
+ expect((el as HTMLElement & { title: string }).title).toBe('Wi-Fi');
87
+ for (const selector of selectors) {
88
+ expect(el.querySelector(selector)?.getAttribute('title')).toBe(
89
+ 'Connects to the office network',
90
+ );
91
+ }
92
+ },
93
+ );
94
+ });
95
+
96
+ await it(`${tag} clears the tooltip when the attribute goes`, () => {
97
+ withWidget(
98
+ () => {
99
+ const el = document.createElement(tag);
100
+ el.setAttribute('title', 'Wi-Fi');
101
+ el.setAttribute('tooltip-text', 'Gone soon');
102
+ return el;
103
+ },
104
+ (el) => {
105
+ expect(partTitles(el)).toStrictEqual(
106
+ Object.fromEntries(TOOLTIPPED_PARTS[tag].map((selector) => [selector, 'Gone soon'])),
107
+ );
108
+
109
+ el.removeAttribute('tooltip-text');
110
+ for (const selector of selectors) {
111
+ expect(el.querySelector(selector)?.hasAttribute('title')).toBe(false);
112
+ }
113
+ // The LABEL is untouched either way.
114
+ expect(el.getAttribute('title')).toBe('Wi-Fi');
115
+ },
116
+ );
117
+ });
118
+ }
119
+
120
+ await it('adw-entry-row leaves the apply button its own tooltip', () => {
121
+ withWidget(
122
+ () => {
123
+ const el = document.createElement('adw-entry-row');
124
+ el.setAttribute('text', 'query');
125
+ el.setAttribute('show-apply-button', '');
126
+ el.setAttribute('tooltip-text', 'The search term');
127
+ return el;
128
+ },
129
+ (el) => {
130
+ const apply = el.querySelector('button') as HTMLButtonElement;
131
+ expect(apply.title).not.toBe('The search term');
132
+ expect(apply.title.length).toBeGreaterThan(0);
133
+ // The editable area is the part that takes the row's tooltip.
134
+ expect(el.querySelector('.adw-entry-row-text')?.getAttribute('title')).toBe('The search term');
135
+ },
136
+ );
137
+ });
138
+
139
+ await it('adw-password-entry-row inherits the entry row tooltip', () => {
140
+ withWidget(
141
+ () => {
142
+ const el = document.createElement('adw-password-entry-row');
143
+ el.setAttribute('title', 'Password');
144
+ el.setAttribute('tooltip-text', 'At least 12 characters');
145
+ return el;
146
+ },
147
+ (el) => {
148
+ expect(el.getAttribute('title')).toBe('Password');
149
+ expect(el.querySelector('.adw-password-entry-row-text')?.getAttribute('title')).toBe(
150
+ 'At least 12 characters',
151
+ );
152
+ },
153
+ );
154
+ });
155
+
156
+ // BLOCKER 1: the tooltip parts must cover the row's full hit area (height).
157
+ // GTK draws the tooltip over the whole widget; the port was only covering the
158
+ // label text's own height. The `[data-row-tooltip]` marker + `align-self: stretch`
159
+ // in `_row.scss` stretches the parts, and this assertion prevents regression.
160
+ await it("tooltip parts cover the row's full height (>= 0.95 of row height)", () => {
161
+ for (const [tag] of Object.entries(TOOLTIPPED_PARTS)) {
162
+ withWidget(
163
+ () => {
164
+ const el = document.createElement(tag);
165
+ el.setAttribute('title', 'Wi-Fi');
166
+ el.setAttribute('tooltip-text', 'Connects to the office network');
167
+ return el;
168
+ },
169
+ (el) => {
170
+ // Force layout so getBoundingClientRect is accurate
171
+ el.style.position = 'absolute';
172
+ el.style.top = '-9999px';
173
+ document.body.appendChild(el);
174
+ try {
175
+ const ratio = tooltipCoverageRatio(el);
176
+ expect(ratio).toBeGreaterThanOrEqual(0.95);
177
+ } finally {
178
+ el.remove();
179
+ }
180
+ },
181
+ );
182
+ }
183
+ });
184
+ });
185
+
186
+ await describe('adw-toggle-group sensitivity', async () => {
187
+ const group = (attrs: Record<string, string>, toggles: string[]) => () => {
188
+ const el = document.createElement('adw-toggle-group');
189
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
190
+ el.innerHTML = toggles.join('');
191
+ return el;
192
+ };
193
+ const buttons = (el: Element) => Array.from(el.querySelectorAll<HTMLButtonElement>('button.adw-toggle'));
194
+
195
+ await it('defaults to sensitive, so a bare group is operable', () => {
196
+ withWidget(group({}, ['<adw-toggle label="One"></adw-toggle>']), (el) => {
197
+ const group = el as unknown as AdwToggleGroup;
198
+ expect(group.sensitive).toBe(true);
199
+ expect(el.hasAttribute('disabled')).toBe(false);
200
+ expect(buttons(el)[0].disabled).toBe(false);
201
+ });
202
+ });
203
+
204
+ await it('reads `sensitive` by VALUE, not by presence', () => {
205
+ // `GtkWidget:sensitive` defaults to TRUE. Read by presence, the bare element
206
+ // would be insensitive and every group on a page would be dimmed and dead.
207
+ withWidget(group({ sensitive: 'false' }, ['<adw-toggle label="One"></adw-toggle>']), (el) => {
208
+ const group = el as unknown as AdwToggleGroup;
209
+ expect(group.sensitive).toBe(false);
210
+ expect(el.getAttribute('disabled')).toBe('');
211
+ expect(buttons(el)[0].disabled).toBe(true);
212
+ });
213
+ });
214
+
215
+ await it('an explicit `sensitive="true"` is sensitive', () => {
216
+ withWidget(group({ sensitive: 'true' }, ['<adw-toggle label="One"></adw-toggle>']), (el) => {
217
+ expect((el as unknown as AdwToggleGroup).sensitive).toBe(true);
218
+ expect(el.hasAttribute('disabled')).toBe(false);
219
+ });
220
+ });
221
+
222
+ await it('sensitive is reachable as a property and takes effect live', () => {
223
+ withWidget(
224
+ group({}, ['<adw-toggle label="One"></adw-toggle>', '<adw-toggle label="Two"></adw-toggle>']),
225
+ (el) => {
226
+ const group = el as unknown as AdwToggleGroup;
227
+ expect(buttons(el).map((btn) => btn.disabled)).toStrictEqual([false, false]);
228
+
229
+ group.sensitive = false;
230
+ expect(buttons(el).map((btn) => btn.disabled)).toStrictEqual([true, true]);
231
+ expect(el.getAttribute('disabled')).toBe('');
232
+
233
+ group.sensitive = true;
234
+ expect(buttons(el).map((btn) => btn.disabled)).toStrictEqual([false, false]);
235
+ expect(el.hasAttribute('disabled')).toBe(false);
236
+ },
237
+ );
238
+ });
239
+
240
+ await it('one toggle takes `enabled="false"` and only that one', () => {
241
+ // `AdwToggle:enabled` — `add_toggle` spends it on the button it builds
242
+ // (adw-toggle-group.c:871). `boolAttribute` is not its reading: the property
243
+ // defaults TRUE, so a bare `<adw-toggle>` is enabled.
244
+ withWidget(
245
+ group({}, [
246
+ '<adw-toggle label="One"></adw-toggle>',
247
+ '<adw-toggle label="Two" enabled="false"></adw-toggle>',
248
+ ]),
249
+ (el) => {
250
+ expect(buttons(el).map((btn) => btn.disabled)).toStrictEqual([false, true]);
251
+ expect((el as unknown as AdwToggleGroup).isToggleEnabled(0)).toBe(true);
252
+ expect((el as unknown as AdwToggleGroup).isToggleEnabled(1)).toBe(false);
253
+ },
254
+ );
255
+ });
256
+
257
+ await it('an insensitive toggle cannot be selected', () => {
258
+ // `set_active_toggle` refuses a toggle that is not enabled (:720), so a
259
+ // disabled button and an unreachable selection cannot disagree.
260
+ withWidget(
261
+ group({}, [
262
+ '<adw-toggle label="One"></adw-toggle>',
263
+ '<adw-toggle label="Two" enabled="false"></adw-toggle>',
264
+ ]),
265
+ (el) => {
266
+ buttons(el)[1].click();
267
+ expect((el as unknown as AdwToggleGroup).active).toBe(0);
268
+ expect(buttons(el).map((btn) => btn.classList.contains('active'))).toStrictEqual([true, false]);
269
+ // The reflected `active` is written only on a real CHANGE
270
+ // (`_selectIndex`), and this press was not one — so it stays absent,
271
+ // the way a group nobody has touched reads.
272
+ expect(el.hasAttribute('active')).toBe(false);
273
+ },
274
+ );
275
+ });
276
+
277
+ await it('an active-but-insensitive toggle hands the selection to an enabled one', () => {
278
+ // `active="1"` beside `<adw-toggle enabled="false">` is a pair upstream cannot
279
+ // express — `adw_toggle_set_enabled` clears the selection instead (:1659). Left
280
+ // alone it would be a checked radio nothing can focus or click.
281
+ withWidget(
282
+ group({ active: '1' }, [
283
+ '<adw-toggle label="One"></adw-toggle>',
284
+ '<adw-toggle label="Two" enabled="false"></adw-toggle>',
285
+ ]),
286
+ (el) => {
287
+ expect((el as unknown as AdwToggleGroup).active).toBe(0);
288
+ expect(buttons(el).map((btn) => btn.classList.contains('active'))).toStrictEqual([true, false]);
289
+ // And the group's ONE tab stop is on the toggle that can take it.
290
+ expect(buttons(el).map((btn) => btn.tabIndex)).toStrictEqual([0, -1]);
291
+ },
292
+ );
293
+ });
294
+
295
+ await it('a group with no enabled toggle leaves the selection alone', () => {
296
+ withWidget(
297
+ group({}, [
298
+ '<adw-toggle label="One" enabled="false"></adw-toggle>',
299
+ '<adw-toggle label="Two" enabled="false"></adw-toggle>',
300
+ ]),
301
+ (el) => {
302
+ // Nothing to move it to. The selection stays where the core put it
303
+ // (index 0), so the first button gets tabIndex 0 (the group's one
304
+ // tab stop) even though it is disabled — the roving filter will
305
+ // skip it, but the attribute reflects the active index.
306
+ expect((el as unknown as AdwToggleGroup).active).toBe(0);
307
+ expect(buttons(el).every((btn) => btn.disabled)).toBe(true);
308
+ expect(buttons(el).map((btn) => btn.tabIndex)).toStrictEqual([0, -1]);
309
+ },
310
+ );
311
+ });
312
+ });
313
+ };
@@ -0,0 +1,52 @@
1
+ // `Gtk.Widget:tooltip-text` on a boxed-list row.
2
+ //
3
+ // A BROWSER'S TOOLTIP IS THE `title` CONTENT ATTRIBUTE, and on a row that attribute is
4
+ // already spoken for: `Adw.PreferencesRow:title` is the row's LABEL and is authored as
5
+ // `title=`, which is the same attribute `HTMLElement.title` reflects. Writing the tooltip
6
+ // to the host would blank the label, so it goes to the row's own parts instead — and the
7
+ // browser then finds it FIRST. Firefox resolves a tooltip by walking UP from the hovered
8
+ // node until an element carries a non-empty `title`, so one `title` per part covers
9
+ // everything inside it. Two consequences, both wanted:
10
+ //
11
+ // - a SLOTTED control that carries a tooltip of its own still wins, which is GTK's
12
+ // precedence too — `gtk_widget_set_tooltip_text` on a child replaces the parent's;
13
+ // - a part the element owns for its OWN tooltip keeps it, which is why the entry row
14
+ // hands over its prefixes / area / suffixes and not its apply button.
15
+ //
16
+ // A PART ONLY COUNTS IF IT COVERS THE ROW. Measured on the shipped row before the
17
+ // `data-row-tooltip` marker existed: a 884x54 `adw-action-row` offered the LABEL over 36 of
18
+ // its 54 rows of pixels and across its 24px of inline padding, and the tooltip only over the
19
+ // 18px the label text happens to occupy — the label column is content-height in a 54px row,
20
+ // so two thirds of the widget's own hit area fell through to the host and found the label.
21
+ // The tooltip has to reach the whole widget the way GTK's does, so the marked part STRETCHES
22
+ // (`scss/_row.scss`) and the row's inline padding is what is left; `row-tooltip.spec.ts`
23
+ // measures the covered region against the row's box so this cannot come back.
24
+ //
25
+ // Upstream has no stylesheet rule for this: GTK draws the tooltip surface itself
26
+ // (`refs/libadwaita/src/stylesheet/widgets/_tooltip.scss`), and `refs/adwaita-web` has no
27
+ // `tooltip` anywhere. The browser's own tooltip IS the port.
28
+
29
+ /**
30
+ * Apply `row`'s `tooltip-text` to `parts`, or clear it when the attribute is absent.
31
+ *
32
+ * `parts` are the row's OWN sections, never a slotted consumer widget — see the header.
33
+ * Each one is marked `data-row-tooltip`, which is the hook `scss/_row.scss` stretches so
34
+ * the tooltip covers the row rather than the label text inside it.
35
+ */
36
+ export function applyRowTooltip(row: HTMLElement, parts: readonly Element[]): void {
37
+ const tooltip = row.getAttribute('tooltip-text') ?? '';
38
+ for (const part of parts) {
39
+ if (tooltip) {
40
+ // Only on a real change: `_render` runs on every unrelated attribute write, and
41
+ // `setAttribute` fires a mutation record even for the value already there.
42
+ if (part.getAttribute('title') !== tooltip) part.setAttribute('title', tooltip);
43
+ if (!part.hasAttribute(TOOLTIP_PART_ATTR)) part.setAttribute(TOOLTIP_PART_ATTR, '');
44
+ } else {
45
+ part.removeAttribute('title');
46
+ part.removeAttribute(TOOLTIP_PART_ATTR);
47
+ }
48
+ }
49
+ }
50
+
51
+ /** The hook `applyRowTooltip` marks its parts with, and `scss/_row.scss` selects on. */
52
+ const TOOLTIP_PART_ATTR = 'data-row-tooltip';