@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.
- package/README.md +8 -0
- package/dist/adwaita-web.css +129 -27
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-status-page.spec.d.ts +1 -0
- package/lib/types/attributes.d.ts +19 -0
- package/lib/types/elements/adw-action-row.d.ts +4 -0
- package/lib/types/elements/adw-combo-row.d.ts +4 -0
- package/lib/types/elements/adw-entry-row.d.ts +6 -0
- package/lib/types/elements/adw-expander-row.d.ts +2 -0
- package/lib/types/elements/adw-header-bar.d.ts +6 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +13 -0
- package/lib/types/elements/adw-sidebar.d.ts +3 -2
- package/lib/types/elements/adw-spin-row.d.ts +6 -0
- package/lib/types/elements/adw-switch-row.d.ts +4 -0
- package/lib/types/elements/adw-toggle-group.d.ts +17 -0
- package/lib/types/elements/gtk-scrolled-window.d.ts +10 -2
- package/lib/types/elements/gtk-window.d.ts +8 -2
- package/lib/types/index.d.ts +1 -1
- package/lib/types/row-line-clamp.d.ts +20 -0
- package/lib/types/row-tooltip.d.ts +8 -0
- package/lib/types/row-tooltip.spec.d.ts +1 -0
- package/lib/types/shared-tree-builder.d.ts +49 -19
- package/lib/types/shared-tree-mount.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/lib/types/widget-size.spec.d.ts +1 -0
- package/package.json +8 -8
- package/scss/_action_row.scss +39 -15
- package/scss/_button_row.scss +5 -0
- package/scss/_expander_row.scss +36 -3
- package/scss/_headerbar.scss +2 -1
- package/scss/_preferences_row.scss +9 -0
- package/scss/_row.scss +16 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_toggle_group.scss +28 -0
- package/scss/_widget.scss +14 -3
- package/scss/_window.scss +13 -1
- package/src/adw-action-rows.spec.ts +255 -0
- package/src/adw-header-bar.spec.ts +18 -0
- package/src/adw-status-page.spec.ts +124 -0
- package/src/attributes.ts +52 -0
- package/src/capabilities.mjs +3 -0
- package/src/construct-vectors.spec.ts +1 -1
- package/src/elements/adw-action-row.ts +31 -2
- package/src/elements/adw-alert-dialog.ts +4 -1
- package/src/elements/adw-button-row.ts +7 -1
- package/src/elements/adw-combo-row.ts +26 -1
- package/src/elements/adw-entry-row.ts +22 -12
- package/src/elements/adw-expander-row.ts +34 -1
- package/src/elements/adw-header-bar.ts +18 -4
- package/src/elements/adw-overlay-split-view.ts +29 -6
- package/src/elements/adw-preferences-row.ts +5 -2
- package/src/elements/adw-sidebar.ts +33 -5
- package/src/elements/adw-spin-row.ts +37 -2
- package/src/elements/adw-status-page.ts +10 -2
- package/src/elements/adw-switch-row.ts +25 -1
- package/src/elements/adw-toggle-group.ts +119 -10
- package/src/elements/adw-window.ts +9 -4
- package/src/elements/gtk-scrolled-window.ts +18 -7
- package/src/elements/gtk-window.ts +9 -3
- package/src/gtk-window.spec.ts +12 -0
- package/src/index.ts +1 -1
- package/src/keyboard-operable.spec.ts +47 -11
- package/src/row-line-clamp.ts +74 -0
- package/src/row-tooltip.spec.ts +313 -0
- package/src/row-tooltip.ts +52 -0
- package/src/shared-tree-builder.ts +88 -35
- package/src/shared-tree-mount.spec.ts +130 -0
- package/src/shared-trees.spec.ts +15 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +8 -0
- 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.
|
|
451
|
-
this.
|
|
452
|
-
this.
|
|
453
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
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) {
|
package/src/gtk-window.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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>`
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
// in the walk to step over it.
|
|
635
|
-
//
|
|
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`
|
|
638
|
-
//
|
|
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';
|