@gjsify/adwaita-web 0.40.0 → 0.42.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 +98 -3
- package/dist/adwaita-web.css +142 -63
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-fonts.spec.d.ts +2 -0
- package/lib/types/breakpoints.d.ts +10 -1
- package/lib/types/connect-lifecycle.spec.d.ts +1 -0
- package/lib/types/elements/adw-about-dialog.d.ts +1 -0
- package/lib/types/elements/adw-action-row.d.ts +2 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-dialog.d.ts +2 -7
- package/lib/types/elements/adw-icon.d.ts +17 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
- package/lib/types/elements/adw-popover.d.ts +1 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
- package/lib/types/elements/adw-switch.d.ts +10 -0
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toggle-group.d.ts +2 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/elements/modal-surface.d.ts +59 -0
- package/lib/types/elements/roving-focus.d.ts +26 -0
- package/lib/types/elements/row-activation.d.ts +26 -0
- package/lib/types/empty-sections.d.ts +15 -0
- package/lib/types/empty-sections.spec.d.ts +1 -0
- package/lib/types/fonts.d.ts +1 -0
- package/lib/types/icon-registry.d.ts +35 -0
- package/lib/types/icon-registry.spec.d.ts +18 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -1
- package/scss/_carousel.scss +15 -1
- package/scss/_checks.scss +8 -8
- package/scss/_combo_row.scss +1 -1
- package/scss/_drop_down.scss +1 -1
- package/scss/_expander_row.scss +1 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icon.scss +47 -0
- package/scss/_icons.generated.scss +36 -6
- package/scss/_password_entry_row.scss +1 -1
- package/scss/_preferences_dialog.scss +3 -2
- package/scss/_progress_bar.scss +1 -1
- package/scss/_row.scss +1 -1
- package/scss/_scrolling.scss +107 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_style_classes.scss +21 -8
- package/scss/_tab_view.scss +1 -1
- package/scss/_theme.scss +1 -0
- package/scss/_toast.scss +6 -24
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +30 -3
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-header-bar.spec.ts +92 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/breakpoints.ts +18 -4
- package/src/chrome.spec.ts +233 -0
- package/src/connect-lifecycle.spec.ts +351 -0
- package/src/elements/adw-about-dialog.ts +24 -18
- package/src/elements/adw-action-row.ts +14 -2
- package/src/elements/adw-alert-dialog.ts +39 -20
- package/src/elements/adw-button-row.ts +3 -0
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-clamp.ts +3 -2
- package/src/elements/adw-data-grid.ts +5 -2
- package/src/elements/adw-dialog.ts +16 -63
- package/src/elements/adw-expander-row.ts +18 -4
- package/src/elements/adw-header-bar.ts +2 -0
- package/src/elements/adw-icon.ts +32 -6
- package/src/elements/adw-inline-view-switcher.ts +12 -0
- package/src/elements/adw-menu-button.ts +17 -10
- package/src/elements/adw-navigation-split-view.ts +30 -7
- package/src/elements/adw-overlay-split-view.ts +15 -3
- package/src/elements/adw-popover.ts +11 -0
- package/src/elements/adw-preferences-dialog.ts +18 -16
- package/src/elements/adw-preferences-group.ts +7 -0
- package/src/elements/adw-sidebar.ts +15 -0
- package/src/elements/adw-status-page.ts +12 -4
- package/src/elements/adw-switch-row.ts +7 -0
- package/src/elements/adw-switch.ts +19 -1
- package/src/elements/adw-tab-view.ts +70 -7
- package/src/elements/adw-toast-overlay.ts +8 -1
- package/src/elements/adw-toggle-group.ts +94 -2
- package/src/elements/adw-toolbar-view.ts +28 -2
- package/src/elements/adw-view-switcher-bar.ts +18 -0
- package/src/elements/adw-view-switcher.ts +14 -0
- package/src/elements/modal-surface.ts +180 -0
- package/src/elements/roving-focus.ts +154 -0
- package/src/elements/row-activation.ts +92 -0
- package/src/empty-sections.spec.ts +81 -0
- package/src/empty-sections.ts +54 -0
- package/src/fonts.ts +25 -0
- package/src/icon-registry.spec.ts +240 -0
- package/src/icon-registry.ts +157 -0
- package/src/index.ts +30 -1
- package/src/keyboard-operable.spec.ts +755 -0
- package/src/scroll-shading.ts +255 -0
- package/src/source-view/theme.ts +15 -3
- package/src/split-button.spec.ts +6 -2
- package/src/style-classes.spec.ts +6 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +14 -0
- package/src/view-switcher.spec.ts +149 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Arrow-key movement for the composites that hand out a roving tabindex.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// `<adw-view-switcher>`, `<adw-view-switcher-bar>`, `<adw-inline-view-switcher>` and
|
|
6
|
+
// `<adw-sidebar>` each declared `role="tablist"` or `role="listbox"`, then set
|
|
7
|
+
// `tabIndex = -1` on every item except the selected one — the roving tabindex — and
|
|
8
|
+
// registered no keydown listener at all. Measured in Firefox: three items, tabIndex
|
|
9
|
+
// `[0, -1, -1]`, and ArrowRight, ArrowLeft, ArrowDown, Home and End all left
|
|
10
|
+
// `document.activeElement` exactly where it was. The two unselected items were reachable
|
|
11
|
+
// by NO key: the roving tabindex had taken them out of the Tab order and nothing had put
|
|
12
|
+
// them back. That is strictly worse than the plain tab stops they replaced — the widget
|
|
13
|
+
// went from clumsy to unusable by adding an accessibility feature half of it.
|
|
14
|
+
//
|
|
15
|
+
// The rule this module implements is the one `<adw-tab-view>` already states in its own
|
|
16
|
+
// header: "the roving tabindex must keep moving: under `role=tablist` a frozen one leaves
|
|
17
|
+
// every inactive tab keyboard-unreachable". `<adw-tab-view>` keeps its own handler — it
|
|
18
|
+
// also owns Ctrl+Tab, Ctrl+Page-Up/Down and Alt+digit from `Adw.TabView`'s shortcut table,
|
|
19
|
+
// plus a strip-local scroll a shared helper has no business knowing about.
|
|
20
|
+
//
|
|
21
|
+
// WHAT THE KEYS DO, and where each behaviour comes from
|
|
22
|
+
//
|
|
23
|
+
// The SHAPE — Tab enters and leaves at the active item, arrows travel inside — is what
|
|
24
|
+
// the C establishes:
|
|
25
|
+
// - `refs/libadwaita/src/adw-toggle-group.c:1045` (`adw_toggle_group_focus`) — Tab
|
|
26
|
+
// forward/backward is PROPAGATED, i.e. focus leaves the group, and every other
|
|
27
|
+
// direction goes to `adw_widget_focus_child`, i.e. moves within it.
|
|
28
|
+
// `AdwInlineViewSwitcher` builds exactly this widget with
|
|
29
|
+
// `accessible-role: GTK_ACCESSIBLE_ROLE_TAB_LIST` (adw-inline-view-switcher.c:702).
|
|
30
|
+
// - `refs/libadwaita/src/adw-sidebar.c:2168` — `gtk_list_box_set_tab_behavior (…,
|
|
31
|
+
// GTK_LIST_TAB_ITEM)`: Tab moves BETWEEN rows and their contents, i.e. the list is one
|
|
32
|
+
// stop from outside, which is the roving tabindex spelled in GTK's own vocabulary.
|
|
33
|
+
//
|
|
34
|
+
// That SELECTION follows focus is grounded for the sidebar and conventional for the two
|
|
35
|
+
// tab lists, and the difference is worth keeping straight:
|
|
36
|
+
// - `refs/libadwaita/src/adw-sidebar.c:2167` — the sidebar's list is a `GtkListBox` in
|
|
37
|
+
// `GTK_SELECTION_SINGLE`, where moving the cursor with an unmodified arrow key selects
|
|
38
|
+
// the row it lands on. The WAI-ARIA listbox practice for a single-select listbox says
|
|
39
|
+
// the same.
|
|
40
|
+
// - `refs/libadwaita/src/adw-view-switcher.c:467` and `adw-view-switcher-button.c:337`
|
|
41
|
+
// declare `TAB_LIST` / `TAB`, but nothing in `AdwToggleGroup` changes `active` on
|
|
42
|
+
// focus: `set_active_toggle` is reached only from `toggle_active_cb`
|
|
43
|
+
// (adw-toggle-group.c:818), which fires on a real toggle. So GTK arrows those two
|
|
44
|
+
// WITHOUT switching the page — manual activation. Selection-follows-focus here is the
|
|
45
|
+
// WAI-ARIA tabs AUTOMATIC-activation pattern, chosen for consistency with
|
|
46
|
+
// `<adw-tab-view>`, which already behaves that way. Not a claim about the C.
|
|
47
|
+
//
|
|
48
|
+
// SELECTION IS NOT ACTIVATION. `<adw-sidebar>` emits `activated` — the documented way to
|
|
49
|
+
// reveal a split view's content pane — and an arrow key must not fire it, or every press
|
|
50
|
+
// navigates. `GtkListBox` splits the same two: an arrow key emits `row-selected`, and only
|
|
51
|
+
// Enter/Space/click emit `row-activated`. So the callback below is a SELECT, and Enter and
|
|
52
|
+
// Space stay the browser's own activation of the `<button>` the item already is.
|
|
53
|
+
//
|
|
54
|
+
// No wrap at the ends, following `<adw-tab-view>`, whose arrows use the non-wrapping
|
|
55
|
+
// `selectPreviousPage`/`selectNextPage` (the wrapping pair is Ctrl+Tab's), and GTK, whose
|
|
56
|
+
// `adw_widget_focus_child` stops at the last child.
|
|
57
|
+
|
|
58
|
+
/** Which axis the items are laid out along — it decides which arrow keys move. */
|
|
59
|
+
export type AdwRovingOrientation = 'horizontal' | 'vertical';
|
|
60
|
+
|
|
61
|
+
export interface AdwRovingFocusInit {
|
|
62
|
+
/**
|
|
63
|
+
* The element the listener sits on — the custom element itself, so items rebuilt at
|
|
64
|
+
* any time are still covered.
|
|
65
|
+
*/
|
|
66
|
+
host: HTMLElement;
|
|
67
|
+
orientation: AdwRovingOrientation;
|
|
68
|
+
/**
|
|
69
|
+
* The items a key may land on, in visual order. The widget filters: a `hidden` or
|
|
70
|
+
* `disabled` item is not navigable, and leaving one in would strand the user on a
|
|
71
|
+
* `focus()` the browser refuses.
|
|
72
|
+
*/
|
|
73
|
+
items: () => readonly HTMLElement[];
|
|
74
|
+
/**
|
|
75
|
+
* Select `item`. Whether the selection actually MOVED is the widget's own business:
|
|
76
|
+
* focus travels with the key either way ({@link attachRovingFocus}).
|
|
77
|
+
*/
|
|
78
|
+
select: (item: HTMLElement) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The arrow pair that moves along each axis. */
|
|
82
|
+
const AXIS_KEYS: Record<AdwRovingOrientation, { previous: string; next: string }> = {
|
|
83
|
+
horizontal: { previous: 'ArrowLeft', next: 'ArrowRight' },
|
|
84
|
+
vertical: { previous: 'ArrowUp', next: 'ArrowDown' },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Make a roving tabindex navigable: arrows step, Home/End jump, and focus travels with
|
|
89
|
+
* the selection so the next keypress has somewhere to start from.
|
|
90
|
+
*/
|
|
91
|
+
export function attachRovingFocus(init: AdwRovingFocusInit): void {
|
|
92
|
+
const { previous, next } = AXIS_KEYS[init.orientation];
|
|
93
|
+
|
|
94
|
+
init.host.addEventListener('keydown', (event) => {
|
|
95
|
+
// A modifier makes it someone else's shortcut: Ctrl+Home is "top of the document",
|
|
96
|
+
// and `Adw.TabView`'s table is full of Ctrl/Alt combinations.
|
|
97
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
98
|
+
|
|
99
|
+
const items = init.items();
|
|
100
|
+
// `contains`, not identity: an item is a button holding an icon, a label and a
|
|
101
|
+
// badge, and a key pressed while one of those is the event target still belongs
|
|
102
|
+
// to the item.
|
|
103
|
+
const from = items.findIndex((item) => item === event.target || item.contains(event.target as Node));
|
|
104
|
+
if (from < 0) return;
|
|
105
|
+
|
|
106
|
+
let to: number;
|
|
107
|
+
switch (event.key) {
|
|
108
|
+
case previous:
|
|
109
|
+
to = from - 1;
|
|
110
|
+
break;
|
|
111
|
+
case next:
|
|
112
|
+
to = from + 1;
|
|
113
|
+
break;
|
|
114
|
+
case 'Home':
|
|
115
|
+
to = 0;
|
|
116
|
+
break;
|
|
117
|
+
case 'End':
|
|
118
|
+
to = items.length - 1;
|
|
119
|
+
break;
|
|
120
|
+
default:
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Claimed even when nothing moves: an ArrowDown at the last row that fell through
|
|
125
|
+
// to the browser would scroll the page out from under a user who is inside the
|
|
126
|
+
// widget.
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
const target = items[to];
|
|
129
|
+
if (target === undefined || target === items[from]) return;
|
|
130
|
+
|
|
131
|
+
// Selection is attempted, and focus moves REGARDLESS of whether it took. Gating
|
|
132
|
+
// the focus move on the selection having changed put the user back in the state
|
|
133
|
+
// this module exists to remove: with focus on row 0 and the selection already on
|
|
134
|
+
// row 1 — reachable by setting `selected` while focus sits elsewhere — the
|
|
135
|
+
// sidebar's `select` returned false for the row the arrow key targeted, so the
|
|
136
|
+
// press was swallowed and focus stayed on a row the roving tabindex had taken out
|
|
137
|
+
// of the Tab order. Measured in Firefox: tabIndex `[-1, 0, -1]`, two ArrowDowns,
|
|
138
|
+
// `document.activeElement` unmoved both times.
|
|
139
|
+
init.select(target);
|
|
140
|
+
|
|
141
|
+
// Focus travels with the roving tabindex or the next keypress goes nowhere — the
|
|
142
|
+
// rule `<adw-tab-view>` states. Plain `focus()`, deliberately, and NOT because
|
|
143
|
+
// these widgets have no scroller of their own: `adw-sidebar` is `overflow-y: auto`
|
|
144
|
+
// (scss/_sidebar.scss) and does. `focus()` walks every scrollable ancestor up to
|
|
145
|
+
// the window, and here that is what a GtkListBox cursor move does too — the
|
|
146
|
+
// enclosing scrolled window scrolls, and an outer one follows. Measured in Firefox
|
|
147
|
+
// on a 4000 px page: 25 ArrowDowns moved the sidebar's own `scrollTop` 0 → 898 and
|
|
148
|
+
// left `window.scrollY` at 0. `<adw-tab-view>` needs `preventScroll` for the
|
|
149
|
+
// opposite reason — C only ever writes the strip's own adjustment
|
|
150
|
+
// (`scroll_to_tab_full`), so a window scroll there is a divergence, and one
|
|
151
|
+
// ArrowRight was measured taking the window from y=1800 to y=8367.
|
|
152
|
+
init.items()[to]?.focus();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Tab stop and Enter/Space activation for the rows that libadwaita makes activatable.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// Measured in Firefox on an `<adw-preferences-group>` holding an activatable
|
|
6
|
+
// `<adw-action-row>`, an `<adw-button-row>` and an `<adw-expander-row>`: Tab reached NONE
|
|
7
|
+
// of them. Every one reported `tabIndex` `-1` and `role` `null` while all three activated
|
|
8
|
+
// on click, so the whole group was operable by mouse and invisible to a keyboard. The only
|
|
9
|
+
// things Tab did reach inside a group were the NATIVE controls a row happens to contain —
|
|
10
|
+
// `<adw-switch-row>`'s bare checkbox and `<adw-combo-row>`'s `<select>` — i.e. the row was
|
|
11
|
+
// never the thing that took focus, even where upstream says it is.
|
|
12
|
+
//
|
|
13
|
+
// GTK gives this free and the web renderer does not inherit it: every one of these extends
|
|
14
|
+
// `GtkListBoxRow`, which is focusable by construction and activates on Enter and Space.
|
|
15
|
+
//
|
|
16
|
+
// WHICH ROWS, AND WHY EACH
|
|
17
|
+
//
|
|
18
|
+
// Derived from the C, not chosen — a row that is a tab stop it should not be turns every
|
|
19
|
+
// static label into a stop, and one that is not becomes unreachable:
|
|
20
|
+
//
|
|
21
|
+
// - `<adw-action-row>` only while `activatable`. A plain row is a label.
|
|
22
|
+
// - `<adw-button-row>` always: `adw-button-row.c:31` says "AdwButtonRow is always
|
|
23
|
+
// activatable", and the upstream template hardcodes it.
|
|
24
|
+
// - `<adw-expander-row>` always, on its HEADER — `adw_expander_row_grab_focus`
|
|
25
|
+
// (adw-expander-row.c:123) forwards focus to `priv->action_row`, so the header row is
|
|
26
|
+
// the focus target and the disclosure control at once.
|
|
27
|
+
// - `<adw-switch-row>` on the ROW, and its slider stops being a tab stop:
|
|
28
|
+
// `adw_switch_row_init` sets `gtk_widget_set_can_focus (self->slider, FALSE)`
|
|
29
|
+
// (adw-switch-row.c:159) and `gtk_list_box_row_set_activatable (…, TRUE)` (:160).
|
|
30
|
+
//
|
|
31
|
+
// `<adw-combo-row>` is deliberately NOT here. Its control is a native `<select>` stretched
|
|
32
|
+
// over the row: already a tab stop, already a combobox to the accessibility tree, already
|
|
33
|
+
// arrow-navigable, and already `disabled` at one option or fewer — which is exactly
|
|
34
|
+
// `gtk_list_box_row_set_activatable (…, n_items > 1)` (adw-combo-row.c:194) without a line
|
|
35
|
+
// of our own. Making the row the tab stop would take focus off it and put a SECOND
|
|
36
|
+
// combobox in the tree.
|
|
37
|
+
//
|
|
38
|
+
// The key set is `GtkListBoxRow`'s: Enter and Space. Space is prevented because the
|
|
39
|
+
// browser would otherwise scroll the page under the row the user just activated.
|
|
40
|
+
|
|
41
|
+
/** What a row needs to become the keyboard's idea of one activatable thing. */
|
|
42
|
+
export interface AdwRowActivationInit {
|
|
43
|
+
/** The element that becomes the tab stop and receives the keys. */
|
|
44
|
+
row: HTMLElement;
|
|
45
|
+
/** Re-read on every {@link AdwRowActivation.sync}: is the row activatable right now? */
|
|
46
|
+
activatable: () => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* What Enter and Space do. Defaults to `row.click()` so a row keeps ONE activation
|
|
49
|
+
* path — the click handler it already has — rather than growing a second one that can
|
|
50
|
+
* drift from it.
|
|
51
|
+
*/
|
|
52
|
+
activate?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AdwRowActivation {
|
|
56
|
+
/** Re-derive the tab stop after anything that can change `activatable()`. */
|
|
57
|
+
sync(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Make `row` a tab stop while it is activatable, and activate it on Enter and Space.
|
|
62
|
+
*
|
|
63
|
+
* The listener is the element's OWN and is bound once, without teardown: it survives a
|
|
64
|
+
* re-parent and is collected with the element, so there is nothing here for
|
|
65
|
+
* `check-adwaita-connect-rebind.mjs` to hold. Only bindings that reach outside the element
|
|
66
|
+
* need releasing and re-arming.
|
|
67
|
+
*/
|
|
68
|
+
export function attachRowActivation(init: AdwRowActivationInit): AdwRowActivation {
|
|
69
|
+
const { row, activatable } = init;
|
|
70
|
+
const activate = init.activate ?? (() => row.click());
|
|
71
|
+
|
|
72
|
+
row.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
73
|
+
// A child control owns its own keys: a `<select>`, an entry's `<input>` and a
|
|
74
|
+
// suffix button all use Enter or Space for something else, and swallowing them
|
|
75
|
+
// here would break the control to activate the row it sits in.
|
|
76
|
+
if (event.target !== row) return;
|
|
77
|
+
if (!activatable()) return;
|
|
78
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
activate();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const sync = () => {
|
|
84
|
+
// `removeAttribute`, not `tabIndex = -1`: a row that is not activatable is not
|
|
85
|
+
// focusable at all upstream, and writing `-1` would make every static label
|
|
86
|
+
// programmatically focusable instead.
|
|
87
|
+
if (activatable()) row.tabIndex = 0;
|
|
88
|
+
else row.removeAttribute('tabindex');
|
|
89
|
+
};
|
|
90
|
+
sync();
|
|
91
|
+
return { sync };
|
|
92
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The `hidden` derivation itself, driven in both directions.
|
|
2
|
+
//
|
|
3
|
+
// `connect-lifecycle.spec.ts` drives the WIDGETS: it appends into every container a
|
|
4
|
+
// consumer can reach and requires the child to be laid out. That half alone does not
|
|
5
|
+
// hold the invariant — replacing the whole derivation with `section.hidden = false`
|
|
6
|
+
// left the full adwaita-web browser suite green, because nothing anywhere asserted that
|
|
7
|
+
// an EMPTY section is hidden or that it goes back to hidden when its last child leaves.
|
|
8
|
+
// An empty bar that re-enters the flow is what that mutant ships.
|
|
9
|
+
//
|
|
10
|
+
// Driven against the helper rather than a widget on purpose: the two elements whose
|
|
11
|
+
// sections are private fields (`<adw-alert-dialog>`, `<adw-status-page>`) have no
|
|
12
|
+
// container getter, so the widget driver cannot see them, and giving them one to satisfy
|
|
13
|
+
// a test would be public API invented for the test.
|
|
14
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
15
|
+
|
|
16
|
+
import { bindEmptySections } from './empty-sections.js';
|
|
17
|
+
|
|
18
|
+
/** A section mounted in the document, since `hidden` is only observable where there is layout. */
|
|
19
|
+
function section(): HTMLElement {
|
|
20
|
+
const el = document.createElement('div');
|
|
21
|
+
document.body.appendChild(el);
|
|
22
|
+
return el;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One microtask checkpoint — where a MutationObserver callback lands. */
|
|
26
|
+
const settle = () => Promise.resolve();
|
|
27
|
+
|
|
28
|
+
export const AdwEmptySectionsTest = async () => {
|
|
29
|
+
await describe('bindEmptySections', async () => {
|
|
30
|
+
await it('hides a section that starts empty', async () => {
|
|
31
|
+
const el = section();
|
|
32
|
+
bindEmptySections(el);
|
|
33
|
+
const hidden = el.hidden;
|
|
34
|
+
el.remove();
|
|
35
|
+
expect(`empty: hidden=${hidden}`).toBe('empty: hidden=true');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await it('shows it when a child lands and hides it again when the last one leaves', async () => {
|
|
39
|
+
const el = section();
|
|
40
|
+
bindEmptySections(el);
|
|
41
|
+
|
|
42
|
+
const child = document.createElement('span');
|
|
43
|
+
el.appendChild(child);
|
|
44
|
+
await settle();
|
|
45
|
+
const filled = el.hidden;
|
|
46
|
+
|
|
47
|
+
child.remove();
|
|
48
|
+
await settle();
|
|
49
|
+
const emptied = el.hidden;
|
|
50
|
+
|
|
51
|
+
el.remove();
|
|
52
|
+
expect(`filled: hidden=${filled} | emptied: hidden=${emptied}`).toBe(
|
|
53
|
+
'filled: hidden=false | emptied: hidden=true',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await it('counts element children, not child nodes', async () => {
|
|
58
|
+
const el = section();
|
|
59
|
+
bindEmptySections(el);
|
|
60
|
+
// The whitespace an indented `<div>\n </div>` carries is a text node, and a
|
|
61
|
+
// section full of indentation is still an empty section. This is also why the
|
|
62
|
+
// CSS `:empty` that this looks like cannot express the rule.
|
|
63
|
+
el.appendChild(document.createTextNode('\n '));
|
|
64
|
+
await settle();
|
|
65
|
+
const hidden = el.hidden;
|
|
66
|
+
el.remove();
|
|
67
|
+
expect(`whitespace only: hidden=${hidden}`).toBe('whitespace only: hidden=true');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await it('derives each section it is handed independently', async () => {
|
|
71
|
+
const empty = section();
|
|
72
|
+
const filled = section();
|
|
73
|
+
filled.appendChild(document.createElement('span'));
|
|
74
|
+
bindEmptySections(empty, filled);
|
|
75
|
+
const report = `empty=${empty.hidden} filled=${filled.hidden}`;
|
|
76
|
+
empty.remove();
|
|
77
|
+
filled.remove();
|
|
78
|
+
expect(report).toBe('empty=true filled=false');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// "Hidden while it has no children" — the ONE derivation, kept live.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT
|
|
4
|
+
//
|
|
5
|
+
// Eight sections across five elements derived `hidden = childElementCount === 0`
|
|
6
|
+
// exactly once — inside the `_initialized` guard, or inside a `_render()` only
|
|
7
|
+
// `attributeChangedCallback` can reach. Neither hears a childList change. Six of them
|
|
8
|
+
// document themselves as imperative append points (`<adw-toolbar-view>.topBar`/
|
|
9
|
+
// `.bottomBar`, `<adw-action-row>` and `<adw-expander-row>`'s `prefixSection`/
|
|
10
|
+
// `.suffixSection`), and the other two are the child slots of `<adw-alert-dialog>` and
|
|
11
|
+
// `<adw-status-page>`. So a header bar appended AFTER connect sat in the DOM with the
|
|
12
|
+
// right classes at offsetHeight 0, while the identical DECLARED bar rendered at 48px:
|
|
13
|
+
// the compiled sheet ends with a global `[hidden] { display: none !important }`, which
|
|
14
|
+
// the widget's own `display: flex` cannot beat. Measured the same in Firefox and
|
|
15
|
+
// Chromium.
|
|
16
|
+
//
|
|
17
|
+
// `<adw-clamp>` had already paid for the same "evaluated once at connect time" shape
|
|
18
|
+
// and fixed it with a MutationObserver on `{ childList: true }`. This is that TECHNIQUE
|
|
19
|
+
// applied to the `hidden` derivation, with one home instead of an eighth site deriving
|
|
20
|
+
// it an eighth time — the clamp keeps its own observer, because what it re-derives is a
|
|
21
|
+
// width, not `hidden`. `<adw-preferences-group>` is the third live one: `hasHeaderSuffix`
|
|
22
|
+
// feeds a core derivation rather than a `hidden` flag, so its existing host observer
|
|
23
|
+
// watches the suffix instead.
|
|
24
|
+
//
|
|
25
|
+
// NOTHING TO TEAR DOWN, deliberately. The observed nodes are children this element
|
|
26
|
+
// BUILT and owns, so the observer is unreachable the moment the element is, and is
|
|
27
|
+
// collected with it — the same reasoning `<adw-overlay-split-view>` writes above the
|
|
28
|
+
// `keydown` listener it binds once and never removes. Only a binding that reaches
|
|
29
|
+
// OUTSIDE (document, window, a media query) has to be released on disconnect and
|
|
30
|
+
// re-established on every connect. Keeping it bound while detached is also the better
|
|
31
|
+
// behaviour: a bar appended to a parked widget is already right when it comes back.
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hide each of `sections` exactly while it holds no element children — now, and one
|
|
35
|
+
* MICROTASK after every later childList change.
|
|
36
|
+
*
|
|
37
|
+
* The deferral is what a MutationObserver is; it matters because the derivation used to
|
|
38
|
+
* run synchronously inside `_render()`. It lands before paint, so layout is never wrong,
|
|
39
|
+
* but a caller that appends and measures in the SAME task still reads the old answer —
|
|
40
|
+
* `<adw-toolbar-view>._syncClasses` does exactly that via `offsetHeight`, and its
|
|
41
|
+
* undershoot classes are corrected by the ResizeObserver on the next frame.
|
|
42
|
+
*
|
|
43
|
+
* Element children, not child NODES: whitespace between two slotted bars is a text node
|
|
44
|
+
* and an empty bar full of indentation is still an empty bar — which is also why the
|
|
45
|
+
* CSS `:empty` this looks like cannot express it.
|
|
46
|
+
*/
|
|
47
|
+
export function bindEmptySections(...sections: readonly HTMLElement[]): void {
|
|
48
|
+
const derive = () => {
|
|
49
|
+
for (const section of sections) section.hidden = section.childElementCount === 0;
|
|
50
|
+
};
|
|
51
|
+
const observer = new MutationObserver(derive);
|
|
52
|
+
for (const section of sections) observer.observe(section, { childList: true });
|
|
53
|
+
derive();
|
|
54
|
+
}
|
package/src/fonts.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// `@gjsify/adwaita-web/fonts` — the opt-in that actually ships the typeface.
|
|
2
|
+
//
|
|
3
|
+
// A SEPARATE SUBPATH, not part of the root entry, and both halves of that are
|
|
4
|
+
// deliberate:
|
|
5
|
+
//
|
|
6
|
+
// • Separate, because the payload is base64 TTF. Inlined, the two Adwaita Sans
|
|
7
|
+
// faces are 2.39 MB / 1.18 MB gzip against a 190 KB / 26 KB stylesheet, and
|
|
8
|
+
// `gjsify build --app browser` emits ONE file with `inlineDynamicImports`, so
|
|
9
|
+
// there is no lazy form that costs less. An app on GNOME already has the
|
|
10
|
+
// typeface installed; an app served to arbitrary browsers does not. That is
|
|
11
|
+
// the app's call, so it is the app's import.
|
|
12
|
+
// • A VALUE export rather than a side-effect module, because a side-effect
|
|
13
|
+
// import is exactly how the fonts stopped shipping in the first place:
|
|
14
|
+
// `src/index.ts` carried `import '@gjsify/adwaita-fonts';` for its whole
|
|
15
|
+
// life, and under css-as-string that resolves to `export default "<css>"`,
|
|
16
|
+
// tree-shakes to nothing, and exits 0. `applyAdwaitaFonts` has to be CALLED,
|
|
17
|
+
// so there is no way to write the opt-in and still get silence.
|
|
18
|
+
//
|
|
19
|
+
// Usage:
|
|
20
|
+
//
|
|
21
|
+
// import '@gjsify/adwaita-web';
|
|
22
|
+
// import { applyAdwaitaFonts } from '@gjsify/adwaita-web/fonts';
|
|
23
|
+
// applyAdwaitaFonts();
|
|
24
|
+
|
|
25
|
+
export { ADWAITA_FONTS_CSS, ADWAITA_FONTS_STYLE_ID, applyAdwaitaFonts } from '@gjsify/adwaita-fonts/embedded';
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// What an icon actually DRAWS — the assertion `adw-icon.spec.ts` did not make.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT. `_icon.scss` set `background-color: currentColor` plus mask sizing and no
|
|
4
|
+
// `mask-image`, so a name outside the compiled `.adw-icon--<name>` set painted a solid 16px
|
|
5
|
+
// square in the widget's text colour. Every spec in this package agreed with it: they read
|
|
6
|
+
// `classList`, and the class WAS applied — correctly, and to nothing. Measured in Firefox
|
|
7
|
+
// before the fix, `<span class="adw-icon adw-icon--dialog-error">` reported
|
|
8
|
+
// `mask-image: none` with `background-color: rgb(0, 128, 0)` and a 16x16 box, next to a
|
|
9
|
+
// `go-next` control whose mask was an 825-character data URI. That is the whole defect, and
|
|
10
|
+
// the reason it survived is that no assertion anywhere read `maskImage`.
|
|
11
|
+
//
|
|
12
|
+
// So these tests go through `getComputedStyle`. Three states have to stay distinguishable:
|
|
13
|
+
// a compiled name draws its own glyph, an unknown name draws `image-missing`, and a name
|
|
14
|
+
// that was never given draws nothing at all.
|
|
15
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
16
|
+
|
|
17
|
+
import { isIconAvailable, registerIcon } from './icon-registry.js';
|
|
18
|
+
import type { AdwIcon } from './elements/adw-icon.js';
|
|
19
|
+
|
|
20
|
+
/** A name no compiled entry can collide with, for the "not shipped" arm. */
|
|
21
|
+
const ABSENT = 'gjsify-registry-probe';
|
|
22
|
+
|
|
23
|
+
/** An Adwaita symbolic SVG, small and distinctive — a filled 8px square in the 16px grid. */
|
|
24
|
+
const PROBE_SVG = `<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
|
|
25
|
+
<path d="m 4 4 h 8 v 8 h -8 z m 0 0" fill="currentColor"/>
|
|
26
|
+
</svg>`;
|
|
27
|
+
|
|
28
|
+
function mount(iconName: string | null): { el: AdwIcon; host: HTMLElement } {
|
|
29
|
+
const host = document.createElement('div');
|
|
30
|
+
document.body.appendChild(host);
|
|
31
|
+
const el = document.createElement('adw-icon') as AdwIcon;
|
|
32
|
+
if (iconName !== null) el.iconName = iconName;
|
|
33
|
+
host.appendChild(el);
|
|
34
|
+
return { el, host };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What an icon box actually draws. Exported because the vector-driven icon specs
|
|
39
|
+
* (`adw-action-rows`, `adw-button-content`, `split-button`) need the same discriminator:
|
|
40
|
+
* they used to read `classList` alone, which is green on a correctly-applied class over
|
|
41
|
+
* a glyph nobody has.
|
|
42
|
+
*/
|
|
43
|
+
export const maskOf = (el: HTMLElement): string => getComputedStyle(el).maskImage;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `--icon-image-missing` the stylesheet compiles, as the cascade resolves it.
|
|
47
|
+
*
|
|
48
|
+
* Compared by EXACT equality against a computed `mask-image` throughout, which is sound
|
|
49
|
+
* because Firefox serializes both identically — measured, 1336 characters each, byte for
|
|
50
|
+
* byte. A substring test would not be sound: every icon's data URI opens with the same
|
|
51
|
+
* `url("data:image/svg+xml,%3Csvg%20height…` prefix, so a leading slice matches ALL of
|
|
52
|
+
* them, and the first version of this file passed that way while asserting nothing.
|
|
53
|
+
*/
|
|
54
|
+
export function fallbackMask(): string {
|
|
55
|
+
return getComputedStyle(document.documentElement).getPropertyValue('--icon-image-missing').trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const AdwIconRegistryTest = async () => {
|
|
59
|
+
await describe('<adw-icon> resolves a name to a real mask', async () => {
|
|
60
|
+
await it('a compiled name draws its OWN glyph, not the fallback', () => {
|
|
61
|
+
const { el, host } = mount('go-next');
|
|
62
|
+
const mask = maskOf(el);
|
|
63
|
+
expect(mask.startsWith('url(')).toBe(true);
|
|
64
|
+
// The discriminator. After the fix EVERY icon box has a non-`none` mask, so
|
|
65
|
+
// "has a mask" no longer separates a drawn glyph from a fallen-back one.
|
|
66
|
+
expect(mask).not.toBe(fallbackMask());
|
|
67
|
+
expect(getComputedStyle(el).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
|
68
|
+
host.remove();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await it('an unknown name draws image-missing rather than a solid square', () => {
|
|
72
|
+
const { el, host } = mount(ABSENT);
|
|
73
|
+
// The class IS applied — that was never the bug.
|
|
74
|
+
expect(el.classList.contains(`adw-icon--${ABSENT}`)).toBe(true);
|
|
75
|
+
expect(maskOf(el)).not.toBe('none');
|
|
76
|
+
// …and what it draws is libadwaita's own placeholder, the same glyph GTK's icon
|
|
77
|
+
// theme substitutes for a name it cannot find.
|
|
78
|
+
expect(maskOf(el)).toBe(fallbackMask());
|
|
79
|
+
// Still painted: the fill is what makes the mask visible.
|
|
80
|
+
expect(getComputedStyle(el).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
|
81
|
+
host.remove();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await it('no name at all draws NOTHING — the box is transparent', () => {
|
|
85
|
+
const { el, host } = mount(null);
|
|
86
|
+
expect(el.resolvedIconName).toBe('');
|
|
87
|
+
// `Gtk.Image` with a NULL icon-name renders empty; only a name that was given
|
|
88
|
+
// and could not be resolved earns the broken glyph.
|
|
89
|
+
expect(getComputedStyle(el).backgroundColor).toBe('rgba(0, 0, 0, 0)');
|
|
90
|
+
// The box still occupies its slot, as the GTK image does.
|
|
91
|
+
expect(getComputedStyle(el).width).toBe('16px');
|
|
92
|
+
host.remove();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await it('a name that is not one CSS token draws image-missing, not nothing', () => {
|
|
96
|
+
// GTK has no third answer: `gtk_icon_theme_lookup_icon` never returns NULL, so
|
|
97
|
+
// `org.gnome.Builder` on a machine without Builder paints the broken glyph.
|
|
98
|
+
// The web used to paint an invisible hole instead — a VISIBLE 16px slot with
|
|
99
|
+
// nothing in it, since `resolvedIconName === ''` was read as "no icon asked
|
|
100
|
+
// for". Reachable through a first-class attribute: `application-icon` is the
|
|
101
|
+
// application ID by convention (adw-about-dialog.c:1215).
|
|
102
|
+
for (const unusable of ['a b', 'org.gnome.Builder']) {
|
|
103
|
+
const { el, host } = mount(unusable);
|
|
104
|
+
expect(el.resolvedIconName).toBe('');
|
|
105
|
+
expect(maskOf(el)).toBe(fallbackMask());
|
|
106
|
+
expect(getComputedStyle(el).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
|
107
|
+
host.remove();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await describe('a name carried by STATE still resolves', async () => {
|
|
113
|
+
await it("the password row's peek toggle draws both of its glyphs", () => {
|
|
114
|
+
// These two reach `<adw-icon>.iconName` as `state.peekIconName`, a property of
|
|
115
|
+
// the core's `PasswordEntryRowState`, so no literal spells them at the point of
|
|
116
|
+
// use. `check-adwaita-icon-masks.mjs` reads their DECLARATIONS
|
|
117
|
+
// (`PASSWORD_REVEAL_ICON_NAME`), which proves the map entry exists — never that
|
|
118
|
+
// a glyph arrives on the element. That is this test, measured rather than
|
|
119
|
+
// asserted against the same constant the widget writes, which is how
|
|
120
|
+
// `entry-rows.spec.ts` reads `dataset.iconName` and agrees with itself.
|
|
121
|
+
const host = document.createElement('div');
|
|
122
|
+
document.body.appendChild(host);
|
|
123
|
+
const row = document.createElement('adw-password-entry-row') as HTMLElement & { revealed: boolean };
|
|
124
|
+
host.appendChild(row);
|
|
125
|
+
|
|
126
|
+
const toggleIcon = row.querySelector('.adw-password-entry-row-toggle .adw-icon') as HTMLElement;
|
|
127
|
+
const masked = maskOf(toggleIcon);
|
|
128
|
+
expect(masked).not.toBe(fallbackMask());
|
|
129
|
+
|
|
130
|
+
row.revealed = true;
|
|
131
|
+
const revealed = maskOf(toggleIcon);
|
|
132
|
+
expect(revealed).not.toBe(fallbackMask());
|
|
133
|
+
// …and the two are DIFFERENT glyphs, or one of them is drawing the other's.
|
|
134
|
+
expect(revealed).not.toBe(masked);
|
|
135
|
+
host.remove();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await describe('registerIcon — the documented way out of the compiled set', async () => {
|
|
140
|
+
await it('reports an unshipped name as unavailable before registration', () => {
|
|
141
|
+
expect(isIconAvailable(ABSENT)).toBe(false);
|
|
142
|
+
// …and a compiled one as available, so the check is not answering `false` to
|
|
143
|
+
// everything. `image-missing` is the case a fallback-comparison would get wrong.
|
|
144
|
+
expect(isIconAvailable('go-next')).toBe(true);
|
|
145
|
+
expect(isIconAvailable('image-missing')).toBe(true);
|
|
146
|
+
expect(isIconAvailable('')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await it('makes the name resolve, on an element that is ALREADY mounted', () => {
|
|
150
|
+
const { el, host } = mount(ABSENT);
|
|
151
|
+
const before = maskOf(el);
|
|
152
|
+
expect(before).toBe(fallbackMask());
|
|
153
|
+
|
|
154
|
+
registerIcon(ABSENT, PROBE_SVG);
|
|
155
|
+
|
|
156
|
+
const after = maskOf(el);
|
|
157
|
+
expect(after).not.toBe(before);
|
|
158
|
+
expect(after).not.toBe(fallbackMask());
|
|
159
|
+
// The glyph really is the SVG that was handed over: `toDataUri` percent-encodes
|
|
160
|
+
// it, so the path data survives as `m%204%204`.
|
|
161
|
+
expect(after.includes('m%204%204')).toBe(true);
|
|
162
|
+
expect(isIconAvailable(ABSENT)).toBe(true);
|
|
163
|
+
host.remove();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await it('serves an element mounted AFTER the registration too', () => {
|
|
167
|
+
const { el, host } = mount(`${ABSENT}-symbolic`);
|
|
168
|
+
// The `-symbolic` suffix is stripped on both sides, so a consumer may spell the
|
|
169
|
+
// name either way at either end.
|
|
170
|
+
expect(el.resolvedIconName).toBe(ABSENT);
|
|
171
|
+
expect(maskOf(el).includes('m%204%204')).toBe(true);
|
|
172
|
+
host.remove();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await it('re-registering REPLACES the glyph without a second rule', () => {
|
|
176
|
+
const sheet = (document.getElementById('adwaita-web-icon-registry') as HTMLStyleElement).sheet;
|
|
177
|
+
const rulesBefore = (sheet as CSSStyleSheet).cssRules.length;
|
|
178
|
+
|
|
179
|
+
registerIcon(ABSENT, PROBE_SVG.replace('m 4 4 h 8 v 8 h -8 z', 'm 2 2 h 12 v 12 h -12 z'));
|
|
180
|
+
|
|
181
|
+
const { el, host } = mount(ABSENT);
|
|
182
|
+
expect(maskOf(el).includes('m%202%202')).toBe(true);
|
|
183
|
+
expect((sheet as CSSStyleSheet).cssRules.length).toBe(rulesBefore);
|
|
184
|
+
host.remove();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await it('throws on a name that could never be one CSS class', () => {
|
|
188
|
+
// Silently registering nothing is the exact failure this area exists to end, and
|
|
189
|
+
// unlike a render this is an explicit call with a wrong argument.
|
|
190
|
+
expect(() => registerIcon('org.gnome.Builder', PROBE_SVG)).toThrow();
|
|
191
|
+
expect(() => registerIcon('a b', PROBE_SVG)).toThrow();
|
|
192
|
+
expect(() => registerIcon('', PROBE_SVG)).toThrow();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await it('throws on a payload that is not SVG source, and registers nothing', () => {
|
|
196
|
+
// The NAME was checked from the start; the payload was not, and a wrong payload
|
|
197
|
+
// is worse than no registration at all: `toDataUri` percent-encodes anything, so
|
|
198
|
+
// the mask comes out well-formed and masks NOTHING, the registered class beats
|
|
199
|
+
// `:where(.adw-icon)` and switches this PR's fallback off, and `isIconAvailable`
|
|
200
|
+
// answers `true`. Measured in Firefox before the guard: `'not an svg at all'`
|
|
201
|
+
// gave `url("data:image/svg+xml,not%20an%20svg%20at%20all")` on a box painted 0 %,
|
|
202
|
+
// where the unregistered control painted the 30 % image-missing glyph.
|
|
203
|
+
const REJECTED = 'gjsify-registry-payload-probe';
|
|
204
|
+
expect(() => registerIcon(REJECTED, 'not an svg at all')).toThrow();
|
|
205
|
+
expect(() => registerIcon(REJECTED, '')).toThrow();
|
|
206
|
+
// The likeliest wrong argument of all: what the custom property HOLDS, handed
|
|
207
|
+
// over instead of what it was made from.
|
|
208
|
+
expect(() => registerIcon(REJECTED, 'url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E")')).toThrow();
|
|
209
|
+
expect(isIconAvailable(REJECTED)).toBe(false);
|
|
210
|
+
|
|
211
|
+
const { el, host } = mount(REJECTED);
|
|
212
|
+
expect(maskOf(el)).toBe(fallbackMask());
|
|
213
|
+
host.remove();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await it('rebuilds its rules after the <style> element is taken away', () => {
|
|
217
|
+
// What an SPA head swap or an Astro view transition does. The record of inserted
|
|
218
|
+
// rules is module-level and outlives the element it describes, so the early
|
|
219
|
+
// return above used to skip the insert forever: the custom property was re-set
|
|
220
|
+
// and the rule that consumes it never came back.
|
|
221
|
+
const LOST = 'gjsify-registry-sheet-probe';
|
|
222
|
+
registerIcon(LOST, PROBE_SVG);
|
|
223
|
+
document.getElementById('adwaita-web-icon-registry')?.remove();
|
|
224
|
+
|
|
225
|
+
registerIcon(LOST, PROBE_SVG);
|
|
226
|
+
|
|
227
|
+
expect(document.getElementById('adwaita-web-icon-registry')).not.toBe(null);
|
|
228
|
+
const { el, host } = mount(LOST);
|
|
229
|
+
expect(maskOf(el).includes('m%204%204')).toBe(true);
|
|
230
|
+
host.remove();
|
|
231
|
+
|
|
232
|
+
// …and the names registered BEFORE the swap are on the rebuilt sheet too. Their
|
|
233
|
+
// custom properties survived on the document element, so re-issuing only the
|
|
234
|
+
// one name being registered would leave every earlier icon on the fallback.
|
|
235
|
+
const { el: earlier, host: earlierHost } = mount(ABSENT);
|
|
236
|
+
expect(maskOf(earlier)).not.toBe(fallbackMask());
|
|
237
|
+
earlierHost.remove();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
};
|