runtime: styles for created roots are constructed sheets (CSP-proof)
- styles.ts: adoptStyles(root, css) adopts a constructed stylesheet into a shadow root or document (not governed by a page's CSP, parsed once, stays with the root when its host is moved); <style> fallback where constructable sheets are missing or refused. - UI kit: the kit's shadow root and each dialog's `css` use it (dialog CSS goes with the dialog). - Plugin API: api.adoptCSS(root, css) and scope.adoptCSS (removed on dispose); injectCSS keeps its <style> and, when a CSP leaves it without a sheet, applies the same rules as an adopted sheet. - tests/styles.test.ts.
This commit is contained in:
parent
30eed908cb
commit
aab3d4d64a
4 changed files with 310 additions and 8 deletions
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
import { createThemeApi, type ThemeApi, type ThemeSource } from './themeApi.ts';
|
import { createThemeApi, type ThemeApi, type ThemeSource } from './themeApi.ts';
|
||||||
import { createUiKit, type UiKit } from './ui.ts';
|
import { createUiKit, type UiKit } from './ui.ts';
|
||||||
|
import { adoptStyles, type RootStyles } from './styles.ts';
|
||||||
import { createCommandRegistry, type CommandDefinition, type CommandInfo, type CommandRegistry } from './commands.ts';
|
import { createCommandRegistry, type CommandDefinition, type CommandInfo, type CommandRegistry } from './commands.ts';
|
||||||
|
|
||||||
export type { ThemeApi, ThemeSource, UiKit, CommandDefinition, CommandInfo, CommandRegistry };
|
export type { ThemeApi, ThemeSource, UiKit, CommandDefinition, CommandInfo, CommandRegistry, RootStyles };
|
||||||
|
|
||||||
export type Dispose = () => void;
|
export type Dispose = () => void;
|
||||||
|
|
||||||
|
|
@ -25,6 +26,8 @@ export type PluginStorage = {
|
||||||
/** Collects disposers so a plugin's `stop()` is a single `scope.dispose()`. */
|
/** Collects disposers so a plugin's `stop()` is a single `scope.dispose()`. */
|
||||||
export type PluginScope = {
|
export type PluginScope = {
|
||||||
css(id: string, css: string): void;
|
css(id: string, css: string): void;
|
||||||
|
/** `api.adoptCSS`, taken off the root on dispose. */
|
||||||
|
adoptCSS(root: ShadowRoot | Document, css: string): RootStyles;
|
||||||
on(target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
|
on(target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
|
||||||
observe(selector: string, callback: (element: Element) => void): void;
|
observe(selector: string, callback: (element: Element) => void): void;
|
||||||
onRouteChange(callback: (href: string, previous: string) => void): void;
|
onRouteChange(callback: (href: string, previous: string) => void): void;
|
||||||
|
|
@ -45,6 +48,12 @@ export type PluginScope = {
|
||||||
export type CorePluginApi = {
|
export type CorePluginApi = {
|
||||||
injectCSS(id: string, css: string): void;
|
injectCSS(id: string, css: string): void;
|
||||||
clearCSS(id: string): void;
|
clearCSS(id: string): void;
|
||||||
|
/**
|
||||||
|
* Styles a shadow root the plugin created (or the document) with a constructable stylesheet:
|
||||||
|
* the page's Content-Security-Policy cannot refuse it, it is parsed once, and it stays with the
|
||||||
|
* root wherever its host is moved. Falls back to a `<style>` element where unsupported.
|
||||||
|
*/
|
||||||
|
adoptCSS(root: ShadowRoot | Document, css: string): RootStyles;
|
||||||
waitFor(selector: string, timeoutMs?: number): Promise<Element>;
|
waitFor(selector: string, timeoutMs?: number): Promise<Element>;
|
||||||
/** Namespaced JSON storage in this tab's localStorage (`<storagePrefix><namespace>:<key>`). */
|
/** Namespaced JSON storage in this tab's localStorage (`<storagePrefix><namespace>:<key>`). */
|
||||||
storage(namespace: string): PluginStorage;
|
storage(namespace: string): PluginStorage;
|
||||||
|
|
@ -125,6 +134,7 @@ export function createCorePluginApi(win: Window & typeof globalThis, site: Plugi
|
||||||
h: (...args: Parameters<UiKit['h']>) => uiKit().h(...args),
|
h: (...args: Parameters<UiKit['h']>) => uiKit().h(...args),
|
||||||
});
|
});
|
||||||
const styleNodes = new Map<string, HTMLStyleElement>();
|
const styleNodes = new Map<string, HTMLStyleElement>();
|
||||||
|
const adoptedFallbacks = new Map<string, RootStyles>();
|
||||||
const report = (error: unknown) => console.error(`[${site.product}] plugin callback failed`, error);
|
const report = (error: unknown) => console.error(`[${site.product}] plugin callback failed`, error);
|
||||||
const registry = createCommandRegistry({ target: doc, isTyping: event => isTypingTarget(event.target) || isTypingTarget(doc.activeElement), report });
|
const registry = createCommandRegistry({ target: doc, isTyping: event => isTypingTarget(event.target) || isTypingTarget(doc.activeElement), report });
|
||||||
const commands: CommandRegistry = Object.freeze({
|
const commands: CommandRegistry = Object.freeze({
|
||||||
|
|
@ -159,10 +169,26 @@ export function createCorePluginApi(win: Window & typeof globalThis, site: Plugi
|
||||||
styleNodes.set(id, node);
|
styleNodes.set(id, node);
|
||||||
}
|
}
|
||||||
node.textContent = css;
|
node.textContent = css;
|
||||||
|
// A CSP that refuses inline styles leaves the element without a sheet: the same rules as an
|
||||||
|
// adopted sheet instead (CSP does not govern constructed sheets).
|
||||||
|
const blocked = 'sheet' in node && node.sheet === null && css.trim() !== '';
|
||||||
|
const fallback = adoptedFallbacks.get(id);
|
||||||
|
if (blocked) {
|
||||||
|
if (fallback) fallback.update(css);
|
||||||
|
else adoptedFallbacks.set(id, adoptStyles(doc, css));
|
||||||
|
} else if (fallback) {
|
||||||
|
fallback.remove();
|
||||||
|
adoptedFallbacks.delete(id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearCSS(id) {
|
clearCSS(id) {
|
||||||
styleNodes.get(id)?.remove();
|
styleNodes.get(id)?.remove();
|
||||||
styleNodes.delete(id);
|
styleNodes.delete(id);
|
||||||
|
adoptedFallbacks.get(id)?.remove();
|
||||||
|
adoptedFallbacks.delete(id);
|
||||||
|
},
|
||||||
|
adoptCSS(root, css) {
|
||||||
|
return adoptStyles(root, css);
|
||||||
},
|
},
|
||||||
waitFor(selector, timeoutMs = 15000) {
|
waitFor(selector, timeoutMs = 15000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -299,6 +325,11 @@ export function createCorePluginApi(win: Window & typeof globalThis, site: Plugi
|
||||||
api.injectCSS(id, css);
|
api.injectCSS(id, css);
|
||||||
disposers.push(() => api.clearCSS(id));
|
disposers.push(() => api.clearCSS(id));
|
||||||
},
|
},
|
||||||
|
adoptCSS(root, css) {
|
||||||
|
const styles = api.adoptCSS(root, css);
|
||||||
|
disposers.push(() => styles.remove());
|
||||||
|
return styles;
|
||||||
|
},
|
||||||
on(target, type, listener, options) {
|
on(target, type, listener, options) {
|
||||||
target.addEventListener(type, listener, options);
|
target.addEventListener(type, listener, options);
|
||||||
disposers.push(() => target.removeEventListener(type, listener, options));
|
disposers.push(() => target.removeEventListener(type, listener, options));
|
||||||
|
|
|
||||||
83
packages/runtime/src/styles.ts
Normal file
83
packages/runtime/src/styles.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Styles for roots the runtime (or a plugin) creates: a constructable stylesheet adopted by the
|
||||||
|
// shadow root (or document) instead of a `<style>` element.
|
||||||
|
//
|
||||||
|
// Why: a site's Content-Security-Policy governs `<style>` elements (a `style-src` with a nonce or
|
||||||
|
// hash and no 'unsafe-inline' refuses them, in shadow roots too), but not constructed sheets
|
||||||
|
// (`new CSSStyleSheet()` + `replaceSync`), which are also parsed once instead of per element and
|
||||||
|
// stay adopted when their host is moved to another parent in the same document (a modal
|
||||||
|
// re-rendered by the site, a card lifted into the top layer). Engines without constructable
|
||||||
|
// sheets (or a root from another realm that refuses the sheet) get the `<style>` element.
|
||||||
|
|
||||||
|
export type RootStyles = {
|
||||||
|
/** The live sheet (insertRule/deleteRule work on it), or null when nothing could be parsed. */
|
||||||
|
readonly sheet: CSSStyleSheet | null;
|
||||||
|
/** True when the sheet is adopted (CSP-proof); false for the `<style>` fallback. */
|
||||||
|
readonly adopted: boolean;
|
||||||
|
/** Replaces the rules. */
|
||||||
|
update(css: string): void;
|
||||||
|
/** Takes the rules off the root (idempotent). */
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StyleRoot = (ShadowRoot | Document) & { adoptedStyleSheets?: CSSStyleSheet[] };
|
||||||
|
|
||||||
|
const DOCUMENT_NODE = 9;
|
||||||
|
const isDocument = (root: StyleRoot): root is Document & StyleRoot => root.nodeType === DOCUMENT_NODE;
|
||||||
|
const documentOf = (root: StyleRoot): Document => (isDocument(root) ? root : (root as ShadowRoot).ownerDocument);
|
||||||
|
|
||||||
|
/** A sheet built in `root`'s own realm (a sheet from another document cannot be adopted). */
|
||||||
|
function construct(root: StyleRoot, css: string): CSSStyleSheet | null {
|
||||||
|
if (!root.adoptedStyleSheets) return null;
|
||||||
|
const Sheet = (documentOf(root).defaultView as (Window & typeof globalThis) | null)?.CSSStyleSheet;
|
||||||
|
if (typeof Sheet !== 'function' || typeof Sheet.prototype.replaceSync !== 'function') return null;
|
||||||
|
try {
|
||||||
|
const sheet = new Sheet();
|
||||||
|
sheet.replaceSync(css);
|
||||||
|
return sheet;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styles `root` with `css`. Adopted sheets come after the root's `<style>` elements in the cascade
|
||||||
|
* (like a `<style>` appended last), and several calls stack in call order.
|
||||||
|
*/
|
||||||
|
export function adoptStyles(root: ShadowRoot | Document, css: string): RootStyles {
|
||||||
|
const target = root as StyleRoot;
|
||||||
|
const sheet = construct(target, css);
|
||||||
|
if (sheet) {
|
||||||
|
let on = false;
|
||||||
|
const attach = () => {
|
||||||
|
try {
|
||||||
|
target.adoptedStyleSheets = [...(target.adoptedStyleSheets ?? []), sheet];
|
||||||
|
on = true;
|
||||||
|
} catch { on = false; }
|
||||||
|
};
|
||||||
|
attach();
|
||||||
|
if (on) {
|
||||||
|
return {
|
||||||
|
sheet,
|
||||||
|
adopted: true,
|
||||||
|
update(next) { try { sheet.replaceSync(next); } catch { /* Keeps the last good rules. */ } },
|
||||||
|
remove() {
|
||||||
|
if (!on) return;
|
||||||
|
on = false;
|
||||||
|
try { target.adoptedStyleSheets = (target.adoptedStyleSheets ?? []).filter(item => item !== sheet); } catch { /* Root gone. */ }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: a `<style>` element as the root's last child (a document: its <head>).
|
||||||
|
const doc = documentOf(target);
|
||||||
|
const style = doc.createElement('style');
|
||||||
|
style.textContent = css;
|
||||||
|
const parent: Node = isDocument(target) ? (target.head ?? target.documentElement) : target;
|
||||||
|
parent.appendChild(style);
|
||||||
|
return {
|
||||||
|
get sheet() { return style.sheet; },
|
||||||
|
adopted: false,
|
||||||
|
update(next) { style.textContent = next; },
|
||||||
|
remove() { style.remove(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
// region (danger toasts `role="alert"`), pause while hovered or focused, and stack (four at most).
|
// region (danger toasts `role="alert"`), pause while hovered or focused, and stack (four at most).
|
||||||
// Motion is skipped with `prefers-reduced-motion`. Text is always set as text (never parsed as HTML).
|
// Motion is skipped with `prefers-reduced-motion`. Text is always set as text (never parsed as HTML).
|
||||||
|
|
||||||
|
import { adoptStyles, type RootStyles } from './styles.ts';
|
||||||
import { themeVars, type ResolvedTheme, type ThemeApi } from './themeApi.ts';
|
import { themeVars, type ResolvedTheme, type ThemeApi } from './themeApi.ts';
|
||||||
|
|
||||||
export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
export type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
@ -357,6 +358,7 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
|
||||||
const hostTag = options.hostTag ?? 'wb-ui';
|
const hostTag = options.hostTag ?? 'wb-ui';
|
||||||
const product = options.product ?? 'wayBetter';
|
const product = options.product ?? 'wayBetter';
|
||||||
const report = (error: unknown) => console.error(`[${product}] ui callback failed`, error);
|
const report = (error: unknown) => console.error(`[${product}] ui callback failed`, error);
|
||||||
|
const isShadowRoot = (node: unknown): node is ShadowRoot => typeof win.ShadowRoot === 'function' && node instanceof win.ShadowRoot;
|
||||||
const reducedMotion = () => { try { return !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
|
const reducedMotion = () => { try { return !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
|
||||||
|
|
||||||
let host: HTMLElement | null = null;
|
let host: HTMLElement | null = null;
|
||||||
|
|
@ -401,8 +403,6 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
|
||||||
// The host sits above the site; it takes no clicks itself (its layers and toasts do).
|
// The host sits above the site; it takes no clicks itself (its layers and toasts do).
|
||||||
host.style.cssText = `all:initial;position:fixed;inset:0;z-index:${options.zIndex ?? 2147483600};pointer-events:none;display:block;`;
|
host.style.cssText = `all:initial;position:fixed;inset:0;z-index:${options.zIndex ?? 2147483600};pointer-events:none;display:block;`;
|
||||||
root = typeof host.attachShadow === 'function' ? host.attachShadow({ mode: 'open' }) : host;
|
root = typeof host.attachShadow === 'function' ? host.attachShadow({ mode: 'open' }) : host;
|
||||||
const style = doc.createElement('style');
|
|
||||||
style.textContent = UI_CSS;
|
|
||||||
const wrap = doc.createElement('div');
|
const wrap = doc.createElement('div');
|
||||||
wrap.className = 'wb-root';
|
wrap.className = 'wb-root';
|
||||||
layers = doc.createElement('div');
|
layers = doc.createElement('div');
|
||||||
|
|
@ -411,7 +411,15 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
|
||||||
toastRegion.setAttribute('aria-live', 'polite');
|
toastRegion.setAttribute('aria-live', 'polite');
|
||||||
toastRegion.setAttribute('aria-relevant', 'additions');
|
toastRegion.setAttribute('aria-relevant', 'additions');
|
||||||
wrap.append(layers, toastRegion);
|
wrap.append(layers, toastRegion);
|
||||||
root.append(style, wrap);
|
root.append(wrap);
|
||||||
|
// An adopted sheet (see styles.ts): the site's CSP cannot refuse it, and it stays with the root
|
||||||
|
// wherever the host is moved. A host without a shadow root keeps a <style> child.
|
||||||
|
if (isShadowRoot(root)) adoptStyles(root, UI_CSS);
|
||||||
|
else {
|
||||||
|
const style = doc.createElement('style');
|
||||||
|
style.textContent = UI_CSS;
|
||||||
|
root.prepend(style);
|
||||||
|
}
|
||||||
// Keys typed in a dialog are ours: the site's (and plugins') page shortcuts never see them.
|
// Keys typed in a dialog are ours: the site's (and plugins') page shortcuts never see them.
|
||||||
for (const type of ['keydown', 'keyup', 'keypress']) root.addEventListener(type, event => event.stopPropagation());
|
for (const type of ['keydown', 'keyup', 'keypress']) root.addEventListener(type, event => event.stopPropagation());
|
||||||
(doc.body ?? doc.documentElement).appendChild(host);
|
(doc.body ?? doc.documentElement).appendChild(host);
|
||||||
|
|
@ -525,11 +533,16 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
|
||||||
panel.appendChild(message);
|
panel.appendChild(message);
|
||||||
panel.setAttribute('aria-describedby', message.id);
|
panel.setAttribute('aria-describedby', message.id);
|
||||||
}
|
}
|
||||||
|
// The dialog's own CSS, on the kit's root while the dialog is open (adopted where possible).
|
||||||
|
let ownStyles: RootStyles | null = null;
|
||||||
if (opts.css) {
|
if (opts.css) {
|
||||||
|
if (root && isShadowRoot(root)) ownStyles = adoptStyles(root, opts.css);
|
||||||
|
else {
|
||||||
const style = doc.createElement('style');
|
const style = doc.createElement('style');
|
||||||
style.textContent = opts.css;
|
style.textContent = opts.css;
|
||||||
panel.appendChild(style);
|
panel.appendChild(style);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const body = doc.createElement('div');
|
const body = doc.createElement('div');
|
||||||
body.className = 'wb-body';
|
body.className = 'wb-body';
|
||||||
panel.appendChild(body);
|
panel.appendChild(body);
|
||||||
|
|
@ -629,7 +642,7 @@ export function createUiKit(win: Window & typeof globalThis, options: UiKitOptio
|
||||||
syncStack();
|
syncStack();
|
||||||
layer.setAttribute('inert', '');
|
layer.setAttribute('inert', '');
|
||||||
layer.classList.add('wb-closing');
|
layer.classList.add('wb-closing');
|
||||||
later(() => layer.remove(), reducedMotion() ? 0 : 130);
|
later(() => { layer.remove(); ownStyles?.remove(); }, reducedMotion() ? 0 : 130);
|
||||||
// Dialogs opened from this one return focus where this one would have.
|
// Dialogs opened from this one return focus where this one would have.
|
||||||
for (const other of stack) if (other.returnTo && layer.contains(other.returnTo)) other.returnTo = entry.returnTo;
|
for (const other of stack) if (other.returnTo && layer.contains(other.returnTo)) other.returnTo = entry.returnTo;
|
||||||
// Focus never lingers in a dialog that is animating out (keys would still go to its fields).
|
// Focus never lingers in a dialog that is animating out (keys would still go to its fields).
|
||||||
|
|
|
||||||
175
packages/runtime/tests/styles.test.ts
Normal file
175
packages/runtime/tests/styles.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { adoptStyles } from '../src/styles.ts';
|
||||||
|
import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts';
|
||||||
|
|
||||||
|
/** A constructable sheet as far as the helper uses one. */
|
||||||
|
class FakeSheet {
|
||||||
|
static refuse = false;
|
||||||
|
css = '';
|
||||||
|
replaceSync(css: string) {
|
||||||
|
if (FakeSheet.refuse) throw new Error('refused');
|
||||||
|
this.css = css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeStyle = { tag: string; textContent: string; parent: FakeParent | null; sheet?: unknown; remove(): void };
|
||||||
|
type FakeParent = { children: FakeStyle[]; appendChild(node: FakeStyle): FakeStyle };
|
||||||
|
|
||||||
|
function parent(): FakeParent {
|
||||||
|
const node: FakeParent = {
|
||||||
|
children: [],
|
||||||
|
appendChild(child) { child.parent = node; node.children.push(child); return child; },
|
||||||
|
};
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeDocument(options: { constructable?: boolean; adopted?: boolean } = {}) {
|
||||||
|
const win: Record<string, unknown> = {};
|
||||||
|
if (options.constructable !== false) win.CSSStyleSheet = FakeSheet;
|
||||||
|
const head = parent();
|
||||||
|
const doc: Record<string, unknown> = {
|
||||||
|
nodeType: 9,
|
||||||
|
defaultView: win,
|
||||||
|
head,
|
||||||
|
documentElement: parent(),
|
||||||
|
createElement(tag: string): FakeStyle {
|
||||||
|
const node: FakeStyle = { tag, textContent: '', parent: null, remove() { if (node.parent) node.parent.children = node.parent.children.filter(c => c !== node); node.parent = null; } };
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (options.adopted !== false) doc.adoptedStyleSheets = [];
|
||||||
|
win.document = doc;
|
||||||
|
return { win, doc, head };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeShadowRoot(doc: Record<string, unknown>, adopted = true) {
|
||||||
|
const root = parent() as FakeParent & Record<string, unknown>;
|
||||||
|
root.nodeType = 11;
|
||||||
|
root.ownerDocument = doc;
|
||||||
|
if (adopted) root.adoptedStyleSheets = [];
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a shadow root adopts a constructed sheet: no <style> element, update and remove work', () => {
|
||||||
|
FakeSheet.refuse = false;
|
||||||
|
const { doc } = fakeDocument();
|
||||||
|
const root = fakeShadowRoot(doc);
|
||||||
|
const earlier = new FakeSheet();
|
||||||
|
root.adoptedStyleSheets = [earlier];
|
||||||
|
const styles = adoptStyles(root as unknown as ShadowRoot, '.a { color: red; }');
|
||||||
|
assert.equal(styles.adopted, true);
|
||||||
|
assert.equal(root.children.length, 0, 'no <style> element the page CSP could refuse');
|
||||||
|
const adopted = root.adoptedStyleSheets as FakeSheet[];
|
||||||
|
assert.equal(adopted.length, 2);
|
||||||
|
assert.equal(adopted[0], earlier, 'sheets already adopted stay first');
|
||||||
|
assert.equal(adopted[1].css, '.a { color: red; }');
|
||||||
|
assert.equal(styles.sheet, adopted[1] as unknown as CSSStyleSheet);
|
||||||
|
styles.update('.b {}');
|
||||||
|
assert.equal(adopted[1].css, '.b {}');
|
||||||
|
styles.remove();
|
||||||
|
assert.deepEqual(root.adoptedStyleSheets, [earlier]);
|
||||||
|
styles.remove();
|
||||||
|
assert.deepEqual(root.adoptedStyleSheets, [earlier], 'remove is idempotent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without constructable sheets, or when the engine refuses one, a <style> element is the fallback', () => {
|
||||||
|
FakeSheet.refuse = false;
|
||||||
|
const noCtor = fakeDocument({ constructable: false });
|
||||||
|
const root = fakeShadowRoot(noCtor.doc);
|
||||||
|
const styles = adoptStyles(root as unknown as ShadowRoot, '.x {}');
|
||||||
|
assert.equal(styles.adopted, false);
|
||||||
|
assert.equal(root.children.length, 1);
|
||||||
|
assert.equal(root.children[0].textContent, '.x {}');
|
||||||
|
styles.update('.y {}');
|
||||||
|
assert.equal(root.children[0].textContent, '.y {}');
|
||||||
|
styles.remove();
|
||||||
|
assert.equal(root.children.length, 0);
|
||||||
|
|
||||||
|
const noAdopt = fakeDocument();
|
||||||
|
const legacy = fakeShadowRoot(noAdopt.doc, false);
|
||||||
|
assert.equal(adoptStyles(legacy as unknown as ShadowRoot, '.z {}').adopted, false);
|
||||||
|
assert.equal(legacy.children.length, 1);
|
||||||
|
|
||||||
|
FakeSheet.refuse = true;
|
||||||
|
const refused = fakeDocument();
|
||||||
|
const root2 = fakeShadowRoot(refused.doc);
|
||||||
|
assert.equal(adoptStyles(root2 as unknown as ShadowRoot, '.w {}').adopted, false);
|
||||||
|
assert.equal(root2.children.length, 1);
|
||||||
|
FakeSheet.refuse = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a document root: adopted sheet, or a <style> in <head> as the fallback', () => {
|
||||||
|
FakeSheet.refuse = false;
|
||||||
|
const { doc, head } = fakeDocument();
|
||||||
|
const styles = adoptStyles(doc as unknown as Document, '.d {}');
|
||||||
|
assert.equal(styles.adopted, true);
|
||||||
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 1);
|
||||||
|
assert.equal(head.children.length, 0);
|
||||||
|
const plain = fakeDocument({ adopted: false });
|
||||||
|
adoptStyles(plain.doc as unknown as Document, '.e {}');
|
||||||
|
assert.equal(plain.head.children.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SITE: PluginApiSite = {
|
||||||
|
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
|
||||||
|
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function pluginEnv(cspBlocksInline: boolean) {
|
||||||
|
FakeSheet.refuse = false;
|
||||||
|
const { win, doc, head } = fakeDocument();
|
||||||
|
const createElement = doc.createElement as (tag: string) => FakeStyle;
|
||||||
|
Object.assign(doc, {
|
||||||
|
addEventListener() {}, removeEventListener() {}, activeElement: null, querySelectorAll: () => [],
|
||||||
|
createElement(tag: string) {
|
||||||
|
const node = createElement(tag) as FakeStyle & { dataset: Record<string, string>; isConnected: boolean };
|
||||||
|
node.dataset = {};
|
||||||
|
// A real <style>: a sheet once connected, none when the CSP refuses inline styles.
|
||||||
|
Object.defineProperty(node, 'sheet', { get: () => (node.parent && !cspBlocksInline ? {} : null) });
|
||||||
|
Object.defineProperty(node, 'isConnected', { get: () => !!node.parent });
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.assign(win, {
|
||||||
|
location: { href: 'https://site.test/home' }, localStorage: null,
|
||||||
|
addEventListener() {}, removeEventListener() {},
|
||||||
|
requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: () => {},
|
||||||
|
MutationObserver: class { observe() {} disconnect() {} },
|
||||||
|
});
|
||||||
|
const api = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
|
||||||
|
return { api, doc, head };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('injectCSS: an inline <style> the page CSP refuses gets its rules as an adopted sheet instead', () => {
|
||||||
|
const { api, doc, head } = pluginEnv(true);
|
||||||
|
api.injectCSS('demo', '.a {}');
|
||||||
|
assert.equal(head.children.length, 1, 'the <style> element is still there (the CSP may change)');
|
||||||
|
const adopted = doc.adoptedStyleSheets as FakeSheet[];
|
||||||
|
assert.equal(adopted.length, 1);
|
||||||
|
assert.equal(adopted[0].css, '.a {}');
|
||||||
|
api.injectCSS('demo', '.b {}');
|
||||||
|
assert.equal(adopted.length, 1, 'updated in place, not stacked');
|
||||||
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[])[0].css, '.b {}');
|
||||||
|
api.clearCSS('demo');
|
||||||
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 0);
|
||||||
|
assert.equal(head.children.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('injectCSS: an allowed <style> needs no adopted copy', () => {
|
||||||
|
const { api, doc, head } = pluginEnv(false);
|
||||||
|
api.injectCSS('demo', '.a {}');
|
||||||
|
assert.equal(head.children.length, 1);
|
||||||
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope.adoptCSS takes the sheet off the root on dispose', () => {
|
||||||
|
const { api, doc } = pluginEnv(false);
|
||||||
|
const root = fakeShadowRoot(doc);
|
||||||
|
const scope = api.scope();
|
||||||
|
const styles = scope.adoptCSS(root as unknown as ShadowRoot, '.card {}');
|
||||||
|
assert.equal(styles.adopted, true);
|
||||||
|
assert.equal((root.adoptedStyleSheets as FakeSheet[]).length, 1);
|
||||||
|
scope.dispose();
|
||||||
|
assert.equal((root.adoptedStyleSheets as FakeSheet[]).length, 0);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue