diff --git a/packages/html/src/define/ui/slider.ts b/packages/html/src/define/ui/slider.ts index d65ebe40..bdad7b75 100644 --- a/packages/html/src/define/ui/slider.ts +++ b/packages/html/src/define/ui/slider.ts @@ -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); diff --git a/packages/html/src/define/ui/time-slider.ts b/packages/html/src/define/ui/time-slider.ts index fda8f5ac..7136c12d 100644 --- a/packages/html/src/define/ui/time-slider.ts +++ b/packages/html/src/define/ui/time-slider.ts @@ -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); diff --git a/packages/html/src/define/ui/volume-slider.ts b/packages/html/src/define/ui/volume-slider.ts index a607a9f5..f87cbfc7 100644 --- a/packages/html/src/define/ui/volume-slider.ts +++ b/packages/html/src/define/ui/volume-slider.ts @@ -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); diff --git a/packages/html/src/ui/slider/slider-preview-element.ts b/packages/html/src/ui/slider/slider-preview-element.ts new file mode 100644 index 00000000..a3795fc4 --- /dev/null +++ b/packages/html/src/ui/slider/slider-preview-element.ts @@ -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(); + } +} diff --git a/packages/html/src/ui/slider/tests/slider-preview-element.test.ts b/packages/html/src/ui/slider/tests/slider-preview-element.test.ts new file mode 100644 index 00000000..ab030f70 --- /dev/null +++ b/packages/html/src/ui/slider/tests/slider-preview-element.test.ts @@ -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(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); + }); +});