refactor(html): SkinMixin -> SkinElement (#1159)

Co-authored-by: Rahim <rahim.alwer@gmail.com>
This commit is contained in:
Sam Potts
2026-03-31 22:22:45 -07:00
committed by GitHub
co-authored by Rahim
parent 63a82c0f92
commit 4cda8fb94f
16 changed files with 303 additions and 142 deletions
+7
View File
@@ -7,9 +7,16 @@ export { listen } from './listen';
export { tryHidePopover, tryShowPopover } from './popover';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
export { type RafThrottled, rafThrottle } from './raf-throttle';
export {
applyShadowStyles,
createShadowStyle,
ensureGlobalStyle,
type ShadowStyle,
} from './shadow-styles';
export { getSlottedElement, querySlot } from './slotted';
export { applyStyles, resolveCSSLength } from './style';
export { supportsAnchorPositioning, supportsAnimationFrame, supportsIdleCallback } from './supports';
export { createTemplate, renderTemplate } from './template';
export { findTrackElement, getTextTrackList } from './text-track';
export { serializeTimeRanges } from './time-ranges';
export type { CustomElement, CustomElementCallbacks } from './types';
+50
View File
@@ -0,0 +1,50 @@
export type ShadowStyle = CSSStyleSheet | string;
/** Inject a `<style>` tag into `document.head` once (idempotent by `id`). */
export function ensureGlobalStyle(id: string, css: string): void {
const doc = globalThis.document;
if (!doc || doc.getElementById(id)) return;
const style = doc.createElement('style');
style.id = id;
style.textContent = css;
doc.head.appendChild(style);
}
function isConstructableStyleSheet(value: ShadowStyle): value is CSSStyleSheet {
return typeof globalThis.CSSStyleSheet !== 'undefined' && value instanceof globalThis.CSSStyleSheet;
}
function getStyleText(style: ShadowStyle): string {
if (typeof style === 'string') return style;
return Array.from(style.cssRules)
.map((rule) => rule.cssText)
.join('\n');
}
/** Create a constructable stylesheet when available, otherwise return raw CSS. */
export function createShadowStyle(css: string): ShadowStyle {
if (typeof globalThis.CSSStyleSheet === 'undefined') {
return css;
}
const sheet = new globalThis.CSSStyleSheet();
sheet.replaceSync(css);
return sheet;
}
/** Apply styles to a shadow root using `adoptedStyleSheets` when available, falling back to `<style>` injection. */
export function applyShadowStyles(shadowRoot: ShadowRoot, styles: ShadowStyle[]): void {
if (styles.every(isConstructableStyleSheet) && 'adoptedStyleSheets' in shadowRoot) {
shadowRoot.adoptedStyleSheets = styles;
return;
}
const doc = shadowRoot.ownerDocument;
for (const styleText of styles.map(getStyleText)) {
const style = doc.createElement('style');
style.textContent = styleText;
shadowRoot.appendChild(style);
}
}
+14
View File
@@ -0,0 +1,14 @@
/** Create an `HTMLTemplateElement` from an HTML string, or `null` when `document` is unavailable (SSR). */
export function createTemplate(html: string): HTMLTemplateElement | null {
const doc = globalThis.document;
if (!doc) return null;
const template = doc.createElement('template');
template.innerHTML = html;
return template;
}
/** Deep-clone a template's content into a container. */
export function renderTemplate(container: Element | ShadowRoot, template: HTMLTemplateElement): void {
container.appendChild(container.ownerDocument.importNode(template.content, true));
}
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyShadowStyles, createShadowStyle, ensureGlobalStyle } from '../shadow-styles';
describe('createShadowStyle', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns a CSSStyleSheet when constructable stylesheets are available', () => {
const result = createShadowStyle('div { color: red; }');
expect(result).toBeInstanceOf(CSSStyleSheet);
});
it('returns raw CSS string when CSSStyleSheet is unavailable', () => {
vi.stubGlobal('CSSStyleSheet', undefined);
const css = 'div { color: red; }';
const result = createShadowStyle(css);
expect(result).toBe(css);
});
});
describe('applyShadowStyles', () => {
function createHost(): HTMLElement {
const host = document.createElement('div');
host.attachShadow({ mode: 'open' });
document.body.appendChild(host);
return host;
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('uses adoptedStyleSheets when supported and all styles are constructable', () => {
const host = createHost();
const shadowRoot = host.shadowRoot!;
const sheet = new CSSStyleSheet();
sheet.replaceSync('div { color: red; }');
let assigned: CSSStyleSheet[] = [];
Object.defineProperty(shadowRoot, 'adoptedStyleSheets', {
get: () => assigned,
set: (value: CSSStyleSheet[]) => {
assigned = value;
},
configurable: true,
});
applyShadowStyles(shadowRoot, [sheet]);
expect(assigned).toContain(sheet);
expect(shadowRoot.querySelectorAll('style').length).toBe(0);
});
it('falls back to <style> injection for string styles', () => {
const host = createHost();
const css = 'div { color: blue; }';
applyShadowStyles(host.shadowRoot!, [css]);
const styleEls = host.shadowRoot!.querySelectorAll('style');
expect(styleEls.length).toBe(1);
expect(styleEls[0]!.textContent).toBe(css);
});
it('falls back to <style> injection when styles are mixed', () => {
const host = createHost();
const sheet = new CSSStyleSheet();
sheet.replaceSync('div { color: red; }');
const css = 'div { color: blue; }';
applyShadowStyles(host.shadowRoot!, [sheet, css]);
const styleEls = host.shadowRoot!.querySelectorAll('style');
expect(styleEls.length).toBe(2);
});
});
describe('ensureGlobalStyle', () => {
afterEach(() => {
vi.unstubAllGlobals();
document.head.innerHTML = '';
});
it('injects a <style> tag into document.head', () => {
ensureGlobalStyle('test-style', 'body { margin: 0; }');
const el = document.getElementById('test-style');
expect(el).toBeInstanceOf(HTMLStyleElement);
expect(el!.textContent).toBe('body { margin: 0; }');
});
it('does not duplicate when called twice with the same id', () => {
ensureGlobalStyle('test-dedup', 'a { color: red; }');
ensureGlobalStyle('test-dedup', 'a { color: blue; }');
expect(document.querySelectorAll('#test-dedup').length).toBe(1);
expect(document.getElementById('test-dedup')!.textContent).toBe('a { color: red; }');
});
it('does nothing when document is unavailable', () => {
vi.stubGlobal('document', undefined);
expect(() => ensureGlobalStyle('ssr', 'body {}')).not.toThrow();
});
});
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createTemplate, renderTemplate } from '../template';
describe('createTemplate', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns an HTMLTemplateElement with parsed content', () => {
const template = createTemplate('<div class="root"><span>Hello</span></div>');
expect(template).toBeInstanceOf(HTMLTemplateElement);
expect(template!.content.querySelector('.root')).toBeTruthy();
expect(template!.content.querySelector('span')!.textContent).toBe('Hello');
});
it('returns null when document is unavailable', () => {
vi.stubGlobal('document', undefined);
expect(createTemplate('<div></div>')).toBeNull();
});
});
describe('renderTemplate', () => {
it('deep-clones template content into a container', () => {
const template = createTemplate('<p>Hello</p><p>World</p>')!;
const container = document.createElement('div');
renderTemplate(container, template);
expect(container.children).toHaveLength(2);
expect(container.innerHTML).toBe('<p>Hello</p><p>World</p>');
});
it('appends without clearing existing content', () => {
const template = createTemplate('<span>new</span>')!;
const container = document.createElement('div');
container.innerHTML = '<span>existing</span>';
renderTemplate(container, template);
expect(container.children).toHaveLength(2);
expect(container.children[0]!.textContent).toBe('existing');
expect(container.children[1]!.textContent).toBe('new');
});
});