feat(html): add slider preview element (#733)

This commit is contained in:
rahim
2026-03-04 19:47:55 -08:00
committed by GitHub
parent db7569711e
commit 7548a8e4a1
5 changed files with 182 additions and 0 deletions
+2
View File
@@ -1,5 +1,6 @@
import { SliderElement } from '../../ui/slider/slider-element';
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderPreviewElement } from '../../ui/slider/slider-preview-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { SliderValueElement } from '../../ui/slider/slider-value-element';
@@ -8,6 +9,7 @@ import { safeDefine } from '../safe-define';
// Parent slider first — sub-elements consume its context.
safeDefine(SliderElement);
safeDefine(SliderFillElement);
safeDefine(SliderPreviewElement);
safeDefine(SliderThumbElement);
safeDefine(SliderTrackElement);
safeDefine(SliderValueElement);
@@ -1,5 +1,6 @@
import { SliderBufferElement } from '../../ui/slider/slider-buffer-element';
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderPreviewElement } from '../../ui/slider/slider-preview-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderThumbnailElement } from '../../ui/slider/slider-thumbnail-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
@@ -11,6 +12,7 @@ import { safeDefine } from '../safe-define';
safeDefine(TimeSliderElement);
safeDefine(SliderBufferElement);
safeDefine(SliderFillElement);
safeDefine(SliderPreviewElement);
safeDefine(SliderThumbElement);
safeDefine(SliderThumbnailElement);
safeDefine(SliderTrackElement);
@@ -1,4 +1,5 @@
import { SliderFillElement } from '../../ui/slider/slider-fill-element';
import { SliderPreviewElement } from '../../ui/slider/slider-preview-element';
import { SliderThumbElement } from '../../ui/slider/slider-thumb-element';
import { SliderTrackElement } from '../../ui/slider/slider-track-element';
import { SliderValueElement } from '../../ui/slider/slider-value-element';
@@ -8,6 +9,7 @@ import { safeDefine } from '../safe-define';
// Parent slider first — sub-elements consume its context.
safeDefine(VolumeSliderElement);
safeDefine(SliderFillElement);
safeDefine(SliderPreviewElement);
safeDefine(SliderThumbElement);
safeDefine(SliderTrackElement);
safeDefine(SliderValueElement);
@@ -0,0 +1,56 @@
import type { SliderPreviewOverflow } from '@videojs/core/dom';
import { applyStateDataAttrs, getSliderPreviewStyle } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { applyStyles } from '@videojs/utils/dom';
import { MediaElement } from '../media-element';
import { sliderContext } from './context';
export class SliderPreviewElement extends MediaElement {
static readonly tagName = 'media-slider-preview';
static override properties = {
overflow: { type: String },
} satisfies PropertyDeclarationMap<'overflow'>;
overflow: SliderPreviewOverflow = 'clamp';
readonly #ctx = new ContextConsumer(this, {
context: sliderContext,
subscribe: true,
});
#resizeObserver: ResizeObserver | null = null;
#width = 0;
override connectedCallback(): void {
super.connectedCallback();
this.#resizeObserver = new ResizeObserver(([entry]) => {
this.#width = entry!.contentRect.width;
this.#applyPosition();
});
this.#resizeObserver.observe(this);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
#applyPosition(): void {
applyStyles(this, getSliderPreviewStyle(this.#width, this.overflow));
}
protected override update(_changed: PropertyValues): void {
super.update(_changed);
const ctx = this.#ctx.value;
if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
this.#applyPosition();
}
}
@@ -0,0 +1,120 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { SliderElement } from '../slider-element';
import { SliderPreviewElement } from '../slider-preview-element';
// jsdom doesn't provide ResizeObserver.
beforeAll(() => {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof globalThis.ResizeObserver;
});
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-slp');
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
return document.createElement(tag) as Element;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('SliderPreviewElement', () => {
it('has the correct tag name', () => {
expect(SliderPreviewElement.tagName).toBe('media-slider-preview');
});
it('defaults overflow to clamp', () => {
const el = createElement(SliderPreviewElement);
expect(el.overflow).toBe('clamp');
});
it('sets structural positioning styles after connect', async () => {
const slider = createElement(SliderElement);
const preview = createElement(SliderPreviewElement);
slider.appendChild(preview);
document.body.appendChild(slider);
await slider.updateComplete;
await preview.updateComplete;
expect(preview.style.position).toBe('absolute');
expect(preview.style.pointerEvents).toBe('none');
expect(preview.style.width).toBe('max-content');
});
it('applies clamped left style by default', async () => {
const slider = createElement(SliderElement);
const preview = createElement(SliderPreviewElement);
// jsdom rejects CSS min()/calc() — spy on setProperty to capture values.
const spy = vi.spyOn(preview.style, 'setProperty');
slider.appendChild(preview);
document.body.appendChild(slider);
await slider.updateComplete;
await preview.updateComplete;
const leftCall = spy.mock.calls.find(([key]) => key === 'left');
expect(leftCall).toBeTruthy();
expect(leftCall![1]).toContain('min(');
expect(leftCall![1]).toContain('max(');
});
it('applies unclamped left style when overflow is visible', async () => {
const slider = createElement(SliderElement);
const preview = createElement(SliderPreviewElement);
preview.overflow = 'visible';
const spy = vi.spyOn(preview.style, 'setProperty');
slider.appendChild(preview);
document.body.appendChild(slider);
await slider.updateComplete;
await preview.updateComplete;
const leftCall = spy.mock.calls.find(([key]) => key === 'left');
expect(leftCall).toBeTruthy();
expect(leftCall![1]).toContain('calc(var(--media-slider-pointer)');
expect(leftCall![1]).not.toContain('min(');
});
it('propagates data attributes from slider state', async () => {
const slider = createElement(SliderElement);
const preview = createElement(SliderPreviewElement);
slider.appendChild(preview);
document.body.appendChild(slider);
await slider.updateComplete;
await preview.updateComplete;
expect(preview.getAttribute('data-orientation')).toBe('horizontal');
});
it('cleans up ResizeObserver on disconnect', async () => {
const slider = createElement(SliderElement);
const preview = createElement(SliderPreviewElement);
slider.appendChild(preview);
document.body.appendChild(slider);
await slider.updateComplete;
await preview.updateComplete;
// Should not throw when removed.
slider.removeChild(preview);
});
});