refactor(html): implement hook-style component architecture for PlayButton and MuteButton (gradual migration to more shareable with React).

This commit is contained in:
Christian Pillsbury
2025-09-08 17:15:40 -07:00
committed by Christian Pillsbury
parent c54945fd58
commit 74dea64ddd
14 changed files with 261 additions and 261 deletions
@@ -15,4 +15,4 @@ export const playable = {
mediapauserequest: () => true,
},
},
};
};
@@ -1,65 +0,0 @@
import { toConnectedHTMLComponent, StateHook, PropsHook, EventsHook } from '../utils/component-factory';
import { MediaMuteButton } from './ui/media-mute-button';
/**
* MuteButton state hook - equivalent to React's useMuteButtonState
* Handles media store state subscription and transformation
*/
export const useMuteButtonState: StateHook<{ muted: boolean; volumeLevel: string }> = {
keys: ['mediaMuted', 'mediaVolumeLevel'],
transform: (rawState) => ({
muted: rawState.mediaMuted ?? false,
volumeLevel: rawState.mediaVolumeLevel ?? 'off'
})
};
/**
* MuteButton props hook - equivalent to React's useMuteButtonProps
* Handles element attributes and properties based on state
*/
export const useMuteButtonProps: PropsHook<{ muted: boolean; volumeLevel: string }> = (state, element) => {
// Handle boolean data attribute: present with empty string when true, absent when false
// This matches the React component behavior exactly
if (state.muted) {
element.setAttribute('data-muted', '');
} else {
element.removeAttribute('data-muted');
}
// Set volume level data attribute
element.setAttribute('data-volume-level', state.volumeLevel);
// Set element properties for backwards compatibility
// @ts-ignore - Custom element property
element.mediaMuted = state.muted;
// @ts-ignore - Custom element property
element.mediaVolumeLevel = state.volumeLevel;
};
/**
* MuteButton events hook - equivalent to React's event handlers
* Handles event dispatch to media store
*/
export const useMuteButtonEvents: EventsHook = {
events: ['mediamuterequest', 'mediaunmuterequest'],
handler: (event, mediaStore) => {
if (['mediamuterequest', 'mediaunmuterequest'].includes(event.type)) {
const { type, detail } = event;
mediaStore.dispatch({ type, detail });
}
}
};
/**
* Connected MuteButton component using hook-style architecture
* Equivalent to React's MuteButton = toConnectedComponent(...)
*/
export const MuteButton = toConnectedHTMLComponent(
MediaMuteButton,
useMuteButtonState,
useMuteButtonProps,
useMuteButtonEvents,
'MuteButton'
);
export default MuteButton;
@@ -1,59 +0,0 @@
import { toConnectedHTMLComponent, StateHook, PropsHook, EventsHook } from '../utils/component-factory';
import { MediaPlayButton } from './ui/media-play-button';
/**
* PlayButton state hook - equivalent to React's usePlayButtonState
* Handles media store state subscription and transformation
*/
export const usePlayButtonState: StateHook<{ paused: boolean }> = {
keys: ['mediaPaused'],
transform: (rawState) => ({
paused: rawState.mediaPaused ?? true
})
};
/**
* PlayButton props hook - equivalent to React's usePlayButtonProps
* Handles element attributes and properties based on state
*/
export const usePlayButtonProps: PropsHook<{ paused: boolean }> = (state, element) => {
// Handle boolean data attribute: present with empty string when true, absent when false
// This matches the React component behavior exactly
if (state.paused) {
element.setAttribute('data-paused', '');
} else {
element.removeAttribute('data-paused');
}
// Set element property for backwards compatibility
// @ts-ignore - Custom element property
element.mediaPaused = state.paused;
};
/**
* PlayButton events hook - equivalent to React's event handlers
* Handles event dispatch to media store
*/
export const usePlayButtonEvents: EventsHook = {
events: ['mediaplayrequest', 'mediapauserequest'],
handler: (event, mediaStore) => {
if (['mediaplayrequest', 'mediapauserequest'].includes(event.type)) {
const { type, detail } = event;
mediaStore.dispatch({ type, detail });
}
}
};
/**
* Connected PlayButton component using hook-style architecture
* Equivalent to React's PlayButton = toConnectedComponent(...)
*/
export const PlayButton = toConnectedHTMLComponent(
MediaPlayButton,
usePlayButtonState,
usePlayButtonProps,
usePlayButtonEvents,
'PlayButton'
);
export default PlayButton;
@@ -1,10 +0,0 @@
import { MuteButton } from '../MuteButton.js';
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-mute-button')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-mute-button', MuteButton);
}
export { MuteButton as MediaMuteButton };
export default MuteButton;
@@ -1,10 +0,0 @@
import { PlayButton } from '../PlayButton.js';
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-play-button')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-play-button', PlayButton);
}
export { PlayButton as MediaPlayButton };
export default PlayButton;
@@ -1,9 +1,9 @@
import { namedNodeMapToObject } from '../../utils/element-utils.js';
import { namedNodeMapToObject } from '../utils/element-utils.js';
export function getTemplateHTML(
this: typeof MediaChromeButton,
_attrs: Record<string, string>,
_props: Record<string, any> = {}
_props: Record<string, any> = {},
) {
return /* html */ `
<style>
@@ -31,17 +31,24 @@ export class MediaChromeButton extends HTMLElement {
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow((this.constructor as typeof MediaChromeButton).shadowRootOptions);
this.attachShadow(
(this.constructor as typeof MediaChromeButton).shadowRootOptions,
);
const attrs = namedNodeMapToObject(this.attributes);
const html = (this.constructor as typeof MediaChromeButton).getTemplateHTML(attrs);
const html = (
this.constructor as typeof MediaChromeButton
).getTemplateHTML(attrs);
// From MDN: setHTMLUnsafe should be used instead of ShadowRoot.innerHTML
// when a string of HTML may contain declarative shadow roots.
const shadowRoot = this.shadowRoot as unknown as ShadowRoot;
shadowRoot.setHTMLUnsafe ? shadowRoot.setHTMLUnsafe(html) : (shadowRoot.innerHTML = html);
shadowRoot.setHTMLUnsafe
? shadowRoot.setHTMLUnsafe(html)
: (shadowRoot.innerHTML = html);
}
this.addEventListener('click', this.handleClick);
this.addEventListener('click', this);
}
handleClick() {}
handleEvent(_event: Event) {}
}
@@ -0,0 +1,117 @@
import {
toConnectedHTMLComponent,
StateHook,
PropsHook,
} from '../utils/component-factory';
import { MediaChromeButton } from './media-chrome-button';
export class MediaMuteButton extends MediaChromeButton {
_state:
| {
muted: boolean;
volumeLevel: string;
requestUnmute: () => void;
requestMute: () => void;
}
| undefined;
handleEvent(event: Event) {
const { type } = event;
if (type === 'click') {
const state = this._state;
if (state) {
if (state.muted) {
state.requestUnmute();
} else {
state.requestMute();
}
}
}
}
get muted() {
return this._state?.muted;
}
get volumeLevel() {
return this._state?.volumeLevel;
}
_update(props: any, state: any) {
this._state = state;
// Make generic
this.toggleAttribute('data-muted', props['data-muted']);
this.setAttribute('data-volume-level', props['data-volume-level']);
this.setAttribute('role', props['role']);
this.setAttribute('aria-label', props['aria-label']);
this.setAttribute('data-tooltip', props['data-tooltip']);
}
}
/**
* MuteButton state hook - equivalent to React's useMuteButtonState
* Handles media store state subscription and transformation
*/
export const useMuteButtonState: StateHook<{
muted: boolean;
volumeLevel: string;
}> = {
keys: ['mediaMuted', 'mediaVolumeLevel'],
transform: (rawState, mediaStore) => ({
muted: rawState.mediaMuted ?? false,
volumeLevel: rawState.mediaVolumeLevel ?? 'off',
requestMute() {
const type = 'mediamuterequest';
mediaStore.dispatch({ type });
},
requestUnmute() {
const type = 'mediaunmuterequest';
mediaStore.dispatch({ type });
},
}),
};
/**
* MuteButton props hook - equivalent to React's useMuteButtonProps
* Handles element attributes and properties based on state
*/
export const useMuteButtonProps: PropsHook<{
muted: boolean;
volumeLevel: string;
}> = (state, _element) => {
const baseProps: Record<string, any> = {
/** data attributes/props */
['data-muted']: state.muted,
['data-volume-level']: state.volumeLevel,
/** @TODO Need another state provider in core for i18n (CJP) */
/** aria attributes/props */
role: 'button',
['aria-label']: state.muted ? 'unmute' : 'mute',
/** tooltip */
['data-tooltip']: state.muted ? 'Unmute' : 'Mute',
/** @TODO Figure out how we want to handle attr overrides (e.g. aria-label) (CJP) */
/** external props spread last to allow for overriding */
// ...props,
};
return baseProps;
};
/**
* Connected MuteButton component using hook-style architecture
* Equivalent to React's MuteButton = toConnectedComponent(...)
*/
export const MuteButton = toConnectedHTMLComponent(
MediaMuteButton,
useMuteButtonState,
useMuteButtonProps,
'MuteButton',
);
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-mute-button')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-mute-button', MuteButton);
}
export default MuteButton;
@@ -0,0 +1,103 @@
import {
toConnectedHTMLComponent,
StateHook,
PropsHook,
} from '../utils/component-factory';
import { MediaChromeButton } from './media-chrome-button';
export class PlayButtonBase extends MediaChromeButton {
_state:
| { paused: boolean; requestPlay: () => void; requestPause: () => void }
| undefined;
handleEvent(event: Event) {
const { type } = event;
const state = this._state;
if (state) {
if (type === 'click') {
if (state.paused) {
state.requestPlay();
} else {
state.requestPause();
}
}
}
}
get paused() {
return this._state?.paused;
}
_update(props: any, state: any) {
this._state = state;
/** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */
// Make generic
this.toggleAttribute('data-paused', props['data-paused']);
this.setAttribute('role', props['role']);
this.setAttribute('aria-label', props['aria-label']);
this.setAttribute('data-tooltip', props['data-tooltip']);
}
}
/**
* PlayButton state hook - equivalent to React's usePlayButtonState
* Handles media store state subscription and transformation
*/
export const usePlayButtonState: StateHook<{ paused: boolean }> = {
keys: ['mediaPaused'],
transform: (rawState, mediaStore) => ({
paused: rawState.mediaPaused ?? true,
requestPlay() {
const type = 'mediaplayrequest';
mediaStore.dispatch({ type });
},
requestPause() {
const type = 'mediapauserequest';
mediaStore.dispatch({ type });
},
}),
};
/**
* PlayButton props hook - equivalent to React's usePlayButtonProps
* Handles element attributes and properties based on state
*/
export const usePlayButtonProps: PropsHook<{ paused: boolean }> = (
state,
_element,
) => {
const baseProps: Record<string, any> = {
/** data attributes/props */
['data-paused']: state.paused,
/** @TODO Need another state provider in core for i18n (CJP) */
/** aria attributes/props */
role: 'button',
['aria-label']: state.paused ? 'play' : 'pause',
/** tooltip */
['data-tooltip']: state.paused ? 'Play' : 'Pause',
/** @TODO Figure out how we want to handle attr overrides (e.g. aria-label) (CJP) */
/** external props spread last to allow for overriding */
// ...props,
};
return baseProps;
};
/**
* Connected PlayButton component using hook-style architecture
* Equivalent to React's PlayButton = toConnectedComponent(...)
*/
export const PlayButton = toConnectedHTMLComponent(
PlayButtonBase,
usePlayButtonState,
usePlayButtonProps,
'PlayButton',
);
// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP)
if (!globalThis.customElements.get('media-play-button')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-play-button', PlayButton);
}
export default PlayButton;
@@ -1,34 +0,0 @@
import { MediaChromeButton } from './media-chrome-button';
export class MediaMuteButton extends MediaChromeButton {
static get observedAttributes() {
return ['mediavolumelevel', 'mediamuted'];
}
handleClick() {
const type = this.mediaMuted ? 'mediaunmuterequest' : 'mediamuterequest';
this.dispatchEvent(new CustomEvent(type));
}
get mediaMuted() {
return this.hasAttribute('mediamuted');
}
set mediaMuted(value: boolean) {
this.toggleAttribute('mediamuted', !!value);
}
get mediaVolumeLevel() {
return this.getAttribute('mediavolumelevel');
}
set mediaVolumeLevel(value: string | null | undefined) {
if (value == null) {
this.removeAttribute('mediavolumelevel');
} else {
this.setAttribute('mediavolumelevel', value);
}
}
}
@@ -1,22 +0,0 @@
import { MediaChromeButton } from './media-chrome-button';
export class MediaPlayButton extends MediaChromeButton {
static get observedAttributes() {
return ['mediapaused'];
}
handleClick() {
const type = this.mediaPaused ? 'mediaplayrequest' : 'mediapauserequest';
this.dispatchEvent(new CustomEvent(type));
}
get mediaPaused() {
return this.hasAttribute('mediapaused');
}
set mediaPaused(value: boolean) {
this.toggleAttribute('mediapaused', !!value);
}
}
+3 -3
View File
@@ -2,12 +2,12 @@ export * as MediaProvider from './media-provider.js';
export * as MediaThemeDefault from './skins/media-skin-default.js';
// New hook-style components
export { PlayButton } from './components/PlayButton.js';
export { MuteButton } from './components/MuteButton.js';
export { PlayButton } from './components/media-play-button.js';
export { MuteButton } from './components/media-mute-button.js';
export function defineVjsPlayer() {
/** @TODO - Reimplement me (at least as a POC) (CJP) */
// defineVideoProvider();
// defineVideoDefaultSkin();
// <video> is native, no need to define
}
}
@@ -1,8 +1,8 @@
import { MediaSkin } from '../media-skin.js';
import { MediaSkin } from '../media-skin';
import '../media-container.js';
import '../components/connected-with-defaults/media-play-button.js';
import '../components/connected-with-defaults/media-mute-button.js';
import '../media-container';
import '../components/media-play-button';
import '../components/media-mute-button';
import '@vjs-10/html-icons';
export function getTemplateHTML() {
@@ -6,20 +6,15 @@ import { ConsumerMixin } from '@open-wc/context-protocol';
*/
export type StateHook<T = any> = {
keys: string[];
transform: (rawState: any) => T;
transform: (rawState: any, mediaStore: any) => T;
};
export type PropsHook<T = any> = (state: T, element: HTMLElement) => void;
export type EventsHook = {
events: string[];
handler: (event: CustomEvent, mediaStore: any) => void;
};
export type PropsHook<T = any, P = any> = (state: T, element: HTMLElement) => P;
/**
* Generic factory function to create connected HTML components using hooks pattern.
* Provides equivalent functionality to React's toConnectedComponent but for custom elements.
*
*
* @param BaseClass - Base custom element class to extend
* @param stateHook - Hook that defines state keys and transformation logic
* @param propsHook - Hook that handles element attributes and properties based on state
@@ -31,70 +26,48 @@ export const toConnectedHTMLComponent = <TState = any>(
BaseClass: CustomElementConstructor,
stateHook: StateHook<TState>,
propsHook: PropsHook<TState>,
eventsHook: EventsHook,
displayName?: string
displayName?: string,
) => {
const ConnectedComponent = class extends ConsumerMixin(BaseClass) {
static get observedAttributes(): string[] {
return [
// @ts-ignore
...(super.observedAttributes ?? [])
...(super.observedAttributes ?? []),
];
}
_mediaStore: any;
_state: TState | undefined;
contexts = {
mediaStore: (mediaStore: any) => {
this._mediaStore = mediaStore;
// Subscribe to media store state changes
// Split into two phases: state transformation, then props update
this._mediaStore.subscribeKeys(
stateHook.keys,
(rawState: any) => {
// Phase 1: Transform raw media store state (state concern)
this._state = stateHook.transform(rawState);
// Phase 2: Update element attributes/properties (props concern)
if (this._state !== undefined) {
// @ts-ignore - Element property access
propsHook(this._state, this);
}
}
);
}
this._mediaStore.subscribeKeys(stateHook.keys, (rawState: any) => {
// Phase 1: Transform raw media store state (state concern)
const state = stateHook.transform(rawState, mediaStore);
// Phase 2: Update element attributes/properties (props concern)
// @ts-ignore - Element property access
const props = propsHook(state ?? {}, this);
// @ts-ignore
this._update(props, state);
});
},
};
connectedCallback(): void {
super.connectedCallback?.();
// Set up event listeners
eventsHook.events.forEach(eventType => {
// @ts-ignore - Element property access
this.addEventListener(eventType, this);
});
}
disconnectedCallback(): void {
super.disconnectedCallback?.();
// Clean up event listeners
eventsHook.events.forEach(eventType => {
// @ts-ignore - Element property access
this.removeEventListener(eventType, this);
});
}
handleEvent(event: CustomEvent): void {
// @ts-ignore
super.handleEvent?.(event);
// Delegate to event hook if media store is available
if (this._mediaStore) {
eventsHook.handler(event, this._mediaStore);
}
}
};
@@ -104,4 +77,4 @@ export const toConnectedHTMLComponent = <TState = any>(
}
return ConnectedComponent;
};
};
@@ -82,4 +82,4 @@ export const PlayButton = toConnectedComponent(
renderPlayButton,
'PlayButton',
);
export default PlayButton;
export default PlayButton;