From 7b798543042e53e3c0a718b54b6c69038bbc1fcd Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Wed, 1 Oct 2025 15:02:23 -0500 Subject: [PATCH] feat: add HTML vertical orientation to time and volume (#32) * feat: add vertical orientation to time and volume range components * fix: improve resetting of styles --- .../html/src/components/media-time-range.ts | 119 +++++++++++++++--- .../html/src/components/media-volume-range.ts | 95 +++++++++++--- .../html/html/src/skins/media-skin-default.ts | 60 ++++++++- 3 files changed, 234 insertions(+), 40 deletions(-) diff --git a/packages/html/html/src/components/media-time-range.ts b/packages/html/html/src/components/media-time-range.ts index 041f67e3..8b5f88b6 100644 --- a/packages/html/html/src/components/media-time-range.ts +++ b/packages/html/html/src/components/media-time-range.ts @@ -13,6 +13,8 @@ type TimeRangeRootState = { }; export class TimeRangeRootBase extends HTMLElement { + static readonly observedAttributes: readonly string[] = ['orientation']; + _state: TimeRangeRootState | undefined; _core: CoreTimeRange | null = null; @@ -24,6 +26,16 @@ export class TimeRangeRootBase extends HTMLElement { 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._render(useTimeRangeRootProps(this._state, this), this._state); + } + } + _update(_props: any, state: any): void { this._state = state; @@ -50,9 +62,11 @@ export class TimeRangeRootBase extends HTMLElement { this.setAttribute('aria-valuemax', '100'); this.setAttribute('aria-valuenow', coreState._fillWidth.toString()); this.setAttribute('aria-valuetext', props['aria-valuetext'] || ''); + this.setAttribute('aria-orientation', props['aria-orientation']); this.setAttribute('data-current-time', state.currentTime.toString()); this.setAttribute('data-duration', state.duration.toString()); + this.setAttribute('data-orientation', props['data-orientation']); } } @@ -69,8 +83,18 @@ export class TimeRangeTrackBase extends HTMLElement { } } - _update(_props: any, _state: any): void { - // Track doesn't need much state management + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate dimensions based on orientation + if (orientation === 'horizontal') { + this.style.width = '100%'; + this.style.removeProperty('height'); + } else { + this.style.height = '100%'; + this.style.removeProperty('width'); + } } } @@ -82,8 +106,22 @@ export class TimeRangeProgressBase extends HTMLElement { this.style.height = '100%'; } - _update(_props: any, _state: any): void { - // Progress updates are handled by CSS custom properties + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate dimensions based on orientation + if (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'); + } } } @@ -95,8 +133,22 @@ export class TimeRangePointerBase extends HTMLElement { this.style.height = '100%'; } - _update(_props: any, _state: any): void { - // Pointer updates are handled by CSS custom properties + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate dimensions based on orientation + if (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'); + } } } @@ -104,13 +156,22 @@ export class TimeRangeThumbBase extends HTMLElement { constructor() { super(); this.style.position = 'absolute'; - this.style.top = '50%'; - this.style.left = 'var(--slider-fill, 0%)'; - this.style.transform = 'translate(-50%, -50%)'; } - _update(_props: any, _state: any): void { - // Thumb updates are handled by CSS custom properties + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate positioning based on orientation + if (orientation === 'horizontal') { + this.style.left = 'var(--slider-fill, 0%)'; + this.style.top = '50%'; + this.style.transform = 'translate(-50%, -50%)'; + } else { + this.style.bottom = 'var(--slider-fill, 0%)'; + this.style.left = '50%'; + this.style.transform = 'translate(-50%, 50%)'; + } } } @@ -133,7 +194,7 @@ export const useTimeRangeRootProps: PropsHook<{ duration: number; requestSeek: (time: number) => void; core: CoreTimeRange | null; -}> = (state, _element) => { +}> = (state, element) => { const formatTime = (time: number) => { const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60); @@ -147,28 +208,46 @@ export const useTimeRangeRootProps: PropsHook<{ /** data attributes/props */ ['data-current-time']: state.currentTime.toString(), ['data-duration']: state.duration.toString(), + ['data-orientation']: (element as any).orientation || 'horizontal', /** aria attributes/props */ ['aria-label']: 'Seek', ['aria-valuetext']: `${currentTimeText} of ${durationText}`, + ['aria-orientation']: (element as any).orientation || 'horizontal', }; return baseProps; }; -export const useTimeRangeTrackProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useTimeRangeTrackProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-time-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; -export const useTimeRangeProgressProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useTimeRangeProgressProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-time-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; -export const useTimeRangePointerProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useTimeRangePointerProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-time-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; -export const useTimeRangeThumbProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useTimeRangeThumbProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-time-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; export const TimeRangeRoot: ConnectedComponentConstructor<{ diff --git a/packages/html/html/src/components/media-volume-range.ts b/packages/html/html/src/components/media-volume-range.ts index 2086cbad..d5341f93 100644 --- a/packages/html/html/src/components/media-volume-range.ts +++ b/packages/html/html/src/components/media-volume-range.ts @@ -17,6 +17,8 @@ type VolumeRangeRootState = { }; export class VolumeRangeRootBase extends HTMLElement { + static readonly observedAttributes: readonly string[] = ['orientation']; + _state: VolumeRangeRootState | undefined; _core: CoreVolumeRange | null = null; @@ -32,7 +34,17 @@ export class VolumeRangeRootBase extends HTMLElement { return this._state?.volumeLevel ?? 'high'; } - _update(_props: any, state: any): void { + 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._render(useVolumeRangeRootProps(this._state, this), this._state); + } + } + + _update(_props: any, state: any): void { this._state = state; if (state && !this._core) { @@ -58,9 +70,11 @@ export class VolumeRangeRootBase extends HTMLElement { this.setAttribute('aria-valuemax', '100'); this.setAttribute('aria-valuenow', coreState._fillWidth.toString()); this.setAttribute('aria-valuetext', props['aria-valuetext'] || ''); + this.setAttribute('aria-orientation', props['aria-orientation']); this.setAttribute('data-muted', state.muted.toString()); this.setAttribute('data-volume-level', state.volumeLevel); + this.setAttribute('data-orientation', props['data-orientation']); } } @@ -80,8 +94,18 @@ export class VolumeRangeTrackBase extends HTMLElement { } } - _update(_props: any, _state: any): void { - // Track doesn't need much state management + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate dimensions based on orientation + if (orientation === 'horizontal') { + this.style.width = '100%'; + this.style.removeProperty('height'); + } else { + this.style.height = '100%'; + this.style.removeProperty('width'); + } } } @@ -96,8 +120,22 @@ export class VolumeRangeProgressBase extends HTMLElement { this.style.height = '100%'; } - _update(_props: any, _state: any): void { - // Progress updates are handled by CSS custom properties + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate dimensions based on orientation + if (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'); + } } } @@ -108,13 +146,22 @@ export class VolumeRangeThumbBase extends HTMLElement { constructor() { super(); this.style.position = 'absolute'; - this.style.top = '50%'; - this.style.left = 'var(--slider-fill, 0%)'; - this.style.transform = 'translate(-50%, -50%)'; } - _update(_props: any, _state: any): void { - // Thumb updates are handled by CSS custom properties + _update(props: any, _state: any): void { + const orientation = props['data-orientation'] || 'horizontal'; + this.setAttribute('data-orientation', orientation); + + // Set appropriate positioning based on orientation + if (orientation === 'horizontal') { + this.style.left = 'var(--slider-fill, 0%)'; + this.style.top = '50%'; + this.style.transform = 'translate(-50%, -50%)'; + } else { + this.style.bottom = 'var(--slider-fill, 0%)'; + this.style.left = '50%'; + this.style.transform = 'translate(-50%, 50%)'; + } } } @@ -147,16 +194,18 @@ export const useVolumeRangeRootProps: PropsHook<{ volumeLevel: string; requestVolumeChange: (volume: number) => void; core: CoreVolumeRange | null; -}> = (state, _element) => { +}> = (state, element) => { const volumeText = `${Math.round(state.muted ? 0 : state.volume * 100)}%`; const baseProps: Record = { /** data attributes/props */ ['data-muted']: state.muted.toString(), ['data-volume-level']: state.volumeLevel, + ['data-orientation']: (element as any).orientation || 'horizontal', /** aria attributes/props */ ['aria-label']: 'Volume', ['aria-valuetext']: volumeText, + ['aria-orientation']: (element as any).orientation || 'horizontal', }; return baseProps; @@ -165,22 +214,34 @@ export const useVolumeRangeRootProps: PropsHook<{ /** * VolumeRange Track props hook */ -export const useVolumeRangeTrackProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useVolumeRangeTrackProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-volume-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; /** * VolumeRange Progress props hook */ -export const useVolumeRangeProgressProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useVolumeRangeProgressProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-volume-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; /** * VolumeRange Thumb props hook */ -export const useVolumeRangeThumbProps: PropsHook<{}> = (_state, _element) => { - return {}; +export const useVolumeRangeThumbProps: PropsHook<{}> = (_state, element) => { + // Get orientation from parent root element if not provided in state + const rootElement = element.closest('media-volume-range-root') as any; + return { + ['data-orientation']: rootElement?.orientation || 'horizontal', + }; }; /** diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 2711d70b..62c6e8a3 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -108,13 +108,28 @@ export function getTemplateHTML() { media-time-range-root { display: flex; align-items: center; + justify-content: center; position: relative; min-width: 100px; width: 100%; - padding-block: .75rem; margin: 0 .5rem; } + /* Horizontal orientation styles */ + media-time-range-root[data-orientation="horizontal"] { + min-width: 100px; + width: 100%; + height: 20px; + } + + /* Vertical orientation styles */ + media-time-range-root[data-orientation="vertical"] { + min-width: 20px; + width: 20px; + height: 100px; + flex-direction: column; + } + media-time-range-track { position: relative; width: 100%; @@ -125,6 +140,18 @@ export function getTemplateHTML() { pointer-events: none; } + /* Horizontal track styles */ + media-time-range-track[data-orientation="horizontal"] { + width: 100%; + height: .375rem; + } + + /* Vertical track styles */ + media-time-range-track[data-orientation="vertical"] { + width: .375rem; + height: 100%; + } + media-time-range-thumb { width: .75rem; height: .75rem; @@ -147,13 +174,28 @@ export function getTemplateHTML() { media-volume-range-root { display: flex; align-items: center; + justify-content: center; position: relative; min-width: 80px; width: 80px; - padding-block: .75rem; margin: 0 .5rem; } + /* Horizontal orientation styles */ + media-volume-range-root[data-orientation="horizontal"] { + min-width: 80px; + width: 80px; + height: 20px; + } + + /* Vertical orientation styles */ + media-volume-range-root[data-orientation="vertical"] { + min-width: 20px; + width: 20px; + height: 80px; + flex-direction: column; + } + media-volume-range-track { position: relative; width: 100%; @@ -164,6 +206,18 @@ export function getTemplateHTML() { pointer-events: none; } + /* Horizontal track styles */ + media-volume-range-track[data-orientation="horizontal"] { + width: 100%; + height: .375rem; + } + + /* Vertical track styles */ + media-volume-range-track[data-orientation="vertical"] { + width: .375rem; + height: 100%; + } + media-volume-range-thumb { width: .75rem; height: .75rem; @@ -202,7 +256,7 @@ export function getTemplateHTML() { - +