@gjsify/adwaita-web 0.41.0 → 0.43.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 +30 -23
- 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 +8 -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-toggle-group.d.ts +2 -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/elements/swipe-drag.d.ts +63 -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 +1 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -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/_icon.scss +47 -0
- package/scss/_icons.generated.scss +18 -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/_status_page.scss +1 -1
- package/scss/_tab_view.scss +1 -1
- package/scss/_toast.scss +6 -24
- package/scss/_variables.scss +23 -3
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/breakpoints.ts +18 -4
- 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 +86 -5
- 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 +7 -4
- 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 +6 -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/elements/swipe-drag.ts +450 -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 +24 -1
- package/src/keyboard-operable.spec.ts +755 -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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The runtime half of icon resolution — how a name OUTSIDE the compiled set gets drawn.
|
|
2
|
+
//
|
|
3
|
+
// `scripts/build-scss.mjs` inlines a chosen subset of `@gjsify/adwaita-icons` into the
|
|
4
|
+
// stylesheet as `--icon-<name>` custom properties plus matching `.adw-icon--<name>` mask
|
|
5
|
+
// classes. The subset exists because the whole set does not fit — inlining every icon that
|
|
6
|
+
// package exports costs a measured ~1.07 MB of data-URI, some five times the stylesheet it
|
|
7
|
+
// would sit in — so a consumer whose app needs a glyph this package does not ship needs a
|
|
8
|
+
// way in that is not "wait for a release".
|
|
9
|
+
//
|
|
10
|
+
// That is this module. `registerIcon` writes the SAME pair the generator writes — the
|
|
11
|
+
// custom property on the document element, the mask class into a stylesheet this module
|
|
12
|
+
// owns — so a registered icon is indistinguishable from a compiled one at every later
|
|
13
|
+
// point: `<adw-icon icon-name="…">`, a hand-written `class="adw-icon adw-icon--…"`, a
|
|
14
|
+
// widget attribute, `var(--icon-…)` in the consumer's own CSS.
|
|
15
|
+
//
|
|
16
|
+
// import { registerIcon } from '@gjsify/adwaita-web';
|
|
17
|
+
// import { dialogErrorSymbolic } from '@gjsify/adwaita-icons/status';
|
|
18
|
+
//
|
|
19
|
+
// registerIcon('dialog-error-symbolic', dialogErrorSymbolic);
|
|
20
|
+
// // …then anywhere: <adw-icon icon-name="dialog-error-symbolic"></adw-icon>
|
|
21
|
+
//
|
|
22
|
+
// Register BEFORE the icon is mounted where you can. An `<adw-icon>` already in the
|
|
23
|
+
// document draws the `image-missing` fallback (see `_icon.scss`) until the registration
|
|
24
|
+
// lands; the CSS is live, so it corrects itself on the next style recalculation with no
|
|
25
|
+
// re-render, but there is a visible frame in between.
|
|
26
|
+
//
|
|
27
|
+
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+ — the icon SVGs.
|
|
28
|
+
// Original implementation.
|
|
29
|
+
|
|
30
|
+
import { normalizeIconName } from '@gjsify/adwaita-core';
|
|
31
|
+
import { toDataUri } from '@gjsify/adwaita-icons/utils';
|
|
32
|
+
|
|
33
|
+
import { MASK_CLASS_PREFIX } from './elements/adw-icon.js';
|
|
34
|
+
|
|
35
|
+
/** Marks the one `<style>` element this module owns, so a reload reuses it. */
|
|
36
|
+
const REGISTRY_STYLE_ID = 'adwaita-web-icon-registry';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Names already given a mask rule. Re-registering a name has to update the GLYPH without
|
|
40
|
+
* appending a second rule: the custom property carries the glyph and the rule only points
|
|
41
|
+
* at it, so the property assignment is the whole update and a duplicate rule would be
|
|
42
|
+
* dead weight that grows without bound under a live theme switcher.
|
|
43
|
+
*/
|
|
44
|
+
const ruled = new Set<string>();
|
|
45
|
+
|
|
46
|
+
/** The one rule a registration adds: the class, pointing at the custom property. */
|
|
47
|
+
function insertMaskRule(sheet: CSSStyleSheet, name: string): void {
|
|
48
|
+
sheet.insertRule(
|
|
49
|
+
`.${MASK_CLASS_PREFIX}${name} { mask-image: var(--icon-${name}); ` +
|
|
50
|
+
`-webkit-mask-image: var(--icon-${name}); }`,
|
|
51
|
+
sheet.cssRules.length,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** This module's stylesheet, created on first use and appended last so nothing hides it. */
|
|
56
|
+
function registrySheet(): CSSStyleSheet {
|
|
57
|
+
const existing = document.getElementById(REGISTRY_STYLE_ID) as HTMLStyleElement | null;
|
|
58
|
+
if (existing?.sheet) return existing.sheet;
|
|
59
|
+
|
|
60
|
+
const style = document.createElement('style');
|
|
61
|
+
style.id = REGISTRY_STYLE_ID;
|
|
62
|
+
document.head.appendChild(style);
|
|
63
|
+
// `sheet` is non-null the moment a <style> is in a document — the property is only
|
|
64
|
+
// null while the element is disconnected, which the append above rules out.
|
|
65
|
+
const sheet = style.sheet as CSSStyleSheet;
|
|
66
|
+
|
|
67
|
+
// Reaching here a SECOND time means the previous element was taken away — an SPA head
|
|
68
|
+
// swap, an Astro view transition — and every rule it held went with it. The custom
|
|
69
|
+
// properties did NOT: they sit on the document element. So re-issue the rules, or every
|
|
70
|
+
// name registered before the swap keeps its glyph in a property nothing consumes and
|
|
71
|
+
// draws the fallback forever. Nothing observes the head, so the recovery happens on the
|
|
72
|
+
// next `registerIcon` call rather than at the moment of the swap.
|
|
73
|
+
for (const name of ruled) insertMaskRule(sheet, name);
|
|
74
|
+
return sheet;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The second argument has to be SVG SOURCE, and nothing else can tell us so: `toDataUri`
|
|
79
|
+
* percent-encodes whatever it is handed, so `registerIcon('x', 'not an svg')` produces a
|
|
80
|
+
* perfectly well-formed `mask-image` that masks NOTHING — an icon strictly worse than an
|
|
81
|
+
* unregistered one, because the registered class beats `:where(.adw-icon)` and switches the
|
|
82
|
+
* `image-missing` fallback OFF, while `isIconAvailable` reports `true`. Measured in Firefox:
|
|
83
|
+
* `url("data:image/svg+xml,not%20an%20svg")`, 0 % of the box painted, no throw, no warning.
|
|
84
|
+
*
|
|
85
|
+
* A real parser rather than a `^<svg` test: an icon read from a file carries an XML prolog
|
|
86
|
+
* or a DOCTYPE before its root element, and both are legitimate here.
|
|
87
|
+
*/
|
|
88
|
+
function svgRootOf(source: string): Element {
|
|
89
|
+
return new DOMParser().parseFromString(source, 'image/svg+xml').documentElement;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Make `name` drawable by every `.adw-icon` in the document, from an Adwaita symbolic SVG
|
|
94
|
+
* source string (what `@gjsify/adwaita-icons` exports).
|
|
95
|
+
*
|
|
96
|
+
* BOTH arguments are checked and a wrong one THROWS rather than registering nothing: unlike
|
|
97
|
+
* a render, this is an explicit call with a wrong argument, and silently doing nothing is
|
|
98
|
+
* the failure mode this whole area exists to end. The name is normalized exactly as
|
|
99
|
+
* `<adw-icon icon-name>` normalizes it — a single `-symbolic` suffix comes off, and the rest
|
|
100
|
+
* must be one CSS token; the SVG has to parse to an `<svg>` root. `<adw-icon>` draws
|
|
101
|
+
* `image-missing` for the same unusable name instead — there it came from markup, and
|
|
102
|
+
* taking the page down over a typo is not proportionate.
|
|
103
|
+
*
|
|
104
|
+
* Registering a name the stylesheet already compiles is allowed and REPLACES the glyph:
|
|
105
|
+
* the property is set on the document element, so it wins over the `:root` rule.
|
|
106
|
+
*
|
|
107
|
+
* CLIENT-SIDE ONLY: this touches `document.documentElement` and `document.head`, so at
|
|
108
|
+
* module top level in an SSR/SSG build it throws before a page is rendered.
|
|
109
|
+
*/
|
|
110
|
+
export function registerIcon(name: string, svg: string): void {
|
|
111
|
+
const resolved = normalizeIconName(name);
|
|
112
|
+
if (resolved === '') {
|
|
113
|
+
throw new TypeError(
|
|
114
|
+
`registerIcon: ${JSON.stringify(name)} is not usable as an icon name — it has to be a single ` +
|
|
115
|
+
'CSS token ([A-Za-z0-9_-]+) once an optional `-symbolic` suffix is removed.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const root = svgRootOf(svg);
|
|
120
|
+
if (root.tagName !== 'svg') {
|
|
121
|
+
throw new TypeError(
|
|
122
|
+
`registerIcon(${JSON.stringify(resolved)}): the second argument has to be SVG SOURCE — the ` +
|
|
123
|
+
'kind of string `@gjsify/adwaita-icons` exports, not a URL, a data-URI or a DOM node. ' +
|
|
124
|
+
`Parsing it produced <${root.tagName}>, so it would have masked nothing at all.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
document.documentElement.style.setProperty(`--icon-${resolved}`, toDataUri(svg));
|
|
129
|
+
|
|
130
|
+
// BEFORE the `ruled` early return: taking the sheet is what detects a lost <style>,
|
|
131
|
+
// and a name already in `ruled` is exactly the one the early return would skip.
|
|
132
|
+
const sheet = registrySheet();
|
|
133
|
+
if (ruled.has(resolved)) return;
|
|
134
|
+
insertMaskRule(sheet, resolved);
|
|
135
|
+
ruled.add(resolved);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether `name` has a glyph — compiled into the stylesheet, or registered at runtime.
|
|
140
|
+
*
|
|
141
|
+
* Reads the live cascade rather than a generated list, so it answers for a glyph that
|
|
142
|
+
* arrived by any route, including a consumer's own `--icon-<name>` declaration. It is
|
|
143
|
+
* deliberately the CUSTOM PROPERTY that is read and not the rendered `mask-image` of a
|
|
144
|
+
* probe element: since `_icon.scss` gives every icon box the `image-missing` fallback,
|
|
145
|
+
* a rendered mask is never `none`, and comparing against the fallback would answer
|
|
146
|
+
* `false` for `image-missing` itself — the one name where being drawn and having fallen
|
|
147
|
+
* back look identical.
|
|
148
|
+
*
|
|
149
|
+
* Narrowing worth knowing: a consumer who writes `.adw-icon--x { mask-image: url(…) }`
|
|
150
|
+
* with no matching `--icon-x` gets `false` here and a drawn icon anyway. The generator and
|
|
151
|
+
* {@link registerIcon} are the two things that create the pair, and both create both.
|
|
152
|
+
*/
|
|
153
|
+
export function isIconAvailable(name: string): boolean {
|
|
154
|
+
const resolved = normalizeIconName(name);
|
|
155
|
+
if (resolved === '') return false;
|
|
156
|
+
return getComputedStyle(document.documentElement).getPropertyValue(`--icon-${resolved}`).trim() !== '';
|
|
157
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,26 @@
|
|
|
5
5
|
// SCSS partials at `@gjsify/adwaita-web/scss/...` for custom theming.
|
|
6
6
|
// Reference: refs/libadwaita (colors/sizing), refs/adwaita-web (component patterns).
|
|
7
7
|
|
|
8
|
-
import '@gjsify/adwaita-fonts'; // Registers @font-face (fontsource pattern)
|
|
9
8
|
import { ADWAITA_WEB_CSS } from './styles.generated.js';
|
|
10
9
|
|
|
10
|
+
// NO font import here, and the absence is the decision. This line used to be
|
|
11
|
+
// `import '@gjsify/adwaita-fonts';` with the comment "Registers @font-face
|
|
12
|
+
// (fontsource pattern)". It registered nothing: that package's `.` export is
|
|
13
|
+
// `index.css`, css-as-string turns it into `export default "<css>"`, and a
|
|
14
|
+
// SIDE-EFFECT import of a module with no side effect is tree-shaken — measured
|
|
15
|
+
// on 0.41.0 as a 0-byte bundle with zero `@font-face`, exit 0. It was invisible
|
|
16
|
+
// because the primary target platform installs the typeface system-wide, so
|
|
17
|
+
// every local screenshot looked right.
|
|
18
|
+
//
|
|
19
|
+
// The faces now travel through `@gjsify/adwaita-web/fonts` → the generated
|
|
20
|
+
// `@gjsify/adwaita-fonts/embedded`, as `data:` URIs behind a VALUE import, which
|
|
21
|
+
// is the shape a bundler cannot discard. It stays OUT of this entry because it
|
|
22
|
+
// is 2.39 MB / 1.18 MB gzip of base64 against a 190 KB / 26 KB stylesheet, and
|
|
23
|
+
// `--app browser` has no code splitting (`output.inlineDynamicImports`), so a
|
|
24
|
+
// lazy face would cost the same bytes as an eager one. The stacks that head with
|
|
25
|
+
// `'Adwaita Sans'` / `'Adwaita Mono'` are a progressive enhancement over a
|
|
26
|
+
// system-installed face, declared as such in `status/stylesheet-font-families.json`.
|
|
27
|
+
|
|
11
28
|
// Self-applied so `import '@gjsify/adwaita-web'` styles the components under ANY bundler:
|
|
12
29
|
// a separate `import '@gjsify/adwaita-web/style.css'` is a no-op under a gjsify
|
|
13
30
|
// `--app browser` build, where css-as-string yields a string a side-effect import
|
|
@@ -40,6 +57,12 @@ export type { ApplyAccentOptions } from './accent.js';
|
|
|
40
57
|
export { AdwScrollShading } from './scroll-shading.js';
|
|
41
58
|
export type { AdwUndershootEdges } from './scroll-shading.js';
|
|
42
59
|
|
|
60
|
+
// The stylesheet compiles a chosen SUBSET of `@gjsify/adwaita-icons` (the whole set is
|
|
61
|
+
// ~1.07 MB of data-URI), so a name outside it draws the `image-missing` fallback. This is
|
|
62
|
+
// the way in for an app that needs a glyph this package does not ship — see
|
|
63
|
+
// `icon-registry.ts` for the full recipe.
|
|
64
|
+
export { isIconAvailable, registerIcon } from './icon-registry.js';
|
|
65
|
+
|
|
43
66
|
export { AdwAvatar } from './elements/adw-avatar.js';
|
|
44
67
|
export { AdwBanner } from './elements/adw-banner.js';
|
|
45
68
|
export { AdwCard } from './elements/adw-card.js';
|