mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add volume slider element (#657)
This commit is contained in:
@@ -11,6 +11,7 @@ export class SliderElement extends MediaElement {
|
||||
static readonly tagName = 'media-slider';
|
||||
|
||||
static override properties = {
|
||||
label: { type: String },
|
||||
value: { type: Number },
|
||||
min: { type: Number },
|
||||
max: { type: Number },
|
||||
@@ -21,6 +22,7 @@ export class SliderElement extends MediaElement {
|
||||
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
|
||||
} satisfies PropertyDeclarationMap<keyof SliderCore.Props>;
|
||||
|
||||
label = SliderCore.defaultProps.label;
|
||||
value = SliderCore.defaultProps.value;
|
||||
min = SliderCore.defaultProps.min;
|
||||
max = SliderCore.defaultProps.max;
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('SliderElement', () => {
|
||||
|
||||
it('initializes with default property values', () => {
|
||||
const slider = createElement(SliderElement);
|
||||
expect(slider.label).toBe('');
|
||||
expect(slider.value).toBe(0);
|
||||
expect(slider.min).toBe(0);
|
||||
expect(slider.max).toBe(100);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SliderThumbElement } from '../../slider/slider-thumb-element';
|
||||
import { VolumeSliderElement } from '../volume-slider-element';
|
||||
|
||||
let tagCounter = 0;
|
||||
|
||||
function uniqueTag(base: string): string {
|
||||
return `${base}-${tagCounter++}`;
|
||||
}
|
||||
|
||||
function createElement<Element extends HTMLElement>(Base: abstract new () => Element): Element {
|
||||
const tag = uniqueTag('test-el');
|
||||
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
|
||||
return document.createElement(tag) as Element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('VolumeSliderElement', () => {
|
||||
it('has the correct tag name', () => {
|
||||
expect(VolumeSliderElement.tagName).toBe('media-volume-slider');
|
||||
});
|
||||
|
||||
it('initializes with default property values', () => {
|
||||
const slider = createElement(VolumeSliderElement);
|
||||
expect(slider.label).toBe('Volume');
|
||||
expect(slider.step).toBe(1);
|
||||
expect(slider.largeStep).toBe(10);
|
||||
expect(slider.orientation).toBe('horizontal');
|
||||
expect(slider.disabled).toBe(false);
|
||||
expect(slider.thumbAlignment).toBe('center');
|
||||
});
|
||||
|
||||
it('sets touch-action and user-select styles on connect', async () => {
|
||||
const slider = createElement(VolumeSliderElement);
|
||||
|
||||
document.body.appendChild(slider);
|
||||
await slider.updateComplete;
|
||||
|
||||
expect(slider.style.touchAction).toBe('none');
|
||||
expect(slider.style.userSelect).toBe('none');
|
||||
});
|
||||
|
||||
it('supports vertical orientation', () => {
|
||||
const slider = createElement(VolumeSliderElement);
|
||||
slider.orientation = 'vertical';
|
||||
expect(slider.orientation).toBe('vertical');
|
||||
});
|
||||
|
||||
it('does not set CSS vars without player context', async () => {
|
||||
const slider = createElement(VolumeSliderElement);
|
||||
|
||||
document.body.appendChild(slider);
|
||||
await slider.updateComplete;
|
||||
|
||||
// Without player store providing volume state, the element guards early.
|
||||
expect(slider.style.getPropertyValue('--media-slider-fill')).toBe('');
|
||||
});
|
||||
|
||||
it('connects without errors when no store is available', async () => {
|
||||
const slider = createElement(VolumeSliderElement);
|
||||
const thumb = createElement(SliderThumbElement);
|
||||
|
||||
slider.appendChild(thumb);
|
||||
document.body.appendChild(slider);
|
||||
await slider.updateComplete;
|
||||
await thumb.updateComplete;
|
||||
|
||||
expect(slider.isConnected).toBe(true);
|
||||
expect(thumb.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up on disconnect', async () => {
|
||||
const slider = createElement(VolumeSliderElement);
|
||||
|
||||
document.body.appendChild(slider);
|
||||
await slider.updateComplete;
|
||||
|
||||
document.body.removeChild(slider);
|
||||
|
||||
// Verifies no errors during disconnect/cleanup.
|
||||
expect(slider.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { SliderDataAttrs, VolumeSliderCore } from '@videojs/core';
|
||||
import {
|
||||
applyStateDataAttrs,
|
||||
createSlider,
|
||||
getSliderCSSVars,
|
||||
logMissingFeature,
|
||||
type SliderHandle,
|
||||
selectVolume,
|
||||
} from '@videojs/core/dom';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { applyStyles, isRTL } from '@videojs/utils/dom';
|
||||
|
||||
import { playerContext } from '../../player/context';
|
||||
import { PlayerController } from '../../player/player-controller';
|
||||
import { MediaElement } from '../media-element';
|
||||
import { sliderContext } from '../slider/slider-context';
|
||||
|
||||
export class VolumeSliderElement extends MediaElement {
|
||||
static readonly tagName = 'media-volume-slider';
|
||||
|
||||
static override properties = {
|
||||
label: { type: String },
|
||||
step: { type: Number },
|
||||
largeStep: { type: Number, attribute: 'large-step' },
|
||||
orientation: { type: String },
|
||||
disabled: { type: Boolean },
|
||||
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
|
||||
} satisfies PropertyDeclarationMap<keyof VolumeSliderCore.Props>;
|
||||
|
||||
label = VolumeSliderCore.defaultProps.label;
|
||||
step = VolumeSliderCore.defaultProps.step;
|
||||
largeStep = VolumeSliderCore.defaultProps.largeStep;
|
||||
orientation = VolumeSliderCore.defaultProps.orientation;
|
||||
disabled = VolumeSliderCore.defaultProps.disabled;
|
||||
thumbAlignment = VolumeSliderCore.defaultProps.thumbAlignment;
|
||||
|
||||
readonly #core = new VolumeSliderCore();
|
||||
readonly #provider = new ContextProvider(this, { context: sliderContext });
|
||||
readonly #volumeState = new PlayerController(this, playerContext, selectVolume);
|
||||
|
||||
#slider: SliderHandle | null = null;
|
||||
#disconnect: AbortController | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#disconnect = new AbortController();
|
||||
const signal = this.#disconnect.signal;
|
||||
|
||||
this.#slider = createSlider({
|
||||
getElement: () => this,
|
||||
getThumbElement: () => this.querySelector<HTMLElement>('media-slider-thumb'),
|
||||
getOrientation: () => this.orientation,
|
||||
isRTL: () => isRTL(this),
|
||||
isDisabled: () => this.disabled || !this.#volumeState.value,
|
||||
getPercent: () => {
|
||||
const media = this.#volumeState.value;
|
||||
if (!media) return 0;
|
||||
return media.volume * 100;
|
||||
},
|
||||
getStepPercent: () => {
|
||||
const { step, min, max } = this.#core.props;
|
||||
const range = max - min;
|
||||
return range > 0 ? (step / range) * 100 : 0;
|
||||
},
|
||||
getLargeStepPercent: () => {
|
||||
const { largeStep, min, max } = this.#core.props;
|
||||
const range = max - min;
|
||||
return range > 0 ? (largeStep / range) * 100 : 0;
|
||||
},
|
||||
onValueChange: (percent) => {
|
||||
this.#setVolume(percent);
|
||||
},
|
||||
onValueCommit: (percent) => {
|
||||
this.#setVolume(percent);
|
||||
},
|
||||
onDragStart: () => {
|
||||
this.dispatchEvent(new CustomEvent('drag-start', { bubbles: true }));
|
||||
},
|
||||
onDragEnd: () => {
|
||||
this.dispatchEvent(new CustomEvent('drag-end', { bubbles: true }));
|
||||
},
|
||||
});
|
||||
|
||||
this.#slider.interaction.subscribe(() => this.requestUpdate(), { signal });
|
||||
|
||||
// Prevent default touch gestures and text selection during interaction.
|
||||
this.style.touchAction = 'none';
|
||||
this.style.userSelect = 'none';
|
||||
|
||||
if (__DEV__ && !this.#volumeState.value) {
|
||||
logMissingFeature(VolumeSliderElement.tagName, 'volume');
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#slider?.destroy();
|
||||
this.#slider = null;
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
}
|
||||
|
||||
protected override willUpdate(_changed: PropertyValues): void {
|
||||
super.willUpdate(_changed);
|
||||
this.#core.setProps(this);
|
||||
}
|
||||
|
||||
protected override update(_changed: PropertyValues): void {
|
||||
super.update(_changed);
|
||||
if (!this.#slider) return;
|
||||
|
||||
const media = this.#volumeState.value;
|
||||
if (!media) return;
|
||||
|
||||
const interaction = this.#slider.interaction.current;
|
||||
const state = this.#core.getVolumeState(media, interaction);
|
||||
const cssVars = getSliderCSSVars(state);
|
||||
|
||||
applyStyles(this, cssVars);
|
||||
|
||||
// Apply data attributes to root.
|
||||
applyStateDataAttrs(this, state, SliderDataAttrs);
|
||||
|
||||
// Provide context to child elements.
|
||||
this.#provider.setValue({
|
||||
state,
|
||||
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
|
||||
thumbAttrs: this.#core.getAttrs(state),
|
||||
thumbProps: this.#slider.thumbProps,
|
||||
formatValue: (value) => `${Math.round(value)}%`,
|
||||
});
|
||||
}
|
||||
|
||||
#setVolume(percent: number): void {
|
||||
const media = this.#volumeState.value;
|
||||
media?.changeVolume(this.#core.valueFromPercent(percent) / 100);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user