WIP: refactor(html): implement hook-style component architecture for PlayButton and MuteButton

- Create generic toConnectedHTMLComponent factory with hooks pattern
- Implement usePlayButtonState/Props/Events hooks for state, attributes, and event handling
- Implement useMuteButtonState/Props/Events hooks for mute functionality
- Replace old connected/ components with new hook-style architecture
- Fix button interactivity by using MediaPlayButton/MediaMuteButton base classes
- Maintain backward compatibility through connected-with-defaults/ registration
- Components now mirror React hooks architecture with separated concerns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2025-09-08 17:15:40 -07:00
committed by Christian Pillsbury
co-authored by Claude
parent 38215df70b
commit c54945fd58
8 changed files with 243 additions and 140 deletions
@@ -0,0 +1,65 @@
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;
@@ -0,0 +1,59 @@
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,12 +1,10 @@
import { toConnectedMediaMuteButton } from "../connected/media-mute-button";
import { MediaMuteButton as BaseMediaMuteButton } from "../ui/media-mute-button";
const MediaMuteButton = toConnectedMediaMuteButton(BaseMediaMuteButton);
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', MediaMuteButton);
globalThis.customElements.define('media-mute-button', MuteButton);
}
export { MediaMuteButton };
export default MediaMuteButton;
export { MuteButton as MediaMuteButton };
export default MuteButton;
@@ -1,12 +1,10 @@
import { toConnectedMediaPlayButton } from "../connected/media-play-button";
import { MediaPlayButton as BaseMediaPlayButton } from "../ui/media-play-button";
const MediaPlayButton = toConnectedMediaPlayButton(BaseMediaPlayButton);
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', MediaPlayButton);
globalThis.customElements.define('media-play-button', PlayButton);
}
export { MediaPlayButton };
export default MediaPlayButton;
export { PlayButton as MediaPlayButton };
export default PlayButton;
@@ -1,65 +0,0 @@
import { ConsumerMixin } from '@open-wc/context-protocol';
// NOTE: This should be fairly generic code that could itself be abstracted into configuration for a more generic factory implementation. (CJP)
export const toConnectedMediaMuteButton = (BaseClass = HTMLElement) => {
// @ts-ignore - Custom element constructor compatibility
return class MediaMuteButton extends ConsumerMixin(BaseClass) {
static get observedAttributes(): string[] {
return [
// @ts-ignore
...(super.observedAttributes ?? []),
'mediapaused',
];
}
_mediaStore: any;
contexts = {
mediaStore: (mediaStore: any) => {
this._mediaStore = mediaStore;
this._mediaStore.subscribeKeys(
['mediaVolumeLevel', 'mediaMuted'],
({ mediaVolumeLevel, mediaMuted }: any) => {
/** @ts-ignore */
this.mediaVolumeLevel = mediaVolumeLevel;
/** @ts-ignore */
this.mediaMuted = mediaMuted;
// @ts-ignore - Element property access
this.setAttribute('data-volume-level', mediaVolumeLevel);
// @ts-ignore - Element property access
this.toggleAttribute('data-muted', mediaMuted);
},
);
},
};
connectedCallback(): void {
super.connectedCallback?.();
// @ts-ignore - Element property access
this.addEventListener('mediamuterequest', this);
// @ts-ignore - Element property access
this.addEventListener('mediaunmuterequest', this);
}
disconnectedCallback(): void {
super.disconnectedCallback?.();
// @ts-ignore - Element property access
this.removeEventListener('mediamuterequest', this);
// @ts-ignore - Element property access
this.removeEventListener('mediaunmuterequest', this);
}
handleEvent(event: CustomEvent) {
/** @ts-ignore */
super.handleEvent?.(event);
if (
this._mediaStore &&
['mediamuterequest', 'mediaunmuterequest'].includes(event.type)
) {
const { type, detail } = event;
this._mediaStore.dispatch({ type, detail });
}
}
};
};
@@ -1,63 +0,0 @@
import { ConsumerMixin } from '@open-wc/context-protocol';
// NOTE: This should be fairly generic code that could itself be abstracted into configuration for a more generic factory implementation. (CJP)
export const toConnectedMediaPlayButton = (BaseClass = HTMLElement) => {
return class MediaPlayButton extends ConsumerMixin(BaseClass) {
static get observedAttributes(): string[] {
return [
// @ts-ignore
...(super.observedAttributes ?? []),
'mediapaused',
];
}
_mediaStore: any;
contexts = {
mediaStore: (mediaStore: any) => {
this._mediaStore = mediaStore;
this._mediaStore.subscribeKeys(
['mediaPaused'],
({ mediaPaused }: any) => {
// NOTE: We may want to assume setting properties instead of attributes here to leave things generic for
// complex values. That allows implementors to decide what to do if/when the property is set.
// Using `this.toggleAttribute('mediapaused', mediaPaused);` should also work if that's preferred.
/** @ts-ignore */
this.mediaPaused = mediaPaused;
// @ts-ignore - Element property access
this.toggleAttribute('data-paused', mediaPaused);
},
);
},
};
connectedCallback(): void {
super.connectedCallback?.();
// @ts-ignore - Element property access
this.addEventListener('mediaplayrequest', this);
// @ts-ignore - Element property access
this.addEventListener('mediapauserequest', this);
}
disconnectedCallback(): void {
super.disconnectedCallback?.();
// @ts-ignore - Element property access
this.removeEventListener('mediaplayrequest', this);
// @ts-ignore - Element property access
this.removeEventListener('mediapauserequest', this);
}
handleEvent(event: CustomEvent) {
/** @ts-ignore */
super.handleEvent?.(event);
if (
this._mediaStore &&
['mediaplayrequest', 'mediapauserequest'].includes(event.type)
) {
const { type, detail } = event;
this._mediaStore.dispatch({ type, detail });
}
}
};
};
+4
View File
@@ -1,6 +1,10 @@
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 function defineVjsPlayer() {
/** @TODO - Reimplement me (at least as a POC) (CJP) */
// defineVideoProvider();
@@ -0,0 +1,107 @@
import { ConsumerMixin } from '@open-wc/context-protocol';
/**
* Generic types for HTML component hooks pattern
* Mirrors the React hooks architecture for consistency
*/
export type StateHook<T = any> = {
keys: string[];
transform: (rawState: any) => T;
};
export type PropsHook<T = any> = (state: T, element: HTMLElement) => void;
export type EventsHook = {
events: string[];
handler: (event: CustomEvent, mediaStore: any) => void;
};
/**
* 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
* @param eventsHook - Hook that defines event handling logic
* @param displayName - Display name for debugging
* @returns Connected custom element class with media store integration
*/
export const toConnectedHTMLComponent = <TState = any>(
BaseClass: CustomElementConstructor,
stateHook: StateHook<TState>,
propsHook: PropsHook<TState>,
eventsHook: EventsHook,
displayName?: string
) => {
const ConnectedComponent = class extends ConsumerMixin(BaseClass) {
static get observedAttributes(): string[] {
return [
// @ts-ignore
...(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);
}
}
);
}
};
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);
}
}
};
// Set display name for debugging and dev tools
if (displayName) {
Object.defineProperty(ConnectedComponent, 'name', { value: displayName });
}
return ConnectedComponent;
};