From f3b1b199173f3750bc05ad9063fcccbd4163b12b Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 21 Nov 2025 15:16:20 -0600 Subject: [PATCH] fix: add popover core, use in html and improve factory (#204) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/core/src/components/popover.ts | 214 +++++++++++++++ packages/core/src/index.ts | 1 + .../html/src/elements/current-time-display.ts | 62 +---- .../html/src/elements/duration-display.ts | 35 +-- .../html/src/elements/fullscreen-button.ts | 71 ++--- packages/html/src/elements/mute-button.ts | 93 +++---- packages/html/src/elements/play-button.ts | 65 ++--- packages/html/src/elements/popover.ts | 236 ++++------------ .../html/src/elements/preview-time-display.ts | 54 +--- packages/html/src/elements/time-slider.ts | 258 ++++++++---------- packages/html/src/elements/volume-slider.ts | 209 ++++++-------- packages/html/src/utils/component-factory.ts | 95 ++++--- packages/utils/src/dom/attributes.ts | 20 +- packages/utils/src/index.ts | 2 + packages/utils/src/shared/memoize.ts | 71 +++++ packages/utils/src/shared/string.ts | 11 + 16 files changed, 755 insertions(+), 742 deletions(-) create mode 100644 packages/core/src/components/popover.ts create mode 100644 packages/utils/src/shared/memoize.ts create mode 100644 packages/utils/src/shared/string.ts diff --git a/packages/core/src/components/popover.ts b/packages/core/src/components/popover.ts new file mode 100644 index 00000000..9d48ec32 --- /dev/null +++ b/packages/core/src/components/popover.ts @@ -0,0 +1,214 @@ +import { shallowEqual } from '@videojs/utils'; +import { contains, getDocument, safePolygon } from '@videojs/utils/dom'; +import { map } from 'nanostores'; + +type Placement = 'top' | 'top-start' | 'top-end'; + +export interface PopoverState { + open: boolean; + openOnHover: boolean; + delay: number; + closeDelay: number; + placement: Placement; + sideOffset: number; + _setTriggerElement: (element: HTMLElement | null) => void; + _triggerElement: HTMLElement | null; + _setPopoverElement: (element: HTMLElement | null) => void; + _popoverElement: HTMLElement | null; + _transitionStatus: 'initial' | 'open' | 'close' | 'unmounted'; +} + +export class Popover { + #hoverTimeout: ReturnType | null = null; + #state = map({ + open: false, + openOnHover: false, + delay: 0, + closeDelay: 0, + placement: 'top', + sideOffset: 5, + _setTriggerElement: this._setTriggerElement.bind(this), + _triggerElement: null, + _setPopoverElement: this._setPopoverElement.bind(this), + _popoverElement: null, + _transitionStatus: 'initial', + }); + + _setPopoverElement(element: HTMLElement | null): void { + if (!element) { + this.#clearHoverTimeout(); + this.#popoverElement?.removeEventListener('pointerenter', this); + this.#popoverElement?.removeEventListener('focusout', this); + getDocument(this.#popoverElement).documentElement.removeEventListener('pointermove', this); + return; + } + + this.setState({ _popoverElement: element }); + + element.addEventListener('pointerenter', this); + element.addEventListener('focusout', this); + } + + _setTriggerElement(element: HTMLElement | null): void { + if (!element) { + this.#triggerElement?.removeEventListener('pointerenter', this); + this.#triggerElement?.removeEventListener('pointerleave', this); + this.#triggerElement?.removeEventListener('focusin', this); + this.#triggerElement?.removeEventListener('focusout', this); + return; + } + + this.setState({ _triggerElement: element }); + + if (globalThis.matchMedia?.('(hover: hover)')?.matches) { + element.addEventListener('pointerenter', this); + element.addEventListener('pointerleave', this); + } + + element.addEventListener('focusin', this); + element.addEventListener('focusout', this); + } + + subscribe(callback: (state: PopoverState) => void): () => void { + return this.#state.subscribe(callback); + } + + setState(state: Partial): void { + if (shallowEqual(state, this.#state.get())) return; + this.#state.set({ ...this.#state.get(), ...state }); + } + + getState(): PopoverState { + return this.#state.get(); + } + + handleEvent(event: Event): void { + switch (event.type) { + case 'pointerenter': + this.#handlePointerEnter(event as PointerEvent); + break; + case 'pointerleave': + this.#handlePointerLeave(event as PointerEvent); + break; + case 'pointermove': + this.#handlePointerMove(event as PointerEvent); + break; + case 'focusin': + this.#handleFocusIn(event as FocusEvent); + break; + case 'focusout': + this.#handleFocusOut(event as FocusEvent); + break; + default: + break; + } + } + + get #popoverElement(): HTMLElement | null { + return this.getState()._popoverElement; + } + + get #triggerElement(): HTMLElement | null { + return this.getState()._triggerElement; + } + + get #open(): boolean { + return this.getState().open; + } + + #setOpen(open: boolean): void { + if (this.#open === open) return; + + this.setState({ open }); + + if (open) { + this.setState({ _transitionStatus: 'initial' }); + + this.#popoverElement?.showPopover(); + + requestAnimationFrame(() => { + this.setState({ _transitionStatus: 'open' }); + }); + } else { + this.setState({ _transitionStatus: 'close' }); + + const transitions = this.#popoverElement?.getAnimations().filter(anim => anim instanceof CSSTransition); + if (transitions && transitions.length > 0) { + Promise.all(transitions.map(t => t.finished)) + .then(() => this.#popoverElement?.hidePopover()) + .catch(() => this.#popoverElement?.hidePopover()); + } else { + this.#popoverElement?.hidePopover(); + } + } + } + + #clearHoverTimeout(): void { + if (this.#hoverTimeout) { + globalThis.clearTimeout(this.#hoverTimeout); + this.#hoverTimeout = null; + } + } + + #handlePointerEnter(event: PointerEvent): void { + if (!this.getState().openOnHover) return; + + this.#clearHoverTimeout(); + + if (event.currentTarget === this.#popoverElement) { + this.#addPointerMoveListener(); + } + + if (this.getState().open) { + return; + } + + this.#hoverTimeout = globalThis.setTimeout(() => { + this.#setOpen(true); + }, this.getState().delay); + } + + #handlePointerLeave(_event: PointerEvent): void { + this.#addPointerMoveListener(); + } + + #addPointerMoveListener(): void { + if (!globalThis.matchMedia?.('(hover: hover)')?.matches || !this.#popoverElement) return; + + getDocument(this.#popoverElement).documentElement.addEventListener('pointermove', this); + } + + #handlePointerMove(event: PointerEvent): void { + if (!this.getState().openOnHover || !this.#triggerElement || !this.#popoverElement) return; + + const close = safePolygon({ blockPointerEvents: true })({ + placement: this.getState().placement, + elements: { + domReference: this.#triggerElement, + floating: this.#popoverElement, + }, + x: event.clientX, + y: event.clientY, + onClose: () => { + getDocument(this.#popoverElement).documentElement.removeEventListener('pointermove', this); + + this.#clearHoverTimeout(); + this.#hoverTimeout = globalThis.setTimeout(() => { + this.#setOpen(false); + }, this.getState().closeDelay); + }, + }); + close(event); + } + + #handleFocusIn(_event: FocusEvent): void { + this.#setOpen(true); + } + + #handleFocusOut(event: FocusEvent): void { + const relatedTarget = event.relatedTarget as HTMLElement; + if (relatedTarget && contains(this.#popoverElement, relatedTarget)) return; + + this.#setOpen(false); + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07c941fe..e8f849e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ +export { Popover, type PopoverState } from './components/popover'; export { TimeSlider, type TimeSliderState } from './components/time-slider'; export { VolumeSlider, type VolumeSliderState } from './components/volume-slider'; diff --git a/packages/html/src/elements/current-time-display.ts b/packages/html/src/elements/current-time-display.ts index 0017f728..5e8d5def 100644 --- a/packages/html/src/elements/current-time-display.ts +++ b/packages/html/src/elements/current-time-display.ts @@ -1,11 +1,21 @@ -import type { CurrentTimeDisplayState, MediaStore } from '@videojs/core/store'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; +import type { CurrentTimeDisplayState } from '@videojs/core/store'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { currentTimeDisplayStateDefinition } from '@videojs/core/store'; - import { formatDisplayTime } from '@videojs/utils'; import { toConnectedHTMLComponent } from '../utils/component-factory'; +export const getCurrentTimeDisplayState: StateHook = (_element, mediaStore) => { + return { + ...currentTimeDisplayStateDefinition.stateTransform(mediaStore.getState()), + // Current time display is read-only, so no request methods needed + }; +}; + +export const getCurrentTimeDisplayProps: PropsHook = (_element, _state) => { + return {}; +}; + export class CurrentTimeDisplay extends HTMLElement { static shadowRootOptions = { mode: 'open' as ShadowRootMode, @@ -13,13 +23,6 @@ export class CurrentTimeDisplay extends HTMLElement { static observedAttributes: string[] = ['show-remaining']; - _state: - | { - currentTime: number | undefined; - duration: number | undefined; - } - | undefined; - constructor() { super(); @@ -28,28 +31,11 @@ export class CurrentTimeDisplay extends HTMLElement { } } - get currentTime(): number { - return this._state?.currentTime ?? 0; - } - - get duration(): number { - return this._state?.duration ?? 0; - } - get showRemaining(): boolean { return this.hasAttribute('show-remaining'); } - attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void { - if (name === 'show-remaining' && this._state) { - // Re-render with current state when show-remaining attribute changes - this._update({}, this._state); - } - } - - _update(_props: any, state: any): void { - this._state = state; - + _update(_props: any, state: CurrentTimeDisplayState): void { /** @TODO Should this live here or elsewhere? (CJP) */ const timeLabel = this.showRemaining && state.duration != null && state.currentTime != null @@ -62,25 +48,7 @@ export class CurrentTimeDisplay extends HTMLElement { } } -export function getCurrentTimeDisplayState(mediaStore: MediaStore): { - currentTime: number | undefined; - duration: number | undefined; -} { - return { - ...currentTimeDisplayStateDefinition.stateTransform(mediaStore.getState()), - // Current time display is read-only, so no request methods needed - }; -} - -export const getCurrentTimeDisplayProps: PropsHook<{ - currentTime: number | undefined; - duration: number | undefined; -}> = (_state, _element) => { - const baseProps: Record = {}; - return baseProps; -}; - -export const CurrentTimeDisplayElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const CurrentTimeDisplayElement: ConnectedComponentConstructor = toConnectedHTMLComponent( CurrentTimeDisplay, getCurrentTimeDisplayState, getCurrentTimeDisplayProps, diff --git a/packages/html/src/elements/duration-display.ts b/packages/html/src/elements/duration-display.ts index bff31811..77e746fe 100644 --- a/packages/html/src/elements/duration-display.ts +++ b/packages/html/src/elements/duration-display.ts @@ -1,5 +1,5 @@ -import type { DurationDisplayState, MediaStore } from '@videojs/core/store'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; +import type { DurationDisplayState } from '@videojs/core/store'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { durationDisplayStateDefinition } from '@videojs/core/store'; @@ -24,12 +24,6 @@ export class DurationDisplay extends HTMLElement { static getTemplateHTML: typeof getTemplateHTML = getTemplateHTML; - _state: - | { - duration: number | undefined; - } - | undefined; - constructor() { super(); @@ -43,13 +37,7 @@ export class DurationDisplay extends HTMLElement { } } - get duration(): number { - return this._state?.duration ?? 0; - } - - _update(_props: any, state: any): void { - this._state = state; - + _update(_props: any, state: DurationDisplayState): void { // Update the span content with formatted duration const spanElement = this.shadowRoot?.querySelector('span') as HTMLElement; if (spanElement) { @@ -58,23 +46,18 @@ export class DurationDisplay extends HTMLElement { } } -export function getDurationDisplayState(mediaStore: MediaStore): { - duration: number | undefined; -} { +export const getDurationDisplayState: StateHook = (_element, mediaStore) => { return { ...durationDisplayStateDefinition.stateTransform(mediaStore.getState()), // Duration display is read-only, so no request methods needed }; -} - -export const getDurationDisplayProps: PropsHook<{ - duration: number | undefined; -}> = (_state, _element) => { - const baseProps: Record = {}; - return baseProps; }; -export const DurationDisplayElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const getDurationDisplayProps: PropsHook = (_element, _state) => { + return {}; +}; + +export const DurationDisplayElement: ConnectedComponentConstructor = toConnectedHTMLComponent( DurationDisplay, getDurationDisplayState, getDurationDisplayProps, diff --git a/packages/html/src/elements/fullscreen-button.ts b/packages/html/src/elements/fullscreen-button.ts index 7ae90264..819bd3dd 100644 --- a/packages/html/src/elements/fullscreen-button.ts +++ b/packages/html/src/elements/fullscreen-button.ts @@ -1,61 +1,29 @@ -import type { FullscreenButtonState, MediaStore } from '@videojs/core/store'; +import type { FullscreenButtonState } from '@videojs/core/store'; import type { Prettify } from '../types'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { fullscreenButtonStateDefinition } from '@videojs/core/store'; -import { setAttributes } from '@videojs/utils/dom'; +import { memoize } from '@videojs/utils'; import { toConnectedHTMLComponent } from '../utils/component-factory'; import { ButtonElement } from './button'; -export class FullscreenButton extends ButtonElement { - _state: - | { - fullscreen: boolean; - requestEnterFullscreen: () => void; - requestExitFullscreen: () => void; - } - | undefined; - - handleEvent(event: Event): void { - super.handleEvent(event); - - const { type } = event; - const state = this._state; - if (state && type === 'click') { - if (state.fullscreen) { - state.requestExitFullscreen(); - } else { - state.requestEnterFullscreen(); - } - } - } - - get fullscreen(): boolean { - return this._state?.fullscreen ?? false; - } - - _update(props: any, state: any, _mediaStore?: any): void { - this._state = state; - /** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */ - setAttributes(this, props); - } -} - type FullscreenButtonStateWithMethods = Prettify>; +const fullscreenButtonCreateRequestMethods = memoize(fullscreenButtonStateDefinition.createRequestMethods); + /** * FullscreenButton state hook - equivalent to React's useFullscreenButtonState * Handles media store state subscription and transformation */ -export function getFullscreenButtonState(mediaStore: MediaStore): FullscreenButtonStateWithMethods { +export const getFullscreenButtonState: StateHook = (_element, mediaStore) => { return { ...fullscreenButtonStateDefinition.stateTransform(mediaStore.getState()), - ...fullscreenButtonStateDefinition.createRequestMethods(mediaStore.dispatch), + ...fullscreenButtonCreateRequestMethods(mediaStore.dispatch), }; -} +}; -export const getFullscreenButtonProps: PropsHook<{ fullscreen: boolean }> = (state, _element) => { +export const getFullscreenButtonProps: PropsHook = (_element, state) => { const baseProps: Record = { /** data attributes/props */ 'data-fullscreen': state.fullscreen, @@ -70,11 +38,28 @@ export const getFullscreenButtonProps: PropsHook<{ fullscreen: boolean }> = (sta /** external props spread last to allow for overriding */ // ...props, }; - return baseProps; }; -export const FullscreenButtonElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export class FullscreenButton extends ButtonElement { + _state: FullscreenButtonStateWithMethods | undefined; + + handleEvent(event: Event): void { + super.handleEvent(event); + + const { type } = event; + const state = this._state; + if (state && type === 'click') { + if (state.fullscreen) { + state.requestExitFullscreen(); + } else { + state.requestEnterFullscreen(); + } + } + } +} + +export const FullscreenButtonElement: ConnectedComponentConstructor = toConnectedHTMLComponent( FullscreenButton, getFullscreenButtonState, getFullscreenButtonProps, diff --git a/packages/html/src/elements/mute-button.ts b/packages/html/src/elements/mute-button.ts index 8e6cb8b5..29325fd1 100644 --- a/packages/html/src/elements/mute-button.ts +++ b/packages/html/src/elements/mute-button.ts @@ -1,22 +1,45 @@ -import type { MediaStore, MuteButtonState } from '@videojs/core/store'; +import type { MuteButtonState } from '@videojs/core/store'; import type { Prettify } from '../types'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { muteButtonStateDefinition } from '@videojs/core/store'; -import { setAttributes } from '@videojs/utils/dom'; +import { memoize } from '@videojs/utils'; import { toConnectedHTMLComponent } from '../utils/component-factory'; import { ButtonElement } from './button'; +type MuteButtonStateWithMethods = Prettify>; + +const muteButtonCreateRequestMethods = memoize(muteButtonStateDefinition.createRequestMethods); + +export const getMuteButtonState: StateHook = (_element, mediaStore) => { + return { + ...muteButtonStateDefinition.stateTransform(mediaStore.getState()), + ...muteButtonCreateRequestMethods(mediaStore.dispatch), + }; +}; + +export const getMuteButtonProps: PropsHook = (_element, state) => { + const baseProps: Record = { + /** 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', + tabindex: '0', + '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; +}; + export class MuteButton extends ButtonElement { - _state: - | { - muted: boolean; - volumeLevel: string; - requestMute: () => void; - requestUnmute: () => void; - } - | undefined; + _state: MuteButtonStateWithMethods | undefined; handleEvent(event: Event): void { super.handleEvent(event); @@ -34,55 +57,9 @@ export class MuteButton extends ButtonElement { } } } - - get muted(): boolean { - return this._state?.muted ?? false; - } - - get volumeLevel(): string { - return this._state?.volumeLevel ?? 'high'; - } - - _update(props: any, state: any): void { - this._state = state; - /** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */ - setAttributes(this, props); - } } -type MuteButtonStateWithMethods = Prettify>; - -export function getMuteButtonState(mediaStore: MediaStore): MuteButtonStateWithMethods { - return { - ...muteButtonStateDefinition.stateTransform(mediaStore.getState()), - ...muteButtonStateDefinition.createRequestMethods(mediaStore.dispatch), - }; -} - -export const getMuteButtonProps: PropsHook<{ - muted: boolean; - volumeLevel: string; -}> = (state, _element) => { - const baseProps: Record = { - /** 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', - tabindex: '0', - '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; -}; - -export const MuteButtonElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const MuteButtonElement: ConnectedComponentConstructor = toConnectedHTMLComponent( MuteButton, getMuteButtonState, getMuteButtonProps, diff --git a/packages/html/src/elements/play-button.ts b/packages/html/src/elements/play-button.ts index 77b40392..0219c954 100644 --- a/packages/html/src/elements/play-button.ts +++ b/packages/html/src/elements/play-button.ts @@ -1,55 +1,28 @@ -import type { MediaStore, PlayButtonState } from '@videojs/core/store'; +import type { PlayButtonState } from '@videojs/core/store'; import type { Prettify } from '../types'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { playButtonStateDefinition } from '@videojs/core/store'; - -import { setAttributes } from '@videojs/utils/dom'; +import { memoize } from '@videojs/utils'; import { toConnectedHTMLComponent } from '../utils/component-factory'; import { ButtonElement } from './button'; -export class PlayButton extends ButtonElement { - _state: { paused: boolean; requestPlay: () => void; requestPause: () => void } | undefined; - - handleEvent(event: Event): void { - super.handleEvent(event); - - const { type } = event; - const state = this._state; - if (state && type === 'click') { - if (state.paused) { - state.requestPlay(); - } else { - state.requestPause(); - } - } - } - - get paused(): boolean { - return this._state?.paused ?? true; - } - - _update(props: any, state: any, _mediaStore?: any): void { - this._state = state; - /** @TODO Follow up with React vs. W.C. data-* attributes discrepancies (CJP) */ - setAttributes(this, props); - } -} - type PlayButtonStateWithMethods = Prettify>; +const playButtonCreateRequestMethods = memoize(playButtonStateDefinition.createRequestMethods); + /** * PlayButton state hook - equivalent to React's usePlayButtonState * Handles media store state subscription and transformation */ -export function getPlayButtonState(mediaStore: MediaStore): PlayButtonStateWithMethods { +export const getPlayButtonState: StateHook = (_element, mediaStore) => { return { ...playButtonStateDefinition.stateTransform(mediaStore.getState()), - ...playButtonStateDefinition.createRequestMethods(mediaStore.dispatch), + ...playButtonCreateRequestMethods(mediaStore.dispatch), }; -} +}; -export const getPlayButtonProps: PropsHook<{ paused: boolean }> = (state, _element) => { +export const getPlayButtonProps: PropsHook = (_element, state) => { const baseProps: Record = { /** data attributes/props */ 'data-paused': state.paused, @@ -68,7 +41,25 @@ export const getPlayButtonProps: PropsHook<{ paused: boolean }> = (state, _eleme return baseProps; }; -export const PlayButtonElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export class PlayButton extends ButtonElement { + _state: PlayButtonStateWithMethods | undefined; + + handleEvent(event: Event): void { + super.handleEvent(event); + + const { type } = event; + const state = this._state; + if (state && type === 'click') { + if (state.paused) { + state.requestPlay(); + } else { + state.requestPause(); + } + } + } +} + +export const PlayButtonElement: ConnectedComponentConstructor = toConnectedHTMLComponent( PlayButton, getPlayButtonState, getPlayButtonProps, diff --git a/packages/html/src/elements/popover.ts b/packages/html/src/elements/popover.ts index 5be410ab..6ad0b845 100644 --- a/packages/html/src/elements/popover.ts +++ b/packages/html/src/elements/popover.ts @@ -1,82 +1,53 @@ -import { contains, getDocument, getDocumentOrShadowRoot, safePolygon } from '@videojs/utils/dom'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; +import type { Prettify } from '../types'; + +import { Popover as CorePopover } from '@videojs/core'; + +import { getDocumentOrShadowRoot } from '@videojs/utils/dom'; +import { getCoreState, getPropsFromAttrs, toConnectedHTMLComponent } from '../utils/component-factory'; type Placement = 'top' | 'top-start' | 'top-end'; -export class PopoverElement extends HTMLElement { +type PopoverState = Prettify>; + +export const getPopoverState: StateHook = (element, _mediaStore) => { + const coreState = getCoreState(CorePopover, getPropsFromAttrs(element)); + return { + ...coreState, + }; +}; + +export const getPopoverProps: PropsHook = (element, state) => { + if (state._popoverElement !== element) { + state._setPopoverElement(element); + } + + const triggerElement = getDocumentOrShadowRoot(element)?.querySelector(`[commandfor="${element.id}"]`) as HTMLElement | null; + if (state._triggerElement !== triggerElement) { + state._setTriggerElement(triggerElement); + } + + const [side, alignment] = element.side.split('-'); + + return { + 'data-starting-style': state._transitionStatus === 'initial', + 'data-open': state._transitionStatus === 'initial' || state._transitionStatus === 'open', + 'data-ending-style': state._transitionStatus === 'close' || state._transitionStatus === 'unmounted', + 'data-closed': state._transitionStatus === 'close' || state._transitionStatus === 'unmounted', + style: { + ...(element.id ? { 'position-anchor': `--${element.id}` } : {}), + top: `calc(anchor(${side}) - ${element.sideOffset}px)`, + translate: '0 -100%', + 'justify-self': alignment === 'start' ? 'anchor-start' : alignment === 'end' ? 'anchor-end' : 'anchor-center', + }, + }; +}; + +export class Popover extends HTMLElement { static get observedAttributes(): string[] { return ['id', 'open-on-hover', 'delay', 'close-delay', 'side', 'side-offset']; } - #open = false; - #transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial'; - #hoverTimeout: ReturnType | null = null; - #cleanup: (() => void) | null = null; - #abortController: AbortController | null = null; - - attributeChangedCallback(name: string, _oldValue: string, newValue: string): void { - if (name === 'id') { - this.style.setProperty('position-anchor', `--${newValue}`); - } - - const [side, alignment] = this.side.split('-'); - this.style.setProperty('top', `calc(anchor(${side}) - ${this.sideOffset}px)`); - this.style.setProperty('translate', `0 -100%`); - this.style.setProperty('justify-self', alignment === 'start' - ? 'anchor-start' - : alignment === 'end' - ? 'anchor-end' - : 'anchor-center'); - } - - connectedCallback(): void { - this.#abortController ??= new AbortController(); - const { signal } = this.#abortController; - - const trigger = this.#triggerElement as HTMLElement; - if (trigger) { - if (globalThis.matchMedia?.('(hover: hover)')?.matches) { - trigger.addEventListener('pointerenter', this, { signal }); - trigger.addEventListener('pointerleave', this, { signal }); - } - - trigger.addEventListener('focusin', this, { signal }); - trigger.addEventListener('focusout', this, { signal }); - } - - this.addEventListener('pointerenter', this, { signal }); - this.addEventListener('focusout', this, { signal }); - } - - disconnectedCallback(): void { - this.#clearHoverTimeout(); - this.#cleanup?.(); - - this.#abortController?.abort(); - this.#abortController = null; - } - - handleEvent(event: Event): void { - switch (event.type) { - case 'pointerenter': - this.#handlePointerEnter(event as PointerEvent); - break; - case 'pointerleave': - this.#handlePointerLeave(event as PointerEvent); - break; - case 'pointermove': - this.#handlePointerMove(event as PointerEvent); - break; - case 'focusin': - this.#handleFocusIn(event as FocusEvent); - break; - case 'focusout': - this.#handleFocusOut(event as FocusEvent); - break; - default: - break; - } - } - get openOnHover(): boolean { return this.hasAttribute('open-on-hover'); } @@ -96,118 +67,11 @@ export class PopoverElement extends HTMLElement { get sideOffset(): number { return Number.parseInt(this.getAttribute('side-offset') ?? '0', 10); } - - get #triggerElement(): HTMLElement | null { - return getDocumentOrShadowRoot(this)?.querySelector(`[commandfor="${this.id}"]`) as HTMLElement | null; - } - - #setOpen(open: boolean): void { - if (this.#open === open) return; - - this.#open = open; - - if (open) { - this.#transitionStatus = 'initial'; - this.#updateVisibility(); - - this.showPopover(); - - requestAnimationFrame(() => { - this.#transitionStatus = 'open'; - this.#updateVisibility(); - }); - } else { - this.#transitionStatus = 'close'; - this.#updateVisibility(); - - const transitions = this.getAnimations().filter(anim => anim instanceof CSSTransition); - if (transitions.length > 0) { - Promise.all(transitions.map(t => t.finished)) - .then(() => this.hidePopover()) - .catch(() => this.hidePopover()); - } else { - this.hidePopover(); - } - - this.#cleanup?.(); - this.#cleanup = null; - } - } - - #updateVisibility(): void { - this.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial'); - this.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open'); - this.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted'); - this.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted'); - } - - #clearHoverTimeout(): void { - if (this.#hoverTimeout) { - globalThis.clearTimeout(this.#hoverTimeout); - this.#hoverTimeout = null; - } - } - - #handlePointerEnter(event: PointerEvent): void { - if (!this.openOnHover) return; - - this.#clearHoverTimeout(); - - if (event.currentTarget === this) { - this.#addPointerMoveListener(); - } - - if (this.#open) { - return; - } - - this.#hoverTimeout = globalThis.setTimeout(() => { - this.#setOpen(true); - }, this.delay); - } - - #handlePointerLeave(_event: PointerEvent): void { - this.#addPointerMoveListener(); - } - - #addPointerMoveListener(): void { - if (!globalThis.matchMedia?.('(hover: hover)')?.matches) return; - - const { signal } = this.#abortController as AbortController; - getDocument(this).documentElement.addEventListener('pointermove', this, { signal }); - } - - #handlePointerMove(event: PointerEvent): void { - if (!this.openOnHover || !this.#triggerElement) return; - - const close = safePolygon({ blockPointerEvents: true })({ - placement: this.side, - elements: { - domReference: this.#triggerElement, - floating: this, - }, - x: event.clientX, - y: event.clientY, - onClose: () => { - getDocument(this).documentElement.removeEventListener('pointermove', this); - - this.#clearHoverTimeout(); - this.#hoverTimeout = globalThis.setTimeout(() => { - this.#setOpen(false); - }, this.closeDelay); - }, - }); - close(event); - } - - #handleFocusIn(_event: FocusEvent): void { - this.#setOpen(true); - } - - #handleFocusOut(event: FocusEvent): void { - const relatedTarget = event.relatedTarget as HTMLElement; - if (relatedTarget && contains(this, relatedTarget)) return; - - this.#setOpen(false); - }; } + +export const PopoverElement: ConnectedComponentConstructor = toConnectedHTMLComponent( + Popover, + getPopoverState, + getPopoverProps, + 'Popover', +); diff --git a/packages/html/src/elements/preview-time-display.ts b/packages/html/src/elements/preview-time-display.ts index 451bf26b..b589bd0e 100644 --- a/packages/html/src/elements/preview-time-display.ts +++ b/packages/html/src/elements/preview-time-display.ts @@ -1,11 +1,22 @@ -import type { MediaStore, PreviewTimeDisplayState } from '@videojs/core/store'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; +import type { PreviewTimeDisplayState } from '@videojs/core/store'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; import { previewTimeDisplayStateDefinition } from '@videojs/core/store'; import { formatDisplayTime } from '@videojs/utils'; import { toConnectedHTMLComponent } from '../utils/component-factory'; +export const getPreviewTimeDisplayState: StateHook = (_element, mediaStore) => { + return { + ...previewTimeDisplayStateDefinition.stateTransform(mediaStore.getState()), + // Preview time display is read-only, so no request methods needed + }; +}; + +export const getPreviewTimeDisplayProps: PropsHook = (_element, _state) => { + return {}; +}; + export class PreviewTimeDisplay extends HTMLElement { static shadowRootOptions = { mode: 'open' as ShadowRootMode, @@ -13,12 +24,6 @@ export class PreviewTimeDisplay extends HTMLElement { static observedAttributes: string[] = ['show-remaining']; - _state: - | { - previewTime: number | undefined; - } - | undefined; - constructor() { super(); @@ -27,24 +32,11 @@ export class PreviewTimeDisplay extends HTMLElement { } } - get previewTime(): number { - return this._state?.previewTime ?? 0; - } - get showRemaining(): boolean { return this.hasAttribute('show-remaining'); } - attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void { - if (name === 'show-remaining' && this._state) { - // Re-render with current state when show-remaining attribute changes - this._update({}, this._state); - } - } - - _update(_props: any, state: any): void { - this._state = state; - + _update(_props: any, state: PreviewTimeDisplayState): void { /** @TODO Should this live here or elsewhere? (CJP) */ const timeLabel = formatDisplayTime(state.previewTime); @@ -54,23 +46,7 @@ export class PreviewTimeDisplay extends HTMLElement { } } -export function getPreviewTimeDisplayState(mediaStore: MediaStore): { - previewTime: number | undefined; -} { - return { - ...previewTimeDisplayStateDefinition.stateTransform(mediaStore.getState()), - // Preview time display is read-only, so no request methods needed - }; -} - -export const getPreviewTimeDisplayProps: PropsHook<{ - previewTime: number | undefined; -}> = (_state, _element) => { - const baseProps: Record = {}; - return baseProps; -}; - -export const PreviewTimeDisplayElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const PreviewTimeDisplayElement: ConnectedComponentConstructor = toConnectedHTMLComponent( PreviewTimeDisplay, getPreviewTimeDisplayState, getPreviewTimeDisplayProps, diff --git a/packages/html/src/elements/time-slider.ts b/packages/html/src/elements/time-slider.ts index 9c357c85..34b92c9b 100644 --- a/packages/html/src/elements/time-slider.ts +++ b/packages/html/src/elements/time-slider.ts @@ -1,33 +1,39 @@ -import type { MediaStore } from '@videojs/core/store'; import type { Prettify } from '../types'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; import { TimeSlider as CoreTimeSlider } from '@videojs/core'; import { timeSliderStateDefinition } from '@videojs/core/store'; +import { memoize } from '@videojs/utils'; -import { setAttributes } from '@videojs/utils/dom'; -import { getCoreState, toConnectedHTMLComponent } from '../utils/component-factory'; - -type TimeSliderState = Prettify>; +import { getCoreState, getPropsFromAttrs, toConnectedHTMLComponent } from '../utils/component-factory'; // ============================================================================ // ROOT COMPONENT // ============================================================================ -export function getTimeSliderRootState(mediaStore: MediaStore): TimeSliderState { +type TimeSliderState = Prettify>; +type TimeSliderStateWithMethods = Prettify>; + +const timeSliderCreateRequestMethods = memoize(timeSliderStateDefinition.createRequestMethods); + +export const getTimeSliderRootState: StateHook = (element, mediaStore) => { const mediaState = timeSliderStateDefinition.stateTransform(mediaStore.getState()); - const mediaMethods = timeSliderStateDefinition.createRequestMethods(mediaStore.dispatch); - const coreState = getCoreState(CoreTimeSlider, { ...mediaState, ...mediaMethods }); + const mediaMethods = timeSliderCreateRequestMethods(mediaStore.dispatch); + const coreState = getCoreState(CoreTimeSlider, { + ...getPropsFromAttrs(element), + ...mediaState, + ...mediaMethods, + }); return { ...coreState, }; -} +}; /** * TimeSlider Root props hook - equivalent to React's useTimeSliderRootProps * Handles element attributes and properties based on state */ -export const getTimeSliderRootProps: PropsHook = (state, element) => { +export const getTimeSliderRootProps: PropsHook = (element, state) => { if (state._rootElement !== element) { state._setRootElement(element); } @@ -37,93 +43,63 @@ export const getTimeSliderRootProps: PropsHook = (state, elemen tabindex: element.getAttribute('tabindex') ?? '0', 'data-current-time': state.currentTime.toString(), 'data-duration': state.duration.toString(), - 'data-orientation': (element as any).orientation || 'horizontal', + 'data-orientation': element.orientation || 'horizontal', 'aria-label': 'Seek', 'aria-valuemin': '0', 'aria-valuemax': Math.round(state.duration).toString(), 'aria-valuenow': Math.round(state.currentTime).toString(), 'aria-valuetext': `${state._currentTimeText} of ${state._durationText}`, - 'aria-orientation': (element as any).orientation || 'horizontal', + 'aria-orientation': element.orientation || 'horizontal', + style: { + ...(element.hasAttribute('commandfor') ? { 'anchor-name': `--${element.getAttribute('commandfor')}` } : {}), + '--slider-fill': `${state._fillWidth.toFixed(3)}%`, + '--slider-pointer': `${(state._pointerWidth * 100).toFixed(3)}%`, + }, }; }; export class TimeSliderRoot extends HTMLElement { - static readonly observedAttributes: readonly string[] = [ - 'commandfor', - 'orientation', - ]; + static readonly observedAttributes: readonly string[] = ['commandfor', 'orientation']; _state: TimeSliderState | undefined; - get currentTime(): number { - return this._state?.currentTime ?? 0; - } - - get duration(): number { - return this._state?.duration ?? 0; - } - get orientation(): 'horizontal' | 'vertical' { return (this.getAttribute('orientation') as 'horizontal' | 'vertical') || 'horizontal'; } - - attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { - if (name === 'orientation' && this._state) { - this._update(getTimeSliderRootProps(this._state, this), this._state); - } else if (name === 'commandfor') { - this.style.setProperty('anchor-name', `--${newValue}`); - } - } - - _update(props: any, state: TimeSliderState): void { - this._state = state; - - this.style.setProperty('--slider-fill', `${state._fillWidth.toFixed(3)}%`); - this.style.setProperty('--slider-pointer', `${(state._pointerWidth * 100).toFixed(3)}%`); - - setAttributes(this, props); - } } -export const TimeSliderRootElement: ConnectedComponentConstructor - = toConnectedHTMLComponent( - TimeSliderRoot, - getTimeSliderRootState, - getTimeSliderRootProps, - 'TimeSliderRoot', - ); +export const TimeSliderRootElement: ConnectedComponentConstructor = toConnectedHTMLComponent( + TimeSliderRoot, + getTimeSliderRootState, + getTimeSliderRootProps, + 'TimeSliderRoot', +); // ============================================================================ // TRACK COMPONENT // ============================================================================ -export const getTimeSliderTrackProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-time-slider') as any; +export const getTimeSliderTrackProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-time-slider') as TimeSliderRoot; if (rootElement._state?._trackElement !== element) { rootElement._state?._setTrackElement?.(element); } + const orientation = rootElement?.orientation || 'horizontal'; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style: { + width: orientation === 'horizontal' ? '100%' : undefined, + height: orientation !== 'horizontal' ? '100%' : undefined, + }, }; }; -export class TimeSliderTrack extends HTMLElement { - _update(props: any, _state: any): void { - setAttributes(this, props); +export class TimeSliderTrack extends HTMLElement {} - if (props['data-orientation'] === 'horizontal') { - this.style.width = '100%'; - this.style.removeProperty('height'); - } else { - this.style.height = '100%'; - this.style.removeProperty('width'); - } - } -} - -export const TimeSliderTrackElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const TimeSliderTrackElement: ConnectedComponentConstructor = toConnectedHTMLComponent( TimeSliderTrack, undefined, getTimeSliderTrackProps, @@ -134,39 +110,34 @@ export const TimeSliderTrackElement: ConnectedComponentConstructor = toConn // PROGRESS COMPONENT // ============================================================================ -export const getTimeSliderProgressProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-time-slider') as any; +export const getTimeSliderProgressProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-time-slider') as TimeSliderRoot; + const orientation = rootElement?.orientation || 'horizontal'; + const style = orientation === 'horizontal' + ? { + position: 'absolute', + width: 'var(--slider-fill, 0%)', + height: '100%', + top: '0', + bottom: undefined, + } + : { + position: 'absolute', + height: 'var(--slider-fill, 0%)', + width: '100%', + bottom: '0', + top: undefined, + }; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style, }; }; -export class TimeSliderProgress extends HTMLElement { - constructor() { - super(); - this.style.position = 'absolute'; - this.style.width = 'var(--slider-fill, 0%)'; - this.style.height = '100%'; - } +export class TimeSliderProgress extends HTMLElement {} - _update(props: any, _state: any): void { - setAttributes(this, props); - - if (props['data-orientation'] === 'horizontal') { - this.style.width = 'var(--slider-fill, 0%)'; - this.style.height = '100%'; - this.style.top = '0'; - this.style.removeProperty('bottom'); - } else { - this.style.height = 'var(--slider-fill, 0%)'; - this.style.width = '100%'; - this.style.bottom = '0'; - this.style.removeProperty('top'); - } - } -} - -export const TimeSliderProgressElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const TimeSliderProgressElement: ConnectedComponentConstructor = toConnectedHTMLComponent( TimeSliderProgress, undefined, getTimeSliderProgressProps, @@ -177,39 +148,34 @@ export const TimeSliderProgressElement: ConnectedComponentConstructor = toC // POINTER COMPONENT // ============================================================================ -export const getTimeSliderPointerProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-time-slider') as any; +export const getTimeSliderPointerProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-time-slider') as TimeSliderRoot; + const orientation = rootElement?.orientation || 'horizontal'; + const style = orientation === 'horizontal' + ? { + position: 'absolute', + width: 'var(--slider-pointer, 0%)', + height: '100%', + top: '0', + bottom: undefined, + } + : { + position: 'absolute', + height: 'var(--slider-pointer, 0%)', + width: '100%', + bottom: '0', + top: undefined, + }; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style, }; }; -export class TimeSliderPointer extends HTMLElement { - constructor() { - super(); - this.style.position = 'absolute'; - this.style.width = 'var(--slider-pointer, 0%)'; - this.style.height = '100%'; - } +export class TimeSliderPointer extends HTMLElement {} - _update(props: any, _state: any): void { - setAttributes(this, props); - - if (props['data-orientation'] === 'horizontal') { - this.style.width = 'var(--slider-pointer, 0%)'; - this.style.height = '100%'; - this.style.top = '0'; - this.style.removeProperty('bottom'); - } else { - this.style.height = 'var(--slider-pointer, 0%)'; - this.style.width = '100%'; - this.style.bottom = '0'; - this.style.removeProperty('top'); - } - } -} - -export const TimeSliderPointerElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const TimeSliderPointerElement: ConnectedComponentConstructor = toConnectedHTMLComponent( TimeSliderPointer, undefined, getTimeSliderPointerProps, @@ -220,36 +186,34 @@ export const TimeSliderPointerElement: ConnectedComponentConstructor = toCo // THUMB COMPONENT // ============================================================================ -export const getTimeSliderThumbProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-time-slider') as any; +export const getTimeSliderThumbProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-time-slider') as TimeSliderRoot; + const orientation = rootElement?.orientation || 'horizontal'; + const style = orientation === 'horizontal' + ? { + position: 'absolute', + left: 'var(--slider-fill, 0%)', + top: '50%', + bottom: undefined, + translate: '-50% -50%', + } + : { + position: 'absolute', + bottom: 'var(--slider-fill, 0%)', + left: '50%', + top: undefined, + translate: '-50% 50%', + }; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style, }; }; -export class TimeSliderThumb extends HTMLElement { - constructor() { - super(); - this.style.position = 'absolute'; - } +export class TimeSliderThumb extends HTMLElement {} - _update(props: any, _state: any): void { - setAttributes(this, props); - - // Set appropriate positioning based on orientation - if (props['data-orientation'] === 'horizontal') { - this.style.left = 'var(--slider-fill, 0%)'; - this.style.top = '50%'; - this.style.translate = '-50% -50%'; - } else { - this.style.bottom = 'var(--slider-fill, 0%)'; - this.style.left = '50%'; - this.style.translate = '-50% 50%'; - } - } -} - -export const TimeSliderThumbElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const TimeSliderThumbElement: ConnectedComponentConstructor = toConnectedHTMLComponent( TimeSliderThumb, undefined, getTimeSliderThumbProps, diff --git a/packages/html/src/elements/volume-slider.ts b/packages/html/src/elements/volume-slider.ts index d26e5f65..6264cf1d 100644 --- a/packages/html/src/elements/volume-slider.ts +++ b/packages/html/src/elements/volume-slider.ts @@ -1,33 +1,39 @@ -import type { MediaStore } from '@videojs/core/store'; import type { Prettify } from '../types'; +import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory'; -import type { ConnectedComponentConstructor, PropsHook } from '../utils/component-factory'; import { VolumeSlider as CoreVolumeSlider } from '@videojs/core'; import { volumeSliderStateDefinition } from '@videojs/core/store'; +import { memoize } from '@videojs/utils'; -import { setAttributes } from '@videojs/utils/dom'; -import { getCoreState, toConnectedHTMLComponent } from '../utils/component-factory'; - -type VolumeSliderState = Prettify>; +import { getCoreState, getPropsFromAttrs, toConnectedHTMLComponent } from '../utils/component-factory'; // ============================================================================ // ROOT COMPONENT // ============================================================================ -export function getVolumeSliderRootState(mediaStore: MediaStore): VolumeSliderState { +type VolumeSliderState = Prettify>; +type VolumeSliderStateWithMethods = Prettify>; + +const volumeSliderCreateRequestMethods = memoize(volumeSliderStateDefinition.createRequestMethods); + +export const getVolumeSliderRootState: StateHook = (element, mediaStore) => { const mediaState = volumeSliderStateDefinition.stateTransform(mediaStore.getState()); - const mediaMethods = volumeSliderStateDefinition.createRequestMethods(mediaStore.dispatch); - const coreState = getCoreState(CoreVolumeSlider, { ...mediaState, ...mediaMethods }); + const mediaMethods = volumeSliderCreateRequestMethods(mediaStore.dispatch); + const coreState = getCoreState(CoreVolumeSlider, { + ...getPropsFromAttrs(element), + ...mediaState, + ...mediaMethods, + }); return { ...coreState, }; -} +}; /** * VolumeSlider Root props hook - equivalent to React's useVolumeSliderRootProps * Handles element attributes and properties based on state */ -export const getVolumeSliderRootProps: PropsHook = (state, element) => { +export const getVolumeSliderRootProps: PropsHook = (element, state) => { if (state._rootElement !== element) { state._setRootElement(element); } @@ -39,96 +45,62 @@ export const getVolumeSliderRootProps: PropsHook = (state, el tabindex: element.getAttribute('tabindex') ?? '0', 'data-muted': state.muted.toString(), 'data-volume-level': state.volumeLevel, - 'data-orientation': (element as any).orientation || 'horizontal', + 'data-orientation': element.orientation || 'horizontal', 'aria-label': 'Volume', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': Math.round(state.muted ? 0 : state.volume * 100).toString(), 'aria-valuetext': volumeText, - 'aria-orientation': (element as any).orientation || 'horizontal', + 'aria-orientation': element.orientation || 'horizontal', + style: { + ...(element.hasAttribute('commandfor') ? { 'anchor-name': `--${element.getAttribute('commandfor')}` } : {}), + '--slider-fill': `${state._fillWidth.toFixed(3)}%`, + }, }; }; export class VolumeSliderRoot extends HTMLElement { - static readonly observedAttributes: readonly string[] = [ - 'commandfor', - 'orientation', - ]; + static readonly observedAttributes: readonly string[] = ['commandfor', 'orientation']; _state: VolumeSliderState | undefined; - get volume(): number { - return this._state?.volume ?? 0; - } - - get muted(): boolean { - return this._state?.muted ?? false; - } - - get volumeLevel(): string { - return this._state?.volumeLevel ?? 'high'; - } - get orientation(): 'horizontal' | 'vertical' { return (this.getAttribute('orientation') as 'horizontal' | 'vertical') || 'horizontal'; } - - attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { - if (name === 'orientation' && this._state) { - this._update(getVolumeSliderRootProps(this._state, this), this._state); - } else if (name === 'commandfor') { - this.style.setProperty('anchor-name', `--${newValue}`); - } - } - - _update(props: any, state: VolumeSliderState): void { - this._state = state; - - this.style.setProperty('--slider-fill', `${state._fillWidth.toFixed(3)}%`); - - setAttributes(this, props); - } } -export const VolumeSliderRootElement: ConnectedComponentConstructor - = toConnectedHTMLComponent( - VolumeSliderRoot, - getVolumeSliderRootState, - getVolumeSliderRootProps, - 'VolumeSliderRoot', - ); +export const VolumeSliderRootElement: ConnectedComponentConstructor = toConnectedHTMLComponent( + VolumeSliderRoot, + getVolumeSliderRootState, + getVolumeSliderRootProps, + 'VolumeSliderRoot', +); // ============================================================================ // TRACK COMPONENT // ============================================================================ -export const getVolumeSliderTrackProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-volume-slider') as any; +export const getVolumeSliderTrackProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-volume-slider') as VolumeSliderRoot; if (rootElement._state?._trackElement !== element) { rootElement._state?._setTrackElement?.(element); } + const orientation = rootElement?.orientation || 'horizontal'; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style: { + width: orientation === 'horizontal' ? '100%' : undefined, + height: orientation !== 'horizontal' ? '100%' : undefined, + }, }; }; -export class VolumeSliderTrack extends HTMLElement { - _update(props: any, _state: any): void { - setAttributes(this, props); +export class VolumeSliderTrack extends HTMLElement {} - if (props['data-orientation'] === 'horizontal') { - this.style.width = '100%'; - this.style.removeProperty('height'); - } else { - this.style.height = '100%'; - this.style.removeProperty('width'); - } - } -} - -export const VolumeSliderTrackElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const VolumeSliderTrackElement: ConnectedComponentConstructor = toConnectedHTMLComponent( VolumeSliderTrack, undefined, getVolumeSliderTrackProps, @@ -139,39 +111,34 @@ export const VolumeSliderTrackElement: ConnectedComponentConstructor = toCo // INDICATOR COMPONENT // ============================================================================ -export const getVolumeSliderIndicatorProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-volume-slider') as any; +export const getVolumeSliderIndicatorProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-volume-slider') as VolumeSliderRoot; + const orientation = rootElement?.orientation || 'horizontal'; + const style = orientation === 'horizontal' + ? { + position: 'absolute', + width: 'var(--slider-fill, 0%)', + height: '100%', + top: '0', + bottom: undefined, + } + : { + position: 'absolute', + height: 'var(--slider-fill, 0%)', + width: '100%', + bottom: '0', + top: undefined, + }; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style, }; }; -export class VolumeSliderIndicator extends HTMLElement { - constructor() { - super(); - this.style.position = 'absolute'; - this.style.width = 'var(--slider-fill, 0%)'; - this.style.height = '100%'; - } +export class VolumeSliderIndicator extends HTMLElement {} - _update(props: any, _state: any): void { - setAttributes(this, props); - - if (props['data-orientation'] === 'horizontal') { - this.style.width = 'var(--slider-fill, 0%)'; - this.style.height = '100%'; - this.style.top = '0'; - this.style.removeProperty('bottom'); - } else { - this.style.height = 'var(--slider-fill, 0%)'; - this.style.width = '100%'; - this.style.bottom = '0'; - this.style.removeProperty('top'); - } - } -} - -export const VolumeSliderIndicatorElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const VolumeSliderIndicatorElement: ConnectedComponentConstructor = toConnectedHTMLComponent( VolumeSliderIndicator, undefined, getVolumeSliderIndicatorProps, @@ -182,36 +149,34 @@ export const VolumeSliderIndicatorElement: ConnectedComponentConstructor = // THUMB COMPONENT // ============================================================================ -export const getVolumeSliderThumbProps: PropsHook> = (_state, element) => { - const rootElement = element.closest('media-volume-slider') as any; +export const getVolumeSliderThumbProps: PropsHook = (element, _state) => { + const rootElement = element.closest('media-volume-slider') as VolumeSliderRoot; + const orientation = rootElement?.orientation || 'horizontal'; + const style = orientation === 'horizontal' + ? { + position: 'absolute', + left: 'var(--slider-fill, 0%)', + top: '50%', + bottom: undefined, + translate: '-50% -50%', + } + : { + position: 'absolute', + bottom: 'var(--slider-fill, 0%)', + left: '50%', + top: undefined, + translate: '-50% 50%', + }; + return { - 'data-orientation': rootElement?.orientation || 'horizontal', + 'data-orientation': orientation, + style, }; }; -export class VolumeSliderThumb extends HTMLElement { - constructor() { - super(); - this.style.position = 'absolute'; - } +export class VolumeSliderThumb extends HTMLElement {} - _update(props: any, _state: any): void { - setAttributes(this, props); - - // Set appropriate positioning based on orientation - if (props['data-orientation'] === 'horizontal') { - this.style.left = 'var(--slider-fill, 0%)'; - this.style.top = '50%'; - this.style.translate = '-50% -50%'; - } else { - this.style.bottom = 'var(--slider-fill, 0%)'; - this.style.left = '50%'; - this.style.translate = '-50% 50%'; - } - } -} - -export const VolumeSliderThumbElement: ConnectedComponentConstructor = toConnectedHTMLComponent( +export const VolumeSliderThumbElement: ConnectedComponentConstructor = toConnectedHTMLComponent( VolumeSliderThumb, undefined, getVolumeSliderThumbProps, diff --git a/packages/html/src/utils/component-factory.ts b/packages/html/src/utils/component-factory.ts index 5343fdd6..adad9dea 100644 --- a/packages/html/src/utils/component-factory.ts +++ b/packages/html/src/utils/component-factory.ts @@ -1,16 +1,18 @@ import type { MediaStore } from '@videojs/core/store'; import { ConsumerMixin } from '@open-wc/context-protocol'; +import { shallowEqual, toCamelCase } from '@videojs/utils'; +import { setAttributes } from '@videojs/utils/dom'; /** * Generic types for HTML component hooks pattern * Mirrors the React hooks architecture for consistency */ -export type StateHook = (mediaStore: MediaStore) => T; +export type StateHook = (element: E, mediaStore: MediaStore) => T; -export type PropsHook = (state: T, element: HTMLElement) => P; +export type PropsHook = (element: E, state: T) => P; -export interface ConnectedComponentConstructor { - new (state: State): HTMLElement; +export interface ConnectedComponentConstructor { + new (state: State): E; } let currentCoreInstances: any[] = []; @@ -25,16 +27,15 @@ let currentCoreIndex: number = 0; * @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 function toConnectedHTMLComponent( +export function toConnectedHTMLComponent( BaseClass: CustomElementConstructor, - stateHook: StateHook | undefined, - propsHook: PropsHook, + stateHook: StateHook | undefined, + propsHook: PropsHook, displayName?: string, -): ConnectedComponentConstructor { +): ConnectedComponentConstructor { const ConnectedComponent = class extends ConsumerMixin(BaseClass) { static get observedAttributes(): string[] { return [ @@ -43,42 +44,52 @@ export function toConnectedHTMLComponent( ]; } - _mediaStore: any; - _coreInstances = []; + _state: State | undefined; + #mediaStore: MediaStore | undefined; + #coreInstances: { core: any; listening: boolean }[] = []; contexts = { mediaStore: (mediaStore: any) => { - this._mediaStore = mediaStore; + this.#mediaStore = mediaStore; // Subscribe to media store state changes - // Split into two phases: state transformation, then props update - this._mediaStore.subscribe(() => { - currentCoreIndex = 0; - currentCoreInstances = this._coreInstances; + mediaStore.subscribe(() => { + this.#render(); - // Phase 1: Transform raw media store state (state concern) - const state = stateHook?.(mediaStore) ?? mediaStore.getState(); - // Phase 2: Update element attributes/properties (props concern) - const props = propsHook(state ?? {} as State, this); - - // @ts-expect-error any - this._update(props, state, mediaStore); - - for (const instance of currentCoreInstances) { + for (const instance of this.#coreInstances) { if (!instance.listening) { instance.listening = true; - instance.core.subscribe(() => { - const state = instance.core.getState(); - const props = propsHook(state ?? {} as State, this); - // @ts-expect-error any - this._update(props, state, mediaStore); - }); + instance.core.subscribe(this.#render); } } }); }, }; + #render = (): void => { + if (!this.#mediaStore) return; + + currentCoreIndex = 0; + currentCoreInstances = this.#coreInstances; + + // Split into two phases: state transformation, then props update + const state = stateHook?.(this as unknown as E, this.#mediaStore); + const props = propsHook(this as unknown as E, state ?? {} as State); + this._update(props, state, this.#mediaStore); + }; + + _update(props: any, state: State | undefined, _mediaStore: MediaStore): void { + this._state = state; + // @ts-expect-error any + super._update?.(props, state, _mediaStore); + setAttributes(this, props); + } + + attributeChangedCallback(name: string, oldValue: string, newValue: string): void { + super.attributeChangedCallback?.(name, oldValue, newValue); + this.#render(); + } + connectedCallback(): void { super.connectedCallback?.(); } @@ -98,7 +109,7 @@ export function toConnectedHTMLComponent( Object.defineProperty(ConnectedComponent, 'name', { value: displayName }); } - return ConnectedComponent; + return ConnectedComponent as unknown as ConnectedComponentConstructor; } export function getCoreState = {}; + for (const key in state) { + oldState[key] = coreState[key]; + } + // Only set the state if it has changed + if (!shallowEqual(oldState, state)) { + core.setState(state); + } + return core.getState(); } + +export function getPropsFromAttrs(element: HTMLElement): Record { + const props: Record = {}; + for (const attr of element.attributes) { + const camelName = toCamelCase(attr.name); + props[camelName] = element[camelName as keyof typeof element]; + } + return props; +} diff --git a/packages/utils/src/dom/attributes.ts b/packages/utils/src/dom/attributes.ts index 9091ef5d..08cfc2e3 100644 --- a/packages/utils/src/dom/attributes.ts +++ b/packages/utils/src/dom/attributes.ts @@ -17,12 +17,24 @@ export function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record): void { +export function setAttributes(element: HTMLElement, attributes: Record): void { for (const [key, value] of Object.entries(attributes)) { - if (typeof value === 'boolean') { - element.toggleAttribute(key, value); + if (key === 'style' && typeof value === 'object') { + for (const [styleKey, styleValue] of Object.entries(value)) { + if (typeof styleValue === 'string') { + element.style.setProperty(styleKey, styleValue); + } else if (styleValue == null) { + element.style.removeProperty(styleKey); + } + } } else { - element.setAttribute(key, value); + if (typeof value === 'boolean') { + element.toggleAttribute(key, value); + } else if (value === undefined) { + element.removeAttribute(key); + } else { + element.setAttribute(key, value); + } } } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2401887b..aff03929 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,7 @@ export * from './shared/console'; export * from './shared/crypto'; +export * from './shared/memoize'; export * from './shared/state'; +export * from './shared/string'; export * from './shared/time'; export * from './shared/unit'; diff --git a/packages/utils/src/shared/memoize.ts b/packages/utils/src/shared/memoize.ts new file mode 100644 index 00000000..135447f5 --- /dev/null +++ b/packages/utils/src/shared/memoize.ts @@ -0,0 +1,71 @@ +// Adapted from https://github.com/caiogondim/fast-memoize.js - MIT License +type MemoizedFunction any> = ( + ...args: Parameters +) => ReturnType; + +interface MemoizedId { + $m: number; +} + +interface MemoizedWithId { + $m?: number; +} + +function isNode(x: unknown): boolean { + if (typeof globalThis === 'undefined') return false; + const NodeConstructor = (globalThis as { Node?: new () => unknown }).Node; + return NodeConstructor !== undefined && x instanceof NodeConstructor; +} + +export function memoize any>( + func: T, +): MemoizedFunction { + const cache: Record> = {}; + return function (this: unknown, ...args: Parameters): ReturnType { + const argsWithFuncIds = args.map((x) => { + if (isPlainObject(x) || Array.isArray(x)) { + const obj: Record = {}; + for (const key in x) { + obj[key] = memoizedIdFunc((x as Record)[key]); + } + return obj; + } + return memoizedIdFunc(x); + }); + + const cacheKey = JSON.stringify(argsWithFuncIds); + const cachedValue = cache[cacheKey]; + if (cachedValue !== undefined) { + return cachedValue; + } + const computedValue = func.apply(this, args); + cache[cacheKey] = computedValue; + return computedValue; + }; +} + +let id = 0; +function memoizedIdFunc(x: unknown): unknown { + if (typeof x === 'function' || isNode(x)) { + const funcOrNode = x as MemoizedWithId; + if (!funcOrNode.$m) funcOrNode.$m = ++id; + return { $m: funcOrNode.$m } as MemoizedId; + } + return x; +} + +/** + * Check if this is a plain object. + * @param {object} obj - The object to inspect. + * @return {boolean} + */ +function isPlainObject(obj: unknown): obj is Record { + if (typeof obj !== 'object' || obj === null) return false; + + let proto = obj; + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + + return Object.getPrototypeOf(obj) === proto; +} diff --git a/packages/utils/src/shared/string.ts b/packages/utils/src/shared/string.ts new file mode 100644 index 00000000..319df12d --- /dev/null +++ b/packages/utils/src/shared/string.ts @@ -0,0 +1,11 @@ +/** + * Converts a string to camel case. + * + * @param str - The string to convert. + * @returns The camel case string. + */ +export function toCamelCase(str: string): string { + return str + .toLowerCase() + .replace(/[-_]([a-z])/g, (_$0, $1) => $1.toUpperCase()); +}