From 32ee304da4893f0c9271a25cf3fc81b84fccc399 Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 4 Mar 2026 15:35:27 -0800 Subject: [PATCH] feat(html): add slider thumbnail element (#714) --- .../html/src/define/ui/slider-thumbnail.ts | 10 ++ packages/html/src/define/ui/time-slider.ts | 2 + packages/html/src/index.ts | 1 + .../src/ui/slider/slider-thumbnail-element.ts | 21 +++++ .../tests/slider-thumbnail-element.test.ts | 94 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 packages/html/src/define/ui/slider-thumbnail.ts create mode 100644 packages/html/src/ui/slider/slider-thumbnail-element.ts create mode 100644 packages/html/src/ui/slider/tests/slider-thumbnail-element.test.ts diff --git a/packages/html/src/define/ui/slider-thumbnail.ts b/packages/html/src/define/ui/slider-thumbnail.ts new file mode 100644 index 00000000..ee32c72f --- /dev/null +++ b/packages/html/src/define/ui/slider-thumbnail.ts @@ -0,0 +1,10 @@ +import { SliderThumbnailElement } from '../../ui/slider/slider-thumbnail-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(SliderThumbnailElement); + +declare global { + interface HTMLElementTagNameMap { + [SliderThumbnailElement.tagName]: SliderThumbnailElement; + } +} diff --git a/packages/html/src/define/ui/time-slider.ts b/packages/html/src/define/ui/time-slider.ts index 25186875..fda8f5ac 100644 --- a/packages/html/src/define/ui/time-slider.ts +++ b/packages/html/src/define/ui/time-slider.ts @@ -1,6 +1,7 @@ import { SliderBufferElement } from '../../ui/slider/slider-buffer-element'; import { SliderFillElement } from '../../ui/slider/slider-fill-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'; import { SliderValueElement } from '../../ui/slider/slider-value-element'; import { TimeSliderElement } from '../../ui/time-slider/time-slider-element'; @@ -11,6 +12,7 @@ safeDefine(TimeSliderElement); safeDefine(SliderBufferElement); safeDefine(SliderFillElement); safeDefine(SliderThumbElement); +safeDefine(SliderThumbnailElement); safeDefine(SliderTrackElement); safeDefine(SliderValueElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 35da8c60..7e1e3dc5 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -35,6 +35,7 @@ export { SliderElement } from './ui/slider/slider-element'; export type { SliderEventMap, SliderValueEventDetail } from './ui/slider/slider-events'; export { SliderFillElement } from './ui/slider/slider-fill-element'; export { SliderThumbElement } from './ui/slider/slider-thumb-element'; +export { SliderThumbnailElement } from './ui/slider/slider-thumbnail-element'; export { SliderTrackElement } from './ui/slider/slider-track-element'; export { SliderValueElement } from './ui/slider/slider-value-element'; export { ThumbnailElement } from './ui/thumbnail/thumbnail-element'; diff --git a/packages/html/src/ui/slider/slider-thumbnail-element.ts b/packages/html/src/ui/slider/slider-thumbnail-element.ts new file mode 100644 index 00000000..75f0cf1c --- /dev/null +++ b/packages/html/src/ui/slider/slider-thumbnail-element.ts @@ -0,0 +1,21 @@ +import type { PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { ThumbnailElement } from '../thumbnail/thumbnail-element'; +import { sliderContext } from './context'; + +// @ts-expect-error TS2417 — tagName narrows to a different literal for custom element registration. +export class SliderThumbnailElement extends ThumbnailElement { + static override readonly tagName = 'media-slider-thumbnail'; + + readonly #ctx = new ContextConsumer(this, { + context: sliderContext, + subscribe: true, + }); + + protected override update(changed: PropertyValues): void { + const ctx = this.#ctx.value; + if (ctx) this.time = ctx.pointerValue; + super.update(changed); + } +} diff --git a/packages/html/src/ui/slider/tests/slider-thumbnail-element.test.ts b/packages/html/src/ui/slider/tests/slider-thumbnail-element.test.ts new file mode 100644 index 00000000..cccf96ef --- /dev/null +++ b/packages/html/src/ui/slider/tests/slider-thumbnail-element.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ThumbnailElement } from '../../thumbnail/thumbnail-element'; +import { SliderElement } from '../slider-element'; +import { SliderThumbnailElement } from '../slider-thumbnail-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(Base: abstract new () => Element): Element { + const tag = uniqueTag('test-slt'); + customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {}); + return document.createElement(tag) as Element; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('SliderThumbnailElement', () => { + it('has the correct tag name', () => { + expect(SliderThumbnailElement.tagName).toBe('media-slider-thumbnail'); + }); + + it('extends ThumbnailElement', () => { + const el = createElement(SliderThumbnailElement); + expect(el).toBeInstanceOf(ThumbnailElement); + }); + + it('has a shadow root with an img element', () => { + const el = createElement(SliderThumbnailElement); + expect(el.shadowRoot).toBeTruthy(); + + const img = el.shadowRoot!.querySelector('img'); + expect(img).toBeTruthy(); + expect(img!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('inherits time property from ThumbnailElement', () => { + const el = createElement(SliderThumbnailElement); + expect(el.time).toBe(0); + + el.time = 10; + expect(el.time).toBe(10); + }); + + it('sets data-hidden when no thumbnails are available', async () => { + const el = createElement(SliderThumbnailElement); + + document.body.appendChild(el); + await el.updateComplete; + + expect(el.hasAttribute('data-hidden')).toBe(true); + }); + + it('reads pointerValue from slider context as time', async () => { + const slider = createElement(SliderElement); + const thumbnail = createElement(SliderThumbnailElement); + + thumbnail.thumbnails = [ + { url: 'thumb-0.jpg', startTime: 0 }, + { url: 'thumb-30.jpg', startTime: 30 }, + { url: 'thumb-60.jpg', startTime: 60 }, + ]; + + slider.appendChild(thumbnail); + document.body.appendChild(slider); + + await slider.updateComplete; + await thumbnail.updateComplete; + + // In idle state, pointerPercent=0 → pointerValue=0 → selects 'thumb-0.jpg'. + const img = thumbnail.shadowRoot!.querySelector('img'); + expect(img!.getAttribute('src')).toBe('thumb-0.jpg'); + }); + + it('does not have data-hidden when thumbnails match', async () => { + const slider = createElement(SliderElement); + const thumbnail = createElement(SliderThumbnailElement); + + thumbnail.thumbnails = [{ url: 'thumb.jpg', startTime: 0 }]; + + slider.appendChild(thumbnail); + document.body.appendChild(slider); + + await slider.updateComplete; + await thumbnail.updateComplete; + + expect(thumbnail.hasAttribute('data-hidden')).toBe(false); + }); +});