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
@@ -1,4 +1,3 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render/minimal';
import { playbackRate } from '@videojs/skins/default/tailwind/audio.tailwind';
import {
@@ -17,9 +16,10 @@ import {
time,
tooltipState,
} from '@videojs/skins/minimal/tailwind/audio.tailwind';
import { createTemplate } from '@videojs/utils/dom';
import { cn } from '@videojs/utils/style';
import { safeDefine } from '../safe-define';
import { SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
@@ -137,9 +137,9 @@ function getTemplateHTML() {
`;
}
export class MinimalAudioSkinTailwindElement extends SkinMixin(ReactiveElement) {
export class MinimalAudioSkinTailwindElement extends SkinElement {
static readonly tagName = 'audio-minimal-skin-tailwind';
static getTemplateHTML = getTemplateHTML;
static template = createTemplate(getTemplateHTML());
}
safeDefine(MinimalAudioSkinTailwindElement);
@@ -1,7 +1,7 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render/minimal';
import { createShadowStyle, createTemplate } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
import { createStyles, SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
import styles from './minimal-skin.css?inline';
// Side-effect imports: register all custom elements used in the template.
@@ -118,10 +118,10 @@ function getTemplateHTML() {
`;
}
export class MinimalAudioSkinElement extends SkinMixin(ReactiveElement) {
export class MinimalAudioSkinElement extends SkinElement {
static readonly tagName = 'audio-minimal-skin';
static styles = createStyles(styles);
static getTemplateHTML = getTemplateHTML;
static styles = createShadowStyle(styles);
static template = createTemplate(getTemplateHTML());
}
safeDefine(MinimalAudioSkinElement);
@@ -1,4 +1,3 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render';
import {
button,
@@ -17,9 +16,10 @@ import {
time,
tooltipState,
} from '@videojs/skins/default/tailwind/audio.tailwind';
import { createTemplate } from '@videojs/utils/dom';
import { cn } from '@videojs/utils/style';
import { safeDefine } from '../safe-define';
import { SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
@@ -132,9 +132,9 @@ function getTemplateHTML() {
`;
}
export class AudioSkinTailwindElement extends SkinMixin(ReactiveElement) {
export class AudioSkinTailwindElement extends SkinElement {
static readonly tagName = 'audio-skin-tailwind';
static getTemplateHTML = getTemplateHTML;
static template = createTemplate(getTemplateHTML());
}
safeDefine(AudioSkinTailwindElement);
+5 -5
View File
@@ -1,7 +1,7 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render';
import { createShadowStyle, createTemplate } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
import { createStyles, SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
import styles from './skin.css?inline';
// Side-effect imports: register all custom elements used in the template.
@@ -113,10 +113,10 @@ function getTemplateHTML() {
`;
}
export class AudioSkinElement extends SkinMixin(ReactiveElement) {
export class AudioSkinElement extends SkinElement {
static readonly tagName = 'audio-skin';
static styles = createStyles(styles);
static getTemplateHTML = getTemplateHTML;
static styles = createShadowStyle(styles);
static template = createTemplate(getTemplateHTML());
}
safeDefine(AudioSkinElement);
+2 -10
View File
@@ -1,18 +1,10 @@
import { ReactiveElement } from '@videojs/element';
import { namedNodeMapToObject } from '@videojs/utils/dom';
import { ensureGlobalStyle, namedNodeMapToObject } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
import styles from './skin.css?inline';
const STYLES_ID = '__media-background-styles';
function ensureBackgroundStyles(): void {
if (document.getElementById(STYLES_ID)) return;
const style = document.createElement('style');
style.id = STYLES_ID;
style.textContent = styles;
document.head.appendChild(style);
}
function getTemplateHTML(_attrs: Record<string, string>) {
return /*html*/ `
<media-container>
@@ -31,7 +23,7 @@ export class BackgroundVideoSkinElement extends ReactiveElement {
constructor() {
super();
ensureBackgroundStyles();
ensureGlobalStyle(STYLES_ID, styles);
if (!this.shadowRoot) {
this.attachShadow((this.constructor as typeof BackgroundVideoSkinElement).shadowRootOptions);
+45
View File
@@ -0,0 +1,45 @@
import { ReactiveElement } from '@videojs/element';
import {
applyShadowStyles,
createShadowStyle,
ensureGlobalStyle,
renderTemplate,
type ShadowStyle,
} from '@videojs/utils/dom';
import rootStyles from './base.css?inline';
import sharedStyles from './shared.css?inline';
const STYLES_ID = '__media-styles';
const sharedSheet = createShadowStyle(sharedStyles);
/**
* Base element for skin definitions. Attaches a shadow root, clones
* `static template` into it, and applies shared + per-skin styles
* via `adoptedStyleSheets` (or `<style>` fallback).
*/
export class SkinElement extends ReactiveElement {
static shadowRootOptions: ShadowRootInit = { mode: 'open' };
static styles?: ShadowStyle;
static template?: HTMLTemplateElement | null;
constructor() {
super();
ensureGlobalStyle(STYLES_ID, rootStyles);
if (!this.shadowRoot) {
const ctor = this.constructor as typeof SkinElement;
this.attachShadow(ctor.shadowRootOptions);
if (ctor.template) {
renderTemplate(this.shadowRoot!, ctor.template);
}
const sheets: ShadowStyle[] = [sharedSheet];
if (ctor.styles) {
sheets.push(ctor.styles);
}
applyShadowStyles(this.shadowRoot!, sheets);
}
}
}
-96
View File
@@ -1,96 +0,0 @@
import type { ReactiveElement } from '@videojs/element';
import type { Constructor } from '@videojs/utils/types';
import rootStyles from './base.css?inline';
import sharedStyles from './shared.css?inline';
const STYLES_ID = '__media-styles';
type SkinStyles = CSSStyleSheet | string;
function ensureRootStyles(): void {
const doc = globalThis.document;
if (!doc || doc.getElementById(STYLES_ID)) return;
const style = doc.createElement('style');
style.id = STYLES_ID;
style.textContent = rootStyles;
doc.head.appendChild(style);
}
function isConstructableStyleSheet(value: SkinStyles): value is CSSStyleSheet {
return typeof globalThis.CSSStyleSheet !== 'undefined' && value instanceof globalThis.CSSStyleSheet;
}
function getStyleText(style: SkinStyles): string {
if (typeof style === 'string') return style;
return Array.from(style.cssRules)
.map((rule) => rule.cssText)
.join('\n');
}
function applyShadowStyles(shadowRoot: ShadowRoot, styles: SkinStyles[]): 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);
}
}
const sharedSheet = createStyles(sharedStyles);
/**
* Mixin for skin elements that renders the template from a static
* `getTemplateHTML` method into a shadow root. Native `<slot>` elements
* handle light DOM projection automatically.
*
* When `static styles` is set, the stylesheet is adopted into the
* shadow root via `adoptedStyleSheets`.
*/
export function SkinMixin<Base extends Constructor<ReactiveElement>>(
BaseClass: Base
): Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles } {
class SkinElement extends (BaseClass as Constructor<ReactiveElement>) {
static shadowRootOptions: ShadowRootInit = { mode: 'open' };
static styles?: SkinStyles;
constructor(...args: any[]) {
super(...args);
ensureRootStyles();
if (!this.shadowRoot) {
const ctor = this.constructor as typeof SkinElement & { getTemplateHTML?: () => string };
this.attachShadow(ctor.shadowRootOptions);
if (ctor.getTemplateHTML) {
this.shadowRoot!.innerHTML = ctor.getTemplateHTML();
}
const sheets: SkinStyles[] = [sharedSheet];
if (ctor.styles) {
sheets.push(ctor.styles);
}
applyShadowStyles(this.shadowRoot!, sheets);
}
}
}
return SkinElement as unknown as Base & { shadowRootOptions: ShadowRootInit; styles?: SkinStyles };
}
/** Create a constructable stylesheet when available, otherwise return raw CSS. */
export function createStyles(css: string): SkinStyles {
if (typeof globalThis.CSSStyleSheet === 'undefined') {
return css;
}
const sheet = new globalThis.CSSStyleSheet();
sheet.replaceSync(css);
return sheet;
}
@@ -1,4 +1,3 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render/minimal';
import {
bufferingIndicator,
@@ -22,9 +21,10 @@ import {
time,
tooltipState,
} from '@videojs/skins/minimal/tailwind/video.tailwind';
import { createTemplate } from '@videojs/utils/dom';
import { cn } from '@videojs/utils/style';
import { safeDefine } from '../safe-define';
import { SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
@@ -199,9 +199,9 @@ function getTemplateHTML() {
`;
}
export class MinimalVideoSkinTailwindElement extends SkinMixin(ReactiveElement) {
export class MinimalVideoSkinTailwindElement extends SkinElement {
static readonly tagName = 'video-minimal-skin-tailwind';
static getTemplateHTML = getTemplateHTML;
static template = createTemplate(getTemplateHTML());
}
safeDefine(MinimalVideoSkinTailwindElement);
@@ -1,7 +1,7 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render/minimal';
import { createShadowStyle, createTemplate } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
import { createStyles, SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
import styles from './minimal-skin.css?inline';
// Side-effect imports: register all custom elements used in the template.
@@ -166,10 +166,10 @@ function getTemplateHTML() {
`;
}
export class MinimalVideoSkinElement extends SkinMixin(ReactiveElement) {
export class MinimalVideoSkinElement extends SkinElement {
static readonly tagName = 'video-minimal-skin';
static styles = createStyles(styles);
static getTemplateHTML = getTemplateHTML;
static styles = createShadowStyle(styles);
static template = createTemplate(getTemplateHTML());
}
safeDefine(MinimalVideoSkinElement);
@@ -1,4 +1,3 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render';
import {
bufferingIndicator,
@@ -22,9 +21,10 @@ import {
time,
tooltipState,
} from '@videojs/skins/default/tailwind/video.tailwind';
import { createTemplate } from '@videojs/utils/dom';
import { cn } from '@videojs/utils/style';
import { safeDefine } from '../safe-define';
import { SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
@@ -194,9 +194,9 @@ function getTemplateHTML() {
`;
}
export class VideoSkinTailwindElement extends SkinMixin(ReactiveElement) {
export class VideoSkinTailwindElement extends SkinElement {
static readonly tagName = 'video-skin-tailwind';
static getTemplateHTML = getTemplateHTML;
static template = createTemplate(getTemplateHTML());
}
safeDefine(VideoSkinTailwindElement);
+5 -5
View File
@@ -1,7 +1,7 @@
import { ReactiveElement } from '@videojs/element';
import { renderIcon } from '@videojs/icons/render';
import { createShadowStyle, createTemplate } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
import { createStyles, SkinMixin } from '../skin-mixin';
import { SkinElement } from '../skin-element';
import styles from './skin.css?inline';
// Side-effect imports: register all custom elements used in the template.
@@ -164,10 +164,10 @@ function getTemplateHTML() {
`;
}
export class VideoSkinElement extends SkinMixin(ReactiveElement) {
export class VideoSkinElement extends SkinElement {
static readonly tagName = 'video-skin';
static styles = createStyles(styles);
static getTemplateHTML = getTemplateHTML;
static styles = createShadowStyle(styles);
static template = createTemplate(getTemplateHTML());
}
safeDefine(VideoSkinElement);
+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');
});
});