From 035b509c7a77e74153ea5b36536fce424ce27d2d Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Thu, 18 Jun 2026 08:12:16 +1000 Subject: [PATCH] feat(packages): compound tooltips with label and shortcut parts (#1494) --- packages/core/src/dom/hotkey/aria.ts | 25 ++++ packages/core/src/dom/hotkey/coordinator.ts | 90 ++++++++----- packages/core/src/dom/hotkey/hotkey-events.ts | 2 + packages/core/src/dom/hotkey/hotkey.ts | 2 +- .../core/src/dom/hotkey/tests/aria.test.ts | 10 +- .../src/dom/hotkey/tests/coordinator.test.ts | 50 +++++++ packages/core/src/dom/index.ts | 1 + .../src/define/audio/minimal-skin.tailwind.ts | 15 ++- .../html/src/define/audio/minimal-skin.ts | 15 ++- packages/html/src/define/audio/minimal-ui.ts | 6 + .../html/src/define/audio/skin.tailwind.ts | 15 ++- packages/html/src/define/audio/skin.ts | 15 ++- packages/html/src/define/audio/ui.ts | 4 + .../live-audio/minimal-skin.tailwind.ts | 5 +- .../src/define/live-audio/minimal-skin.ts | 5 +- .../src/define/live-audio/skin.tailwind.ts | 5 +- packages/html/src/define/live-audio/skin.ts | 5 +- .../live-video/minimal-skin.tailwind.ts | 30 ++++- .../src/define/live-video/minimal-skin.ts | 30 ++++- .../src/define/live-video/skin.tailwind.ts | 30 ++++- packages/html/src/define/live-video/skin.ts | 30 ++++- packages/html/src/define/ui/tooltip.ts | 6 + .../src/define/video/minimal-skin.tailwind.ts | 35 ++++- .../html/src/define/video/minimal-skin.ts | 35 ++++- packages/html/src/define/video/minimal-ui.ts | 4 + .../html/src/define/video/skin.tailwind.ts | 35 ++++- packages/html/src/define/video/skin.ts | 35 ++++- packages/html/src/define/video/ui.ts | 4 + packages/html/src/index.ts | 2 + .../hotkey/aria-key-shortcuts-controller.ts | 73 +++++++++-- .../ui/hotkey/tests/hotkey-element.test.ts | 23 ++++ packages/html/src/ui/media-button-element.ts | 28 +++- .../playback-rate-button-element.ts | 1 + .../src/ui/seek-button/seek-button-element.ts | 5 + .../src/ui/tests/media-button-element.test.ts | 59 +++++++++ .../ui/tooltip/tests/tooltip-element.test.ts | 124 ++++++++++++++++++ .../html/src/ui/tooltip/tooltip-element.ts | 30 ++++- .../src/ui/tooltip/tooltip-label-element.ts | 18 +++ .../ui/tooltip/tooltip-shortcut-element.ts | 24 ++++ packages/react/src/index.ts | 4 +- .../presets/audio/minimal-skin.tailwind.tsx | 15 ++- .../react/src/presets/audio/minimal-skin.tsx | 15 ++- .../react/src/presets/audio/skin.tailwind.tsx | 15 ++- packages/react/src/presets/audio/skin.tsx | 15 ++- .../live-audio/minimal-skin.tailwind.tsx | 5 +- .../src/presets/live-audio/minimal-skin.tsx | 5 +- .../src/presets/live-audio/skin.tailwind.tsx | 5 +- .../react/src/presets/live-audio/skin.tsx | 11 +- .../live-video/minimal-skin.tailwind.tsx | 30 ++++- .../src/presets/live-video/minimal-skin.tsx | 30 ++++- .../src/presets/live-video/skin.tailwind.tsx | 30 ++++- .../react/src/presets/live-video/skin.tsx | 30 ++++- .../presets/video/minimal-skin.tailwind.tsx | 35 ++++- .../react/src/presets/video/minimal-skin.tsx | 35 ++++- .../react/src/presets/video/skin.tailwind.tsx | 35 ++++- packages/react/src/presets/video/skin.tsx | 35 ++++- packages/react/src/ui/create-media-button.tsx | 31 +++-- .../hotkey/tests/use-hotkey-shortcut.test.tsx | 58 ++++++++ .../src/ui/hotkey/use-aria-key-shortcuts.ts | 14 -- .../src/ui/hotkey/use-hotkey-shortcut.ts | 27 ++++ packages/react/src/ui/hotkey/use-hotkey.ts | 2 +- .../react/src/ui/live-button/live-button.tsx | 2 +- .../playback-rate-button.tsx | 1 + .../tests/playback-rate-button.test.tsx | 58 ++++++++ .../react/src/ui/seek-button/seek-button.tsx | 2 + packages/react/src/ui/tooltip/context.tsx | 9 +- packages/react/src/ui/tooltip/index.parts.ts | 2 + packages/react/src/ui/tooltip/index.ts | 2 +- .../src/ui/tooltip/tests/tooltip.test.tsx | 76 +++++++++++ .../react/src/ui/tooltip/tooltip-label.tsx | 35 +++++ .../react/src/ui/tooltip/tooltip-popup.tsx | 24 +++- .../react/src/ui/tooltip/tooltip-root.tsx | 4 +- .../react/src/ui/tooltip/tooltip-shortcut.tsx | 39 ++++++ .../src/default/css/components/popup.css | 19 +++ .../src/default/css/components/slider.css | 2 +- .../src/default/tailwind/components/popup.ts | 5 + .../src/default/tailwind/components/slider.ts | 14 +- .../src/minimal/css/components/popup.css | 20 +++ .../src/minimal/tailwind/components/popup.ts | 4 + .../src/minimal/tailwind/components/slider.ts | 8 +- .../demos/tooltip/react/css/BasicUsage.tsx | 2 +- .../docs/demos/tooltip/react/css/Grouping.tsx | 6 +- 82 files changed, 1509 insertions(+), 238 deletions(-) create mode 100644 packages/core/src/dom/hotkey/hotkey-events.ts create mode 100644 packages/html/src/ui/tests/media-button-element.test.ts create mode 100644 packages/html/src/ui/tooltip/tests/tooltip-element.test.ts create mode 100644 packages/html/src/ui/tooltip/tooltip-label-element.ts create mode 100644 packages/html/src/ui/tooltip/tooltip-shortcut-element.ts create mode 100644 packages/react/src/ui/hotkey/tests/use-hotkey-shortcut.test.tsx delete mode 100644 packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts create mode 100644 packages/react/src/ui/hotkey/use-hotkey-shortcut.ts create mode 100644 packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx create mode 100644 packages/react/src/ui/tooltip/tests/tooltip.test.tsx create mode 100644 packages/react/src/ui/tooltip/tooltip-label.tsx create mode 100644 packages/react/src/ui/tooltip/tooltip-shortcut.tsx diff --git a/packages/core/src/dom/hotkey/aria.ts b/packages/core/src/dom/hotkey/aria.ts index 5e8761ab..63159507 100644 --- a/packages/core/src/dom/hotkey/aria.ts +++ b/packages/core/src/dom/hotkey/aria.ts @@ -7,6 +7,13 @@ const ARIA_MODIFIER_MAP: Record = { meta: 'Meta', }; +const DISPLAY_MODIFIER_MAP: Record = { + shift: 'Shift', + ctrl: 'Ctrl', + alt: 'Alt', + meta: 'Meta', +}; + const MODIFIER_ORDER: readonly HotkeyModifierKey[] = ['ctrl', 'shift', 'alt', 'meta']; /** @@ -37,3 +44,21 @@ export function toAriaKeyShortcut(bindings: ParsedHotkeyBinding[]): string { }) .join(' '); } + +/** Convert a parsed key binding to a compact display shortcut. */ +export function toDisplayKeyShortcut(binding: ParsedHotkeyBinding): string { + const parts: string[] = []; + + for (const mod of MODIFIER_ORDER) { + if (binding.modifiers.has(mod)) { + parts.push(DISPLAY_MODIFIER_MAP[mod]); + } + } + + parts.push(toDisplayKey(binding.originalKey)); + return parts.join('+'); +} + +function toDisplayKey(key: string): string { + return key.length === 1 ? key.toUpperCase() : key; +} diff --git a/packages/core/src/dom/hotkey/coordinator.ts b/packages/core/src/dom/hotkey/coordinator.ts index a44eab2c..d1465742 100644 --- a/packages/core/src/dom/hotkey/coordinator.ts +++ b/packages/core/src/dom/hotkey/coordinator.ts @@ -1,6 +1,7 @@ import { isEditableTarget, isInteractiveActivation, listen } from '@videojs/utils/dom'; +import { isUndefined } from '@videojs/utils/predicate'; -import { toAriaKeyShortcut } from './aria'; +import { toAriaKeyShortcut, toDisplayKeyShortcut } from './aria'; import type { HotkeyOptions, ParsedHotkeyBinding } from './hotkey'; import { matchesHotkeyEvent, parseHotkeyPattern } from './hotkey'; @@ -18,15 +19,19 @@ interface HotkeyBinding { id: number; } +export interface HotkeyShortcutDetails { + aria?: string | undefined; + shortcut?: string | undefined; +} + export class HotkeyCoordinator { #target: HTMLElement; #bindings: HotkeyBinding[] = []; #nextId = 0; #disconnect: AbortController | null = null; #docDisconnect: AbortController | null = null; - /** Action name → bound keys. Controls query this to set `aria-keyshortcuts`. */ - #ariaRegistry = new Map(); - #subscribers = new Set<(event: HotkeyActivateEvent) => void>(); + #activationSubscribers = new Set<(event: HotkeyActivateEvent) => void>(); + #shortcutSubscribers = new Set<() => void>(); #destroyed = false; constructor(target: HTMLElement) { @@ -34,8 +39,13 @@ export class HotkeyCoordinator { } subscribe(callback: (event: HotkeyActivateEvent) => void): () => void { - this.#subscribers.add(callback); - return () => this.#subscribers.delete(callback); + this.#activationSubscribers.add(callback); + return () => this.#activationSubscribers.delete(callback); + } + + subscribeShortcutChanges(callback: () => void): () => void { + this.#shortcutSubscribers.add(callback); + return () => this.#shortcutSubscribers.delete(callback); } add(options: HotkeyOptions): () => void { @@ -45,10 +55,6 @@ export class HotkeyCoordinator { this.#bindings.push(binding); this.#sortBindings(); - if (options.action) { - this.#addToAriaRegistry(options.action, parsed); - } - // Lazily connect listeners. if (options.target === 'document') { this.#connectDocument(); @@ -56,6 +62,8 @@ export class HotkeyCoordinator { this.#connect(); } + this.#notify(); + let removed = false; return () => { if (removed) return; @@ -64,18 +72,26 @@ export class HotkeyCoordinator { const idx = this.#bindings.indexOf(binding); if (idx !== -1) this.#bindings.splice(idx, 1); - if (options.action) { - this.#removeFromAriaRegistry(options.action, parsed); - } - this.#maybeDisconnect(); + this.#notify(); }; } getAriaKeys(action: string): string | undefined { - const bindings = this.#ariaRegistry.get(action); - if (!bindings?.length) return undefined; - return toAriaKeyShortcut(bindings); + return this.getShortcut(action).aria; + } + + getShortcut(action: string, value?: number | undefined): HotkeyShortcutDetails { + const bindings = this.#getActionBindings(action, value); + if (!bindings.length) return {}; + + const parsed = bindings.flatMap((binding) => binding.parsed); + const preferred = bindings[bindings.length - 1]!; + + return { + aria: toAriaKeyShortcut(parsed), + shortcut: this.#formatDisplayShortcut(preferred), + }; } destroy(): void { @@ -86,7 +102,9 @@ export class HotkeyCoordinator { this.#docDisconnect?.abort(); this.#docDisconnect = null; this.#bindings = []; - this.#ariaRegistry.clear(); + this.#notify(); + this.#activationSubscribers.clear(); + this.#shortcutSubscribers.clear(); } // --- Private --- @@ -156,14 +174,14 @@ export class HotkeyCoordinator { // Input safety: single-key shortcuts suppressed in editable fields. if (editable && p.modifiers.size === 0) continue; - if (this.#subscribers.size > 0) { + if (this.#activationSubscribers.size > 0) { const activateEvent: HotkeyActivateEvent = { source: 'hotkey', action: options.action, value: options.value, event, }; - for (const cb of this.#subscribers) { + for (const cb of this.#activationSubscribers) { try { cb(activateEvent); } catch (error) { @@ -178,25 +196,25 @@ export class HotkeyCoordinator { } }; - #addToAriaRegistry(action: string, bindings: ParsedHotkeyBinding[]): void { - let existing = this.#ariaRegistry.get(action); - if (!existing) { - existing = []; - this.#ariaRegistry.set(action, existing); - } - existing.push(...bindings); + #getActionBindings(action: string, value?: number | undefined): HotkeyBinding[] { + return this.#bindings + .filter((binding) => { + if (binding.options.disabled) return false; + if (binding.options.action !== action) return false; + if (isUndefined(value)) return true; + return binding.options.value === value; + }) + .sort((a, b) => a.id - b.id); } - #removeFromAriaRegistry(action: string, bindings: ParsedHotkeyBinding[]): void { - const existing = this.#ariaRegistry.get(action); - if (!existing) return; + #formatDisplayShortcut(binding: HotkeyBinding): string { + if (binding.options.keys === '0-9') return binding.options.keys; + return toDisplayKeyShortcut(binding.parsed[0]!); + } - const filtered = existing.filter((b) => !bindings.includes(b)); - - if (filtered.length === 0) { - this.#ariaRegistry.delete(action); - } else { - this.#ariaRegistry.set(action, filtered); + #notify(): void { + for (const subscriber of this.#shortcutSubscribers) { + subscriber(); } } } diff --git a/packages/core/src/dom/hotkey/hotkey-events.ts b/packages/core/src/dom/hotkey/hotkey-events.ts new file mode 100644 index 00000000..46cb0b6f --- /dev/null +++ b/packages/core/src/dom/hotkey/hotkey-events.ts @@ -0,0 +1,2 @@ +/** Dispatched when display shortcut metadata changes (e.g. coordinator updates). Tooltips may listen. */ +export const HOTKEY_SHORTCUT_CHANGE_EVENT = 'hotkey-shortcut-change' as const; diff --git a/packages/core/src/dom/hotkey/hotkey.ts b/packages/core/src/dom/hotkey/hotkey.ts index aaee7e90..9a5d7570 100644 --- a/packages/core/src/dom/hotkey/hotkey.ts +++ b/packages/core/src/dom/hotkey/hotkey.ts @@ -22,7 +22,7 @@ export interface HotkeyOptions { disabled?: boolean | undefined; /** Action name for the ARIA registry and subscriber events. */ action?: string | undefined; - /** Numeric magnitude passed to subscriber events (e.g. 10 for `seekStep`). */ + /** Action value for matching UI controls and subscriber events. */ value?: number | undefined; } diff --git a/packages/core/src/dom/hotkey/tests/aria.test.ts b/packages/core/src/dom/hotkey/tests/aria.test.ts index 9012faf0..eaffc88a 100644 --- a/packages/core/src/dom/hotkey/tests/aria.test.ts +++ b/packages/core/src/dom/hotkey/tests/aria.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { toAriaKeyShortcut } from '../aria'; +import { toAriaKeyShortcut, toDisplayKeyShortcut } from '../aria'; import { parseHotkeyPattern } from '../hotkey'; describe('toAriaKeyShortcut', () => { @@ -36,3 +36,11 @@ describe('toAriaKeyShortcut', () => { expect(result).toBe('0 1 2 3 4 5 6 7 8 9'); }); }); + +describe('toDisplayKeyShortcut', () => { + it('formats a compact display key', () => { + const binding = parseHotkeyPattern('Ctrl+Shift+k')[0]!; + + expect(toDisplayKeyShortcut(binding)).toBe('Ctrl+Shift+K'); + }); +}); diff --git a/packages/core/src/dom/hotkey/tests/coordinator.test.ts b/packages/core/src/dom/hotkey/tests/coordinator.test.ts index 328f7724..9485638d 100644 --- a/packages/core/src/dom/hotkey/tests/coordinator.test.ts +++ b/packages/core/src/dom/hotkey/tests/coordinator.test.ts @@ -383,5 +383,55 @@ describe('HotkeyCoordinator', () => { expect(c.getAriaKeys('togglePaused')).toBeUndefined(); }); + + it('returns the latest registered shortcut as the preferred display key', () => { + const c = setup(); + c.add({ keys: 'Space', onActivate: vi.fn(), action: 'togglePaused' }); + c.add({ keys: 'k', onActivate: vi.fn(), action: 'togglePaused' }); + + expect(c.getShortcut('togglePaused')).toEqual({ + aria: 'Space k', + shortcut: 'K', + }); + }); + + it('filters shortcuts by action value', () => { + const c = setup(); + c.add({ keys: 'ArrowLeft', onActivate: vi.fn(), action: 'seekStep', value: -5 }); + c.add({ keys: 'j', onActivate: vi.fn(), action: 'seekStep', value: -10 }); + c.add({ keys: 'l', onActivate: vi.fn(), action: 'seekStep', value: 10 }); + + expect(c.getShortcut('seekStep', -10)).toEqual({ + aria: 'j', + shortcut: 'J', + }); + }); + + it('excludes disabled bindings from shortcut lookup', () => { + const c = setup(); + c.add({ keys: 'Space', onActivate: vi.fn(), action: 'togglePaused', disabled: true }); + c.add({ keys: 'k', onActivate: vi.fn(), action: 'togglePaused' }); + + expect(c.getShortcut('togglePaused')).toEqual({ + aria: 'k', + shortcut: 'K', + }); + }); + + it('notifies subscribers when shortcut lookup changes', () => { + const c = setup(); + const subscriber = vi.fn(); + const unsubscribe = c.subscribeShortcutChanges(subscriber); + + const remove = c.add({ keys: 'k', onActivate: vi.fn(), action: 'togglePaused' }); + remove(); + + expect(subscriber).toHaveBeenCalledTimes(2); + + unsubscribe(); + c.add({ keys: 'Space', onActivate: vi.fn(), action: 'togglePaused' }); + + expect(subscriber).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 9f3d9931..1b4e4de3 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -7,6 +7,7 @@ export * from './hotkey/actions'; export * from './hotkey/aria'; export * from './hotkey/coordinator'; export * from './hotkey/hotkey'; +export * from './hotkey/hotkey-events'; export * from './media/types'; export * from './store/features'; export * from './store/selectors'; diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index 041dadd0..82ef8020 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -53,7 +53,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + @@ -61,7 +64,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -69,7 +75,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index 50f4abd4..3899f5ad 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -36,7 +36,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + @@ -44,7 +47,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -52,7 +58,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
diff --git a/packages/html/src/define/audio/minimal-ui.ts b/packages/html/src/define/audio/minimal-ui.ts index 24f47c67..f1cf0a8d 100644 --- a/packages/html/src/define/audio/minimal-ui.ts +++ b/packages/html/src/define/audio/minimal-ui.ts @@ -2,6 +2,7 @@ // used by the minimal skin without creating a skin element. Use this entry // when building an ejected (light DOM) player layout. import { MediaContainerElement } from '../../media/container-element'; +import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; @@ -10,6 +11,8 @@ import { PopoverElement } from '../../ui/popover/popover-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { TooltipLabelElement } from '../../ui/tooltip/tooltip-label-element'; +import { TooltipShortcutElement } from '../../ui/tooltip/tooltip-shortcut-element'; import { safeDefine } from '../safe-define'; import { defineErrorDialog, defineMenu, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; @@ -29,11 +32,14 @@ defineTime(); defineMenu(); // Standalone elements. +safeDefine(HotkeyElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); safeDefine(PlaybackRateRadioGroupElement); safeDefine(PopoverElement); safeDefine(SeekButtonElement); +safeDefine(TooltipLabelElement); +safeDefine(TooltipShortcutElement); safeDefine(TooltipElement); safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 761423f3..7e4d5850 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -53,7 +53,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + @@ -61,7 +64,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -69,7 +75,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 01c3923f..ebc0718c 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -36,7 +36,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + @@ -44,7 +47,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -52,7 +58,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index 0624de8c..2e645c14 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -13,6 +13,8 @@ import { PopoverElement } from '../../ui/popover/popover-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { TooltipLabelElement } from '../../ui/tooltip/tooltip-label-element'; +import { TooltipShortcutElement } from '../../ui/tooltip/tooltip-shortcut-element'; import { safeDefine } from '../safe-define'; import { defineErrorDialog, defineMenu, defineSliders, defineTime } from '../ui/compounds'; @@ -40,5 +42,7 @@ safeDefine(PlaybackRateButtonElement); safeDefine(PlaybackRateRadioGroupElement); safeDefine(PopoverElement); safeDefine(SeekButtonElement); +safeDefine(TooltipLabelElement); +safeDefine(TooltipShortcutElement); safeDefine(TooltipElement); safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts index b43aa493..c82093d3 100644 --- a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts @@ -45,7 +45,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + +
diff --git a/packages/html/src/define/live-audio/minimal-skin.ts b/packages/html/src/define/live-audio/minimal-skin.ts index 70db1c47..d7c0ccf6 100644 --- a/packages/html/src/define/live-audio/minimal-skin.ts +++ b/packages/html/src/define/live-audio/minimal-skin.ts @@ -34,7 +34,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index 56accf04..495c4468 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -45,7 +45,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts index 4bafcd1c..54206b55 100644 --- a/packages/html/src/define/live-audio/skin.ts +++ b/packages/html/src/define/live-audio/skin.ts @@ -34,7 +34,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts index 85947f19..611833f4 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -59,7 +59,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + @@ -97,27 +100,42 @@ function getTemplateHTML() { - + + + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - + + + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - + + + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - + + + + diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 0b2aea7f..c073e2a3 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -42,7 +42,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + @@ -81,31 +84,46 @@ function getTemplateHTML() { - + + + + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} - + + + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} - + + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} - + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} - + + + + diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index c05baa34..3f036d14 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -61,7 +61,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + @@ -99,27 +102,42 @@ function getTemplateHTML() { - + + + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - + + + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - + + + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - + + + + diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index 25d4326a..8991ee78 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -44,7 +44,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + @@ -83,31 +86,46 @@ function getTemplateHTML() { - + + + + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} - + + + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} - + + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} - + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} - + + + + diff --git a/packages/html/src/define/ui/tooltip.ts b/packages/html/src/define/ui/tooltip.ts index b79f24de..8da8af62 100644 --- a/packages/html/src/define/ui/tooltip.ts +++ b/packages/html/src/define/ui/tooltip.ts @@ -1,10 +1,16 @@ import { TooltipElement } from '../../ui/tooltip/tooltip-element'; +import { TooltipLabelElement } from '../../ui/tooltip/tooltip-label-element'; +import { TooltipShortcutElement } from '../../ui/tooltip/tooltip-shortcut-element'; import { safeDefine } from '../safe-define'; +safeDefine(TooltipLabelElement); +safeDefine(TooltipShortcutElement); safeDefine(TooltipElement); declare global { interface HTMLElementTagNameMap { + [TooltipLabelElement.tagName]: TooltipLabelElement; + [TooltipShortcutElement.tagName]: TooltipShortcutElement; [TooltipElement.tagName]: TooltipElement; } } diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index e3a675db..8acdef9f 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -67,7 +67,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + @@ -75,7 +78,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -83,7 +89,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
@@ -225,22 +234,34 @@ function getTemplateHTML() { ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - + + + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - + + + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - + + + +
diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 6b5f75cf..00f5ad9f 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -44,7 +44,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + @@ -52,7 +55,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -60,7 +66,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
@@ -203,25 +212,37 @@ function getTemplateHTML() { ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} - + + + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} - + + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} - + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} - + + + +
diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index 23575b35..763c051d 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -22,6 +22,8 @@ import { QualityRadioGroupElement } from '../../ui/quality-radio-group/quality-r import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { TooltipLabelElement } from '../../ui/tooltip/tooltip-label-element'; +import { TooltipShortcutElement } from '../../ui/tooltip/tooltip-shortcut-element'; import { safeDefine } from '../safe-define'; import { defineControls, @@ -68,5 +70,7 @@ safeDefine(PopoverElement); safeDefine(PosterElement); safeDefine(QualityRadioGroupElement); safeDefine(SeekButtonElement); +safeDefine(TooltipLabelElement); +safeDefine(TooltipShortcutElement); safeDefine(TooltipElement); safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 89668ed8..f09441b4 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -69,7 +69,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + + + + @@ -77,7 +80,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -85,7 +91,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
@@ -221,22 +230,34 @@ function getTemplateHTML() { ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - + + + + ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - + + + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - + + + +
diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index d7ec3d06..f87f83a0 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -46,7 +46,10 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + + + + @@ -54,7 +57,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + + @@ -62,7 +68,10 @@ function getTemplateHTML() { ${SEEK_TIME} - + + + +
@@ -199,25 +208,37 @@ function getTemplateHTML() { ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} - + + + + ${renderIcon('airplay-enter', { class: 'media-icon media-icon--airplay-enter' })} ${renderIcon('airplay-exit', { class: 'media-icon media-icon--airplay-exit' })} - + + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} - + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} - + + + +
diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index e008d162..1fb0c322 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -22,6 +22,8 @@ import { QualityRadioGroupElement } from '../../ui/quality-radio-group/quality-r import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; import { TooltipElement } from '../../ui/tooltip/tooltip-element'; import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { TooltipLabelElement } from '../../ui/tooltip/tooltip-label-element'; +import { TooltipShortcutElement } from '../../ui/tooltip/tooltip-shortcut-element'; import { safeDefine } from '../safe-define'; import { defineControls, @@ -67,5 +69,7 @@ safeDefine(PopoverElement); safeDefine(PosterElement); safeDefine(QualityRadioGroupElement); safeDefine(SeekButtonElement); +safeDefine(TooltipLabelElement); +safeDefine(TooltipShortcutElement); safeDefine(TooltipElement); safeDefine(TooltipGroupElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index f45404ae..3b56f82b 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -100,6 +100,8 @@ export { TimeSliderElement } from './ui/time-slider/time-slider-element'; export { tooltipGroupContext } from './ui/tooltip/context'; export { TooltipElement } from './ui/tooltip/tooltip-element'; export { TooltipGroupElement } from './ui/tooltip/tooltip-group-element'; +export { TooltipLabelElement } from './ui/tooltip/tooltip-label-element'; +export { TooltipShortcutElement } from './ui/tooltip/tooltip-shortcut-element'; export { VolumeIndicatorElement } from './ui/volume-indicator/volume-indicator-element'; export { VolumeIndicatorFillElement } from './ui/volume-indicator/volume-indicator-fill-element'; export { VolumeIndicatorValueElement } from './ui/volume-indicator/volume-indicator-value-element'; diff --git a/packages/html/src/ui/hotkey/aria-key-shortcuts-controller.ts b/packages/html/src/ui/hotkey/aria-key-shortcuts-controller.ts index e78a8a32..e0d15c60 100644 --- a/packages/html/src/ui/hotkey/aria-key-shortcuts-controller.ts +++ b/packages/html/src/ui/hotkey/aria-key-shortcuts-controller.ts @@ -1,4 +1,4 @@ -import { findHotkeyCoordinator } from '@videojs/core/dom'; +import { getHotkeyCoordinator, type HotkeyShortcutDetails } from '@videojs/core/dom'; import type { ReactiveController } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; @@ -6,23 +6,72 @@ import type { ContainerContextConsumer } from '../../player/context'; import { containerContext } from '../../player/context'; import type { PlayerControllerHost } from '../../player/player-controller'; -/** Provides `aria-keyshortcuts` for a given hotkey action name. */ -export class AriaKeyShortcutsController implements ReactiveController { - #action: string; - #container: ContainerContextConsumer; +export interface AriaKeyShortcutsControllerOptions { + value?: (() => number | undefined) | undefined; +} - constructor(host: PlayerControllerHost, action: string) { +/** Provides hotkey shortcut metadata for a given hotkey action name. */ +export class AriaKeyShortcutsController implements ReactiveController { + #host: PlayerControllerHost; + #action: string; + #getValue: (() => number | undefined) | undefined; + #container: ContainerContextConsumer; + #unsubscribe: (() => void) | null = null; + + constructor(host: PlayerControllerHost, action: string, options: AriaKeyShortcutsControllerOptions = {}) { + this.#host = host; this.#action = action; - this.#container = new ContextConsumer(host, { context: containerContext, subscribe: true }); + this.#getValue = options.value; + this.#container = new ContextConsumer(host, { + context: containerContext, + callback: (ctx) => this.#connect(ctx?.container), + subscribe: true, + }); host.addController(this); } get value(): string | undefined { - const container = this.#container.value?.container; - if (!container) return undefined; - return findHotkeyCoordinator(container)?.getAriaKeys(this.#action); + return this.aria; } - hostConnected(): void {} - hostDisconnected(): void {} + get aria(): string | undefined { + return this.details.aria; + } + + get shortcut(): string | undefined { + return this.details.shortcut; + } + + get details(): HotkeyShortcutDetails { + const container = this.#container.value?.container; + if (!container) return {}; + return getHotkeyCoordinator(container).getShortcut(this.#action, this.#getValue?.()); + } + + hostConnected(): void { + this.#connect(this.#container.value?.container); + } + + hostDisconnected(): void { + this.#disconnect(); + } + + #connect(container: HTMLElement | null | undefined): void { + this.#disconnect(); + + if (!container) return; + + const coordinator = getHotkeyCoordinator(container); + const notify = () => { + this.#host.requestUpdate(); + }; + + this.#unsubscribe = coordinator.subscribeShortcutChanges(notify); + notify(); + } + + #disconnect(): void { + this.#unsubscribe?.(); + this.#unsubscribe = null; + } } diff --git a/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts b/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts index c0073bbf..dbd10b2e 100644 --- a/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts +++ b/packages/html/src/ui/hotkey/tests/hotkey-element.test.ts @@ -1,4 +1,7 @@ +import { ContextProvider } from '@videojs/element/context'; import { afterEach, describe, expect, it } from 'vitest'; +import { containerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; import { AriaKeyShortcutsController } from '../aria-key-shortcuts-controller'; import { HotkeyElement } from '../hotkey-element'; @@ -42,6 +45,16 @@ describe('HotkeyElement', () => { }); describe('AriaKeyShortcutsController', () => { + class TestContainerProviderElement extends MediaElement { + readonly provider = new ContextProvider(this, { + context: containerContext, + initialValue: { + container: this, + setContainer: () => {}, + }, + }); + } + it('returns undefined when no coordinator exists', () => { const el = createElement(HotkeyElement); document.body.appendChild(el); @@ -50,4 +63,14 @@ describe('AriaKeyShortcutsController', () => { expect(controller.value).toBeUndefined(); }); + + it('connects when context is available during construction', () => { + const provider = createElement(TestContainerProviderElement); + const el = createElement(HotkeyElement); + + provider.append(el); + document.body.append(provider); + + expect(() => new AriaKeyShortcutsController(el, 'togglePaused')).not.toThrow(); + }); }); diff --git a/packages/html/src/ui/media-button-element.ts b/packages/html/src/ui/media-button-element.ts index e1574b7c..b038dd70 100644 --- a/packages/html/src/ui/media-button-element.ts +++ b/packages/html/src/ui/media-button-element.ts @@ -9,6 +9,7 @@ import { applyElementProps, applyStateDataAttrs, createButton, + HOTKEY_SHORTCUT_CHANGE_EVENT, logMissingFeature, type UIEvent, } from '@videojs/core/dom'; @@ -46,19 +47,27 @@ export abstract class MediaButtonElement exte /** Override to set the hotkey action name for `aria-keyshortcuts`. */ protected readonly hotkeyAction: string | undefined = undefined; + /** Override to match hotkeys that use action values, such as seek steps. */ + protected get hotkeyValue(): number | undefined { + return undefined; + } + get $state(): State { return this.core.state; } #disconnect: AbortController | null = null; #hotkeyRegistry: AriaKeyShortcutsController | null = null; + #lastHotkeyShortcut: string | undefined; override connectedCallback(): void { super.connectedCallback(); if (this.destroyed) return; if (this.hotkeyAction && !this.#hotkeyRegistry) { - this.#hotkeyRegistry = new AriaKeyShortcutsController(this, this.hotkeyAction); + this.#hotkeyRegistry = new AriaKeyShortcutsController(this, this.hotkeyAction, { + value: () => this.hotkeyValue, + }); } this.#disconnect = new AbortController(); @@ -86,6 +95,10 @@ export abstract class MediaButtonElement exte return this.core.state.current.label || undefined; } + getShortcut(): string | undefined { + return this.#hotkeyRegistry?.shortcut; + } + protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); this.core.setProps?.(this); @@ -96,14 +109,25 @@ export abstract class MediaButtonElement exte const media = this.mediaState.value; + this.#syncHotkeyShortcut(); + if (!media) return; this.core.setMedia(media); const state = this.core.getState(); applyElementProps(this, { ...this.core.getAttrs?.(state), - 'aria-keyshortcuts': this.#hotkeyRegistry?.value, + 'aria-keyshortcuts': this.#hotkeyRegistry?.aria, }); applyStateDataAttrs(this, state, this.stateAttrMap); } + + #syncHotkeyShortcut(): void { + const shortcut = this.getShortcut(); + + if (shortcut === this.#lastHotkeyShortcut) return; + + this.#lastHotkeyShortcut = shortcut; + this.dispatchEvent(new CustomEvent(HOTKEY_SHORTCUT_CHANGE_EVENT)); + } } diff --git a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts index e50c2b2c..e0520d92 100644 --- a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts +++ b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts @@ -21,6 +21,7 @@ export class PlaybackRateButtonElement extends MediaButtonElement { protected readonly core = new SeekButtonCore(); protected readonly stateAttrMap = SeekButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectTime); + protected override readonly hotkeyAction = 'seekStep'; + + protected override get hotkeyValue(): number | undefined { + return this.seconds; + } protected activate(state: MediaTimeState): void { this.core.seek(state); diff --git a/packages/html/src/ui/tests/media-button-element.test.ts b/packages/html/src/ui/tests/media-button-element.test.ts new file mode 100644 index 00000000..e5f4ff8d --- /dev/null +++ b/packages/html/src/ui/tests/media-button-element.test.ts @@ -0,0 +1,59 @@ +import { createHotkey, HOTKEY_SHORTCUT_CHANGE_EVENT } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { containerContext } from '../../player/context'; +import { MediaElement } from '../media-element'; +import { PlayButtonElement } from '../play-button/play-button-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(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; +} + +class TestContainerProviderElement extends MediaElement { + readonly provider = new ContextProvider(this, { + context: containerContext, + initialValue: { + container: this, + setContainer: () => {}, + }, + }); +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('MediaButtonElement', () => { + it('emits shortcut changes before media is attached', async () => { + const provider = createElement(TestContainerProviderElement); + const button = createElement(PlayButtonElement); + const onShortcutChange = vi.fn(); + + button.addEventListener(HOTKEY_SHORTCUT_CHANGE_EVENT, onShortcutChange); + provider.append(button); + document.body.append(provider); + + await button.updateComplete; + + createHotkey(provider, { + keys: 'k', + action: 'togglePaused', + onActivate: () => {}, + }); + + await button.updateComplete; + + expect(onShortcutChange).toHaveBeenCalledTimes(1); + expect(button.getShortcut()).toBe('K'); + }); +}); diff --git a/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts b/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts new file mode 100644 index 00000000..0d7ff848 --- /dev/null +++ b/packages/html/src/ui/tooltip/tests/tooltip-element.test.ts @@ -0,0 +1,124 @@ +import type { ButtonState } from '@videojs/core'; +import { HOTKEY_SHORTCUT_CHANGE_EVENT } from '@videojs/core/dom'; +import { createState } from '@videojs/store'; +import { afterEach, describe, expect, it } from 'vitest'; +import { TooltipElement } from '../tooltip-element'; +import { TooltipLabelElement } from '../tooltip-label-element'; +import { TooltipShortcutElement } from '../tooltip-shortcut-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(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; +} + +class TestTriggerElement extends HTMLElement { + $state = createState({ label: 'Play' }); + shortcut: string | undefined = 'K'; + + getLabel(): string | undefined { + return this.$state.current.label; + } + + getShortcut(): string | undefined { + return this.shortcut; + } +} + +function defineTestElements(): void { + if (!customElements.get('test-tooltip-trigger')) { + customElements.define('test-tooltip-trigger', TestTriggerElement); + } + if (!customElements.get(TooltipLabelElement.tagName)) { + customElements.define(TooltipLabelElement.tagName, TooltipLabelElement); + } + if (!customElements.get(TooltipShortcutElement.tagName)) { + customElements.define(TooltipShortcutElement.tagName, TooltipShortcutElement); + } +} + +function setup() { + defineTestElements(); + + const trigger = document.createElement('test-tooltip-trigger') as TestTriggerElement; + const tooltip = createElement(TooltipElement); + + tooltip.id = 'tooltip'; + trigger.setAttribute('commandfor', tooltip.id); + document.body.append(trigger, tooltip); + + return { tooltip, trigger }; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('TooltipElement', () => { + it('creates default label and shortcut elements for empty tooltips', async () => { + const { tooltip } = setup(); + + await tooltip.updateComplete; + + const label = TooltipLabelElement.findIn(tooltip); + const shortcut = TooltipShortcutElement.findIn(tooltip); + expect(label?.localName).toBe(TooltipLabelElement.tagName); + expect(label?.textContent).toBe('Play'); + expect(shortcut?.localName).toBe(TooltipShortcutElement.tagName); + expect(shortcut?.textContent).toBe('K'); + expect(shortcut?.hidden).toBe(false); + }); + + it('syncs label and shortcut onto existing compound parts', async () => { + const { tooltip } = setup(); + const labelEl = TooltipLabelElement.create(); + const shortcutEl = TooltipShortcutElement.create(); + tooltip.replaceChildren(document.createTextNode('Action: '), labelEl, shortcutEl); + + await tooltip.updateComplete; + + const label = TooltipLabelElement.findIn(tooltip); + expect(tooltip.textContent).toBe('Action: PlayK'); + expect(label?.textContent).toBe('Play'); + expect(TooltipShortcutElement.findIn(tooltip)?.textContent).toBe('K'); + }); + + it('preserves authored content without tooltip parts', async () => { + const { tooltip } = setup(); + tooltip.textContent = 'Custom tooltip'; + + await tooltip.updateComplete; + + expect(tooltip.textContent).toBe('Custom tooltip'); + }); + + it('updates shortcut text when the trigger shortcut changes', async () => { + const { tooltip, trigger } = setup(); + + await tooltip.updateComplete; + + trigger.shortcut = 'P'; + trigger.dispatchEvent(new CustomEvent(HOTKEY_SHORTCUT_CHANGE_EVENT)); + + expect(TooltipShortcutElement.findIn(tooltip)?.textContent).toBe('P'); + }); + + it('hides shortcut part when the trigger shortcut is cleared', async () => { + const { tooltip, trigger } = setup(); + + await tooltip.updateComplete; + + trigger.shortcut = undefined; + trigger.dispatchEvent(new CustomEvent(HOTKEY_SHORTCUT_CHANGE_EVENT)); + + const shortcut = TooltipShortcutElement.findIn(tooltip); + expect(shortcut?.textContent).toBe(''); + expect(shortcut?.hidden).toBe(true); + }); +}); diff --git a/packages/html/src/ui/tooltip/tooltip-element.ts b/packages/html/src/ui/tooltip/tooltip-element.ts index 74660fa7..9cd7704a 100644 --- a/packages/html/src/ui/tooltip/tooltip-element.ts +++ b/packages/html/src/ui/tooltip/tooltip-element.ts @@ -15,6 +15,7 @@ import { getAnchorPositionStyle, getPopupPositionRect, getPositioningBoundaryRect, + HOTKEY_SHORTCUT_CHANGE_EVENT, type PositioningBoundary, resolveOffsets, resolvePositioningBoundary, @@ -26,15 +27,18 @@ import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; import type { State } from '@videojs/store'; import { SnapshotController } from '@videojs/store/html'; -import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; +import { applyStyles, listen, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; import { containerContext } from '../../player/context'; import { MediaElement } from '../media-element'; import { PositionController } from '../position-controller'; import { tooltipGroupContext } from './context'; +import { TooltipLabelElement } from './tooltip-label-element'; +import { TooltipShortcutElement } from './tooltip-shortcut-element'; type TriggerElement = HTMLElement & { getLabel(): string | undefined; + getShortcut?: (() => string | undefined) | undefined; $state: State; }; @@ -234,12 +238,34 @@ export class TooltipElement extends MediaElement { triggerEl.$state.subscribe(() => this.#syncContent(triggerEl), { signal: this.#triggerAbort.signal, }); + listen(triggerEl, HOTKEY_SHORTCUT_CHANGE_EVENT, () => this.#syncContent(triggerEl), { + signal: this.#triggerAbort.signal, + }); } } } #syncContent(triggerEl: TriggerElement): void { - this.textContent = triggerEl.getLabel() ?? ''; + const label = triggerEl.getLabel() ?? ''; + const shortcut = triggerEl.getShortcut?.(); + + let labelEl = TooltipLabelElement.findIn(this); + let shortcutEl = TooltipShortcutElement.findIn(this); + + if (!labelEl && !shortcutEl) { + if (this.#hostHasAuthoredTooltipContent()) return; + + labelEl = TooltipLabelElement.create(); + shortcutEl = TooltipShortcutElement.create(); + this.replaceChildren(labelEl, shortcutEl); + } + + labelEl?.setSyncedText(label); + shortcutEl?.setSyncedShortcut(shortcut); + } + + #hostHasAuthoredTooltipContent(): boolean { + return Array.from(this.childNodes).some((node) => node.nodeType !== Node.TEXT_NODE || !!node.textContent?.trim()); } #cleanupTrigger(): void { diff --git a/packages/html/src/ui/tooltip/tooltip-label-element.ts b/packages/html/src/ui/tooltip/tooltip-label-element.ts new file mode 100644 index 00000000..0b1088a7 --- /dev/null +++ b/packages/html/src/ui/tooltip/tooltip-label-element.ts @@ -0,0 +1,18 @@ +import { MediaElement } from '../media-element'; + +/** Label region inside `media-tooltip`; parent syncs text from the trigger when linked to a media button. */ +export class TooltipLabelElement extends MediaElement { + static readonly tagName = 'media-tooltip-label'; + + static findIn(host: HTMLElement): TooltipLabelElement | null { + return host.querySelector(TooltipLabelElement.tagName); + } + + static create(): TooltipLabelElement { + return document.createElement(TooltipLabelElement.tagName) as TooltipLabelElement; + } + + setSyncedText(text: string): void { + this.textContent = text; + } +} diff --git a/packages/html/src/ui/tooltip/tooltip-shortcut-element.ts b/packages/html/src/ui/tooltip/tooltip-shortcut-element.ts new file mode 100644 index 00000000..ebb48d70 --- /dev/null +++ b/packages/html/src/ui/tooltip/tooltip-shortcut-element.ts @@ -0,0 +1,24 @@ +import { MediaElement } from '../media-element'; + +/** Shortcut hint inside `media-tooltip`. CSS skins: `class="media-tooltip__kbd"`; Tailwind skins: `class` from `popup.tooltipShortcut`. */ +export class TooltipShortcutElement extends MediaElement { + static readonly tagName = 'media-tooltip-shortcut'; + + static findIn(host: HTMLElement): TooltipShortcutElement | null { + return host.querySelector(TooltipShortcutElement.tagName); + } + + static create(): TooltipShortcutElement { + return document.createElement(TooltipShortcutElement.tagName) as TooltipShortcutElement; + } + + setSyncedShortcut(shortcut: string | undefined): void { + if (shortcut) { + this.textContent = shortcut; + this.hidden = false; + } else { + this.textContent = ''; + this.hidden = true; + } + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8d01d8d0..d04a0488 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -51,8 +51,8 @@ export { type UseTapGestureOptions, useTapGesture } from './ui/gesture/use-tap-g export { useButton } from './ui/hooks/use-button'; export { useSlider } from './ui/hooks/use-slider'; export { Hotkey, type HotkeyProps, MediaHotkey, type MediaHotkeyProps } from './ui/hotkey/hotkey'; -export { useAriaKeyShortcuts } from './ui/hotkey/use-aria-key-shortcuts'; export { type UseHotkeyOptions, useHotkey } from './ui/hotkey/use-hotkey'; +export { useHotkeyShortcut } from './ui/hotkey/use-hotkey-shortcut'; export { LiveButton, type LiveButtonProps } from './ui/live-button/live-button'; export { Menu, type MenuContextValue, useMenuContext, useOptionalMenuContext } from './ui/menu'; export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; @@ -92,7 +92,7 @@ export type { StatusIndicatorValueProps } from './ui/status-indicator/status-ind export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail'; export { Time } from './ui/time'; export { TimeSlider } from './ui/time-slider'; -export { Tooltip, type TooltipContextValue, useTooltipContext } from './ui/tooltip'; +export { Tooltip, type TooltipContent, type TooltipContextValue, useTooltipContext } from './ui/tooltip'; export { VolumeIndicator } from './ui/volume-indicator'; export type { VolumeIndicatorFillProps } from './ui/volume-indicator/volume-indicator-fill'; export type { VolumeIndicatorRootProps } from './ui/volume-indicator/volume-indicator-root'; diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index b5fc7b7f..3beacf15 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -189,7 +189,10 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod } /> - + + + + @@ -203,7 +206,10 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod } /> - + + + + @@ -217,7 +223,10 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod } /> - + + + + diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index 13530604..40c2e86c 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -136,7 +136,10 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { } /> - + + + + @@ -150,7 +153,10 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { } /> - + + + + @@ -164,7 +170,10 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index e99678e6..026c7748 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -191,7 +191,10 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { } /> - + + + + @@ -205,7 +208,10 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { } /> - + + + + @@ -219,7 +225,10 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index 2db8920b..9e6feb40 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -136,7 +136,10 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { } /> - + + + + @@ -150,7 +153,10 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { } /> - + + + + @@ -164,7 +170,10 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx index 9223280b..fd53bcf6 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -129,7 +129,10 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps): } /> - + + + + diff --git a/packages/react/src/presets/live-audio/minimal-skin.tsx b/packages/react/src/presets/live-audio/minimal-skin.tsx index 061e8eb0..13dc27dd 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tsx @@ -95,7 +95,10 @@ export function MinimalLiveAudioSkin(props: MinimalLiveAudioSkinProps): ReactNod } /> - + + + + diff --git a/packages/react/src/presets/live-audio/skin.tailwind.tsx b/packages/react/src/presets/live-audio/skin.tailwind.tsx index 58706680..4f38e920 100644 --- a/packages/react/src/presets/live-audio/skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/skin.tailwind.tsx @@ -129,7 +129,10 @@ export function LiveAudioSkinTailwind(props: LiveAudioSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/live-audio/skin.tsx b/packages/react/src/presets/live-audio/skin.tsx index bffdcdc2..bfceec27 100644 --- a/packages/react/src/presets/live-audio/skin.tsx +++ b/packages/react/src/presets/live-audio/skin.tsx @@ -54,6 +54,15 @@ function VolumePopover(): ReactNode { ); } +export function TooltipPopup(props: Omit): ReactNode { + return ( + + + + + ); +} + /** * Default audio skin configured for live playback. Mirrors {@link AudioSkin} * but omits the time slider and the current / duration time displays. A @@ -94,7 +103,7 @@ export function LiveAudioSkin(props: LiveAudioSkinProps): ReactNode { } /> - + diff --git a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx index 5c714205..ab11e9da 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -155,7 +155,10 @@ function CaptionsTrigger(): ReactNode { } /> - + + + + ); } @@ -245,7 +248,10 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + + + + @@ -267,7 +273,10 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + + + + @@ -279,7 +288,10 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + + + + @@ -291,7 +303,10 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + + + + @@ -303,7 +318,10 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): } /> - + + + + diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index f0270d1f..b2884bd6 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -115,7 +115,10 @@ function CaptionsTrigger(): ReactNode { } /> - + + + + ); } @@ -207,7 +210,10 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod } /> - + + + + @@ -229,7 +235,10 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod } /> - + + + + @@ -241,7 +250,10 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod } /> - + + + + @@ -253,7 +265,10 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod } /> - + + + + @@ -265,7 +280,10 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod } /> - + + + + diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index 158967aa..966470cb 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -155,7 +155,10 @@ function CaptionsTrigger(): ReactNode { } /> - + + + + ); } @@ -247,7 +250,10 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -269,7 +275,10 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -281,7 +290,10 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -293,7 +305,10 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -305,7 +320,10 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index 71bb1f2f..a6ccc9d8 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -114,7 +114,10 @@ function CaptionsTrigger(): ReactNode { } /> - + + + + ); } @@ -201,7 +204,10 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -223,7 +229,10 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -235,7 +244,10 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -247,7 +259,10 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { } /> - + + + + @@ -259,7 +274,10 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index a83d3ef6..2ccd6f64 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -398,7 +398,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + @@ -412,7 +415,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + @@ -426,7 +432,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + @@ -470,7 +479,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + @@ -482,7 +494,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + @@ -494,7 +509,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + @@ -506,7 +524,10 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod } /> - + + + + diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index efeaf13a..b824f0d3 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -328,7 +328,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + @@ -342,7 +345,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + @@ -356,7 +362,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + @@ -401,7 +410,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + @@ -413,7 +425,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + @@ -425,7 +440,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + @@ -437,7 +455,10 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 38f78f78..8940617c 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -400,7 +400,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -414,7 +417,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -428,7 +434,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -466,7 +475,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -478,7 +490,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -490,7 +505,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -502,7 +520,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index d37ebce7..4a057908 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -330,7 +330,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -344,7 +347,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -358,7 +364,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -397,7 +406,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -409,7 +421,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -421,7 +436,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + @@ -433,7 +451,10 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { } /> - + + + + diff --git a/packages/react/src/ui/create-media-button.tsx b/packages/react/src/ui/create-media-button.tsx index 80c11b72..c9851fff 100644 --- a/packages/react/src/ui/create-media-button.tsx +++ b/packages/react/src/ui/create-media-button.tsx @@ -11,7 +11,7 @@ import { usePlayer } from '../player/context'; import type { renderElement as renderElementFn } from '../utils/use-render'; import { renderElement } from '../utils/use-render'; import { useButton } from './hooks/use-button'; -import { useAriaKeyShortcuts } from './hotkey/use-aria-key-shortcuts'; +import { useHotkeyShortcut } from './hotkey/use-hotkey-shortcut'; import { useOptionalMenuTriggerChildContext } from './menu/context'; import { useOptionalTooltipContext } from './tooltip/context'; @@ -22,13 +22,24 @@ interface MediaButtonConfig> { selector: Selector | undefined>; action: (core: Core, state: InferMediaState) => void; hotkeyAction?: string; + hotkeyValue?: (props: Record) => number | undefined; + tooltipLabel?: (core: Core, state: InferComponentState) => string | undefined; } /** Creates a media button React component from a core class and config. */ export function createMediaButton, Props extends object>( config: MediaButtonConfig ): ForwardRefExoticComponent> { - const { displayName, core: CoreClass, stateAttrMap, selector, action, hotkeyAction } = config; + const { + displayName, + core: CoreClass, + stateAttrMap, + selector, + action, + hotkeyAction, + hotkeyValue, + tooltipLabel, + } = config; // Props that exist in the core's defaultProps are routed to setProps; the rest go to the DOM element. const corePropKeys = new Set(Object.keys(CoreClass.defaultProps)); @@ -52,8 +63,9 @@ export function createMediaButton, P const tooltipCtx = useOptionalTooltipContext(); const menuTriggerChild = useOptionalMenuTriggerChildContext(); + const setTooltipContent = tooltipCtx?.setContent; const feature = usePlayer(selector); - const shortcuts = useAriaKeyShortcuts(hotkeyAction); + const shortcut = useHotkeyShortcut(hotkeyAction, hotkeyValue?.(coreProps)); const [core] = useState(() => new CoreClass()); @@ -75,20 +87,21 @@ export function createMediaButton, P if (feature) core.setMedia(feature); const state = feature ? (core.getState() as State) : null; const label = state ? core.getLabel(state) : undefined; + const tooltipText = state ? (tooltipLabel?.(core, state) ?? label) : undefined; // Forward label to tooltip popup content when inside a Tooltip.Root. useLayoutEffect(() => { - if (!tooltipCtx) return; - tooltipCtx.setContent(label); - return () => tooltipCtx.setContent(undefined); - }, [tooltipCtx, label]); + if (!setTooltipContent) return; + setTooltipContent(tooltipText ? { label: tooltipText, shortcut: shortcut.shortcut } : undefined); + return () => setTooltipContent(undefined); + }, [setTooltipContent, tooltipText, shortcut.shortcut]); if (!feature || !state) { if (__DEV__) logMissingFeature(displayName, selector.displayName ?? displayName); return null; } - const attrs = { ...core.getAttrs(state), 'aria-keyshortcuts': shortcuts }; + const attrs = { ...core.getAttrs(state), 'aria-keyshortcuts': shortcut.aria }; return renderElement( 'button', @@ -97,7 +110,7 @@ export function createMediaButton, P state, stateAttrMap, ref: [forwardedRef, buttonRef], - props: [attrs, elementProps, getButtonProps()], + props: [getButtonProps(), elementProps, attrs], } ); }); diff --git a/packages/react/src/ui/hotkey/tests/use-hotkey-shortcut.test.tsx b/packages/react/src/ui/hotkey/tests/use-hotkey-shortcut.test.tsx new file mode 100644 index 00000000..dad0e10b --- /dev/null +++ b/packages/react/src/ui/hotkey/tests/use-hotkey-shortcut.test.tsx @@ -0,0 +1,58 @@ +import { render, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { PlayerContextProvider, type PlayerContextValue } from '../../../player/context'; +import { createMockStore } from '../../../testing/mocks'; +import { Hotkey } from '../hotkey'; +import { useHotkeyShortcut } from '../use-hotkey-shortcut'; + +function createContextValue(container: HTMLElement): PlayerContextValue { + return { + store: createMockStore() as any, + media: null, + setMedia: vi.fn(), + container, + setContainer: vi.fn(), + }; +} + +function Wrapper({ children, value }: { children: ReactNode; value: PlayerContextValue }) { + return {children}; +} + +function Shortcut({ keys }: { keys: string }) { + const shortcut = useHotkeyShortcut('togglePaused'); + + return ( + <> + {shortcut.shortcut} + {shortcut.aria} + + + ); +} + +describe('useHotkeyShortcut', () => { + it('updates when hotkey registrations change', async () => { + const container = document.createElement('div'); + const value = createContextValue(container); + const { rerender } = render( + + + + ); + + await waitFor(() => expect(document.querySelector('[data-testid="shortcut"]')?.textContent).toBe('K')); + expect(document.querySelector('[data-testid="aria"]')?.textContent).toBe('k'); + + rerender( + + + + ); + + await waitFor(() => expect(document.querySelector('[data-testid="shortcut"]')?.textContent).toBe('P')); + expect(document.querySelector('[data-testid="aria"]')?.textContent).toBe('p'); + }); +}); diff --git a/packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts b/packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts deleted file mode 100644 index b98b6ae1..00000000 --- a/packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -import { findHotkeyCoordinator } from '@videojs/core/dom'; -import { useMemo } from 'react'; - -import { useContainer } from '../../player/context'; - -export function useAriaKeyShortcuts(action: string | undefined): string | undefined { - const container = useContainer(); - return useMemo(() => { - if (!container || !action) return undefined; - return findHotkeyCoordinator(container)?.getAriaKeys(action); - }, [container, action]); -} diff --git a/packages/react/src/ui/hotkey/use-hotkey-shortcut.ts b/packages/react/src/ui/hotkey/use-hotkey-shortcut.ts new file mode 100644 index 00000000..1f1d2d14 --- /dev/null +++ b/packages/react/src/ui/hotkey/use-hotkey-shortcut.ts @@ -0,0 +1,27 @@ +'use client'; + +import { getHotkeyCoordinator, type HotkeyShortcutDetails } from '@videojs/core/dom'; +import { useEffect, useState } from 'react'; + +import { useContainer } from '../../player/context'; + +export function useHotkeyShortcut(action: string | undefined, value?: number | undefined): HotkeyShortcutDetails { + const container = useContainer(); + + const [shortcut, setShortcut] = useState({}); + + useEffect(() => { + if (!container || !action) { + setShortcut({}); + return; + } + + const coordinator = getHotkeyCoordinator(container); + const update = () => setShortcut(coordinator.getShortcut(action, value)); + + update(); + return coordinator.subscribeShortcutChanges(update); + }, [container, action, value]); + + return shortcut; +} diff --git a/packages/react/src/ui/hotkey/use-hotkey.ts b/packages/react/src/ui/hotkey/use-hotkey.ts index 4f071691..09138c28 100644 --- a/packages/react/src/ui/hotkey/use-hotkey.ts +++ b/packages/react/src/ui/hotkey/use-hotkey.ts @@ -29,5 +29,5 @@ export function useHotkey(options: UseHotkeyOptions): void { disabled, onActivate: (event, key) => onActivateRef.current(event, key), }); - }, [container, keys, target, repeatable, disabled, onActivateRef]); + }, [container, keys, target, repeatable, disabled]); } diff --git a/packages/react/src/ui/live-button/live-button.tsx b/packages/react/src/ui/live-button/live-button.tsx index fe206890..ee1fcf62 100644 --- a/packages/react/src/ui/live-button/live-button.tsx +++ b/packages/react/src/ui/live-button/live-button.tsx @@ -71,7 +71,7 @@ export const LiveButton = forwardRef( useLayoutEffect(() => { if (!tooltipCtx) return; - tooltipCtx.setContent(labelText); + tooltipCtx.setContent(labelText ? { label: labelText } : undefined); return () => tooltipCtx.setContent(undefined); }, [tooltipCtx, labelText]); diff --git a/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx b/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx index e0d14346..179e9d5a 100644 --- a/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx +++ b/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx @@ -32,6 +32,7 @@ export const PlaybackRateButton = createMediaButton core.cycle(state), + hotkeyAction: 'speedUp', }); export namespace PlaybackRateButton { diff --git a/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx b/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx new file mode 100644 index 00000000..da0a67ff --- /dev/null +++ b/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx @@ -0,0 +1,58 @@ +import { render, waitFor } from '@testing-library/react'; +import { popup } from '@videojs/skins/default/tailwind/video.tailwind'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { PlayerContextProvider, type PlayerContextValue } from '../../../player/context'; +import { createMockStore } from '../../../testing/mocks'; +import { Hotkey } from '../../hotkey/hotkey'; +import { Tooltip } from '../../tooltip'; +import { PlaybackRateButton } from '../playback-rate-button'; + +function createContextValue(container: HTMLElement): PlayerContextValue { + return { + store: createMockStore({ + playbackRates: [0.5, 1, 1.5, 2], + playbackRate: 1, + setPlaybackRate: vi.fn(), + }) as any, + media: null, + setMedia: vi.fn(), + container, + setContainer: vi.fn(), + }; +} + +function Wrapper({ children, value }: { children: ReactNode; value: PlayerContextValue }) { + return {children}; +} + +describe('PlaybackRateButton', () => { + it('uses the core label and the speed-up shortcut', async () => { + const container = document.createElement('div'); + const value = createContextValue(container); + + render( + + + } /> + + + + + + + + + ); + + const button = document.querySelector('[data-testid="button"]'); + + await waitFor(() => { + expect(document.querySelector('[data-testid="popup"] span')?.textContent).toBe('Playback rate 1'); + expect(document.querySelector('[data-testid="popup"] kbd')?.textContent).toBe('>'); + }); + expect(button?.getAttribute('aria-label')).toBe('Playback rate 1'); + expect(button?.getAttribute('aria-keyshortcuts')).toBe('>'); + }); +}); diff --git a/packages/react/src/ui/seek-button/seek-button.tsx b/packages/react/src/ui/seek-button/seek-button.tsx index 62d4dc4b..84fd3b9e 100644 --- a/packages/react/src/ui/seek-button/seek-button.tsx +++ b/packages/react/src/ui/seek-button/seek-button.tsx @@ -31,6 +31,8 @@ export const SeekButton = createMediaButton({ stateAttrMap: SeekButtonDataAttrs, selector: selectTime, action: (core, state) => core.seek(state), + hotkeyAction: 'seekStep', + hotkeyValue: (props) => (typeof props.seconds === 'number' ? props.seconds : SeekButtonCore.defaultProps.seconds), }); export namespace SeekButton { diff --git a/packages/react/src/ui/tooltip/context.tsx b/packages/react/src/ui/tooltip/context.tsx index 16c569ab..7d5be754 100644 --- a/packages/react/src/ui/tooltip/context.tsx +++ b/packages/react/src/ui/tooltip/context.tsx @@ -4,6 +4,11 @@ import type { StateAttrMap, TooltipCore } from '@videojs/core'; import type { MediaContainer, PositioningBoundary, TooltipApi } from '@videojs/core/dom'; import { createContext, useContext } from 'react'; +export interface TooltipContent { + label?: string | undefined; + shortcut?: string | undefined; +} + export interface TooltipContextValue { core: TooltipCore; tooltip: TooltipApi; @@ -11,8 +16,8 @@ export interface TooltipContextValue { stateAttrMap: StateAttrMap; anchorName: string; popupId: string; - content: string | undefined; - setContent: (content: string | undefined) => void; + content: TooltipContent | undefined; + setContent: (content: TooltipContent | undefined) => void; boundary: PositioningBoundary; container: MediaContainer | null; } diff --git a/packages/react/src/ui/tooltip/index.parts.ts b/packages/react/src/ui/tooltip/index.parts.ts index 50ba190d..b8eb51c5 100644 --- a/packages/react/src/ui/tooltip/index.parts.ts +++ b/packages/react/src/ui/tooltip/index.parts.ts @@ -1,5 +1,7 @@ export { TooltipArrow as Arrow, type TooltipArrowProps as ArrowProps } from './tooltip-arrow'; +export { TooltipLabel as Label, type TooltipLabelProps as LabelProps } from './tooltip-label'; export { TooltipPopup as Popup, type TooltipPopupProps as PopupProps } from './tooltip-popup'; export { TooltipProvider as Provider, type TooltipProviderProps as ProviderProps } from './tooltip-provider'; export { TooltipRoot as Root, type TooltipRootProps as RootProps } from './tooltip-root'; +export { TooltipShortcut as Shortcut, type TooltipShortcutProps as ShortcutProps } from './tooltip-shortcut'; export { TooltipTrigger as Trigger, type TooltipTriggerProps as TriggerProps } from './tooltip-trigger'; diff --git a/packages/react/src/ui/tooltip/index.ts b/packages/react/src/ui/tooltip/index.ts index 0cae6447..cab84809 100644 --- a/packages/react/src/ui/tooltip/index.ts +++ b/packages/react/src/ui/tooltip/index.ts @@ -1,2 +1,2 @@ -export { type TooltipContextValue, useOptionalTooltipContext, useTooltipContext } from './context'; +export { type TooltipContent, type TooltipContextValue, useOptionalTooltipContext, useTooltipContext } from './context'; export * as Tooltip from './index.parts'; diff --git a/packages/react/src/ui/tooltip/tests/tooltip.test.tsx b/packages/react/src/ui/tooltip/tests/tooltip.test.tsx new file mode 100644 index 00000000..64e5f58b --- /dev/null +++ b/packages/react/src/ui/tooltip/tests/tooltip.test.tsx @@ -0,0 +1,76 @@ +import { render, waitFor } from '@testing-library/react'; +import { popup } from '@videojs/skins/default/tailwind/video.tailwind'; +import { useLayoutEffect } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Tooltip, useOptionalTooltipContext } from '..'; + +function TooltipContent({ label, shortcut }: { label?: string; shortcut?: string }) { + const tooltip = useOptionalTooltipContext(); + const setContent = tooltip?.setContent; + + useLayoutEffect(() => { + setContent?.({ label, shortcut }); + return () => setContent?.(undefined); + }, [setContent, label, shortcut]); + + return null; +} + +describe('Tooltip', () => { + it('renders label and kbd shortcut with skin popup.tooltipShortcut from context', async () => { + const { container } = render( + + + + + + + + ); + + await waitFor(() => { + expect(container.querySelector('[data-testid="popup"] span')?.textContent).toBe('Play'); + const hint = container.querySelector('[data-testid="popup"] kbd'); + expect(hint?.textContent).toBe('K'); + expect(hint?.localName).toBe('kbd'); + }); + expect(container.querySelector('[data-testid="popup"] span')?.getAttribute('class')).toBeNull(); + }); + + it('omits shortcut without a shortcut value', async () => { + const { container } = render( + + + + + + + + ); + + await waitFor(() => expect(container.querySelector('[data-testid="popup"] span')?.textContent).toBe('Play')); + expect(container.querySelector('[data-testid="popup"] kbd')).toBeNull(); + }); + + it('passes TooltipState to custom popup render functions', async () => { + const { container } = render( + + + ( +
+ + +
+ )} + /> +
+ ); + + await waitFor(() => + expect(container.querySelector('[data-testid="popup"]')?.getAttribute('data-open')).toBe('true') + ); + expect(container.querySelector('[data-testid="popup"]')?.getAttribute('data-side')).toBe('top'); + }); +}); diff --git a/packages/react/src/ui/tooltip/tooltip-label.tsx b/packages/react/src/ui/tooltip/tooltip-label.tsx new file mode 100644 index 00000000..fb1fdc0b --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-label.tsx @@ -0,0 +1,35 @@ +'use client'; + +import type { TooltipState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useTooltipContext } from './context'; + +export interface TooltipLabelProps extends UIComponentProps<'span', TooltipState> {} + +/** Tooltip body label; defaults to context `content.label` from the linked trigger. */ +export const TooltipLabel = forwardRef(function TooltipLabel( + { render, className, style, children, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap, content } = useTooltipContext(); + const body = children !== undefined ? children : (content?.label ?? ''); + + return renderElement( + 'span', + { render, className, style }, + { + state, + stateAttrMap, + ref: forwardedRef, + props: [elementProps, { children: body }], + } + ); +}); + +export namespace TooltipLabel { + export type Props = TooltipLabelProps; + export type State = TooltipState; +} diff --git a/packages/react/src/ui/tooltip/tooltip-popup.tsx b/packages/react/src/ui/tooltip/tooltip-popup.tsx index 4e32f34f..badfc5ad 100644 --- a/packages/react/src/ui/tooltip/tooltip-popup.tsx +++ b/packages/react/src/ui/tooltip/tooltip-popup.tsx @@ -1,7 +1,6 @@ 'use client'; -import type { TooltipState } from '@videojs/core'; -import { TooltipCSSVars } from '@videojs/core'; +import { TooltipCSSVars, type TooltipState } from '@videojs/core'; import { getAnchorPositionStyle, getPopupPositionRect, @@ -18,6 +17,8 @@ import type { UIComponentProps } from '../../utils/types'; import { useComposedRefs } from '../../utils/use-composed-refs'; import { renderElement } from '../../utils/use-render'; import { useTooltipContext } from './context'; +import { TooltipLabel } from './tooltip-label'; +import { TooltipShortcut } from './tooltip-shortcut'; export interface TooltipPopupProps extends UIComponentProps<'div', TooltipState> {} @@ -25,10 +26,10 @@ const POPUP_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 /** Container for the tooltip content. Positioned relative to the trigger using CSS anchor positioning with a JavaScript fallback. */ export const TooltipPopup = forwardRef(function TooltipPopup( - { render, className, style, ...elementProps }, + { render, className, style, children, ...elementProps }, forwardedRef ) { - const { core, tooltip, state, stateAttrMap, anchorName, popupId, content, boundary, container } = useTooltipContext(); + const { core, tooltip, state, stateAttrMap, anchorName, popupId, boundary, container } = useTooltipContext(); const internalRef = useRef(null); const popupRef = useCallback( @@ -49,7 +50,7 @@ export const TooltipPopup = forwardRef(functi // CSS Anchor Positioning — computed from state, no measurement needed. // `position-anchor` is set imperatively in the ref callback above - // because React's style prop silently drops unrecognised CSS properties. + // because React's style prop silently drops unrecognized CSS properties. const anchorStyle = useMemo(() => { if (!supportsAnchorPositioning()) return null; const { positionAnchor: _, ...rest } = getAnchorPositionStyle( @@ -159,6 +160,16 @@ export const TooltipPopup = forwardRef(functi return null; } + const body = + children !== undefined ? ( + children + ) : ( + <> + + + + ); + // Remap DOM focus events to React synthetic event names. const { onFocusOut, ...restPopupProps } = tooltip.popupProps; @@ -175,8 +186,7 @@ export const TooltipPopup = forwardRef(functi style: positioningStyle, ...core.getPopupAttrs(state), }, - // Forwarded content as default children — explicit children override. - { children: content }, + { children: body }, { ...restPopupProps, onBlur: onFocusOut }, elementProps, ], diff --git a/packages/react/src/ui/tooltip/tooltip-root.tsx b/packages/react/src/ui/tooltip/tooltip-root.tsx index a51b89dc..6067caa3 100644 --- a/packages/react/src/ui/tooltip/tooltip-root.tsx +++ b/packages/react/src/ui/tooltip/tooltip-root.tsx @@ -16,7 +16,7 @@ import { useDestroy } from '../../utils/use-destroy'; import { useLatestRef } from '../../utils/use-latest-ref'; import { useSafeId } from '../../utils/use-safe-id'; import { useOptionalControlsContext } from '../controls/context'; -import { TooltipContextProvider } from './context'; +import { type TooltipContent, TooltipContextProvider } from './context'; import { useTooltipGroup } from './group-context'; export interface TooltipRootProps extends CoreTooltipProps { @@ -85,7 +85,7 @@ export function TooltipRoot({ return instance; }); - const [content, setContent] = useState(); + const [content, setContent] = useState(); const anchorName = useSafeId(); const popupId = useSafeId('tooltip'); diff --git a/packages/react/src/ui/tooltip/tooltip-shortcut.tsx b/packages/react/src/ui/tooltip/tooltip-shortcut.tsx new file mode 100644 index 00000000..596c2cde --- /dev/null +++ b/packages/react/src/ui/tooltip/tooltip-shortcut.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { TooltipState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useTooltipContext } from './context'; + +export interface TooltipShortcutProps extends UIComponentProps<'kbd', TooltipState> {} + +/** Keyboard shortcut hint; apply skin `className` (CSS: `media-tooltip__kbd`; Tailwind: `popup.tooltipShortcut`). */ +export const TooltipShortcut = forwardRef(function TooltipShortcut( + { render, className, style, children, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap, content } = useTooltipContext(); + const shortcut = children !== undefined && children !== null ? children : (content?.shortcut ?? null); + + if (!shortcut) { + return null; + } + + return renderElement( + 'kbd', + { render, className, style }, + { + state, + stateAttrMap, + ref: forwardedRef, + props: [elementProps, { children: shortcut }], + } + ); +}); + +export namespace TooltipShortcut { + export type Props = TooltipShortcutProps; + export type State = TooltipState; +} diff --git a/packages/skins/src/default/css/components/popup.css b/packages/skins/src/default/css/components/popup.css index 8709ec11..81df1ec8 100644 --- a/packages/skins/src/default/css/components/popup.css +++ b/packages/skins/src/default/css/components/popup.css @@ -100,6 +100,13 @@ white-space: nowrap; border-radius: calc(infinity * 1px); + /* `display: flex` must not apply while closed — it overrides UA `[popover]` hiding. */ + &[data-open] { + display: flex; + column-gap: 0.25rem; + align-items: center; + } + &[data-side="top"]::before, &[data-side="bottom"]::before { height: var(--media-tooltip-side-offset); @@ -108,4 +115,16 @@ &[data-side="right"]::before { width: var(--media-tooltip-side-offset); } + + & .media-tooltip__kbd { + min-width: 1.5em; + padding: 0.1em; + font-family: inherit; + font-size: 90%; + font-weight: 600; + line-height: 1.25; + text-align: center; + background-color: oklch(from currentColor l c h / 0.3); + border-radius: 0.25rem; + } } diff --git a/packages/skins/src/default/css/components/slider.css b/packages/skins/src/default/css/components/slider.css index 905017fa..3116caf1 100644 --- a/packages/skins/src/default/css/components/slider.css +++ b/packages/skins/src/default/css/components/slider.css @@ -85,7 +85,7 @@ inset: -4px; content: ""; border-radius: inherit; - box-shadow: 0 0 0 2px oklch(1 0 0); + box-shadow: 0 0 0 2px currentColor; transition-timing-function: ease-out; transition-duration: 150ms; transition-property: opacity, scale; diff --git a/packages/skins/src/default/tailwind/components/popup.ts b/packages/skins/src/default/tailwind/components/popup.ts index 5dc4a906..c56e6074 100644 --- a/packages/skins/src/default/tailwind/components/popup.ts +++ b/packages/skins/src/default/tailwind/components/popup.ts @@ -29,8 +29,13 @@ export const popup = { tooltip: cn( base, 'py-1 px-2.5 rounded-full text-[0.75rem] whitespace-nowrap', + /* Flex only while open — unconditional `flex` overrides UA `[popover]` `display:none`. */ + 'data-[open]:flex data-[open]:items-center data-[open]:gap-1', 'data-[side=top]:before:h-(--media-tooltip-side-offset) data-[side=bottom]:before:h-(--media-tooltip-side-offset)', 'data-[side=left]:before:w-(--media-tooltip-side-offset) data-[side=right]:before:w-(--media-tooltip-side-offset)' ), volume: 'py-3 px-0 rounded-full', + tooltipShortcut: cn( + 'min-w-[1.5em] p-[0.1em] bg-current/30 text-[90%] font-semibold font-[inherit] leading-[1.25] text-center rounded' + ), }; diff --git a/packages/skins/src/default/tailwind/components/slider.ts b/packages/skins/src/default/tailwind/components/slider.ts index 15901ef5..40fb2109 100644 --- a/packages/skins/src/default/tailwind/components/slider.ts +++ b/packages/skins/src/default/tailwind/components/slider.ts @@ -22,9 +22,11 @@ export const slider = { // Horizontal 'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0', 'data-[orientation=horizontal]:w-(--media-slider-fill)', + 'group-data-dragging/slider:data-[orientation=horizontal]:w-(--media-slider-pointer)', // Vertical 'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0', - 'data-[orientation=vertical]:h-(--media-slider-fill)' + 'data-[orientation=vertical]:h-(--media-slider-fill)', + 'group-data-dragging/slider:data-[orientation=vertical]:h-(--media-slider-pointer)' ), buffer: cn( 'bg-current/20 duration-250 ease-out', @@ -45,10 +47,18 @@ export const slider = { 'outline-4 outline-transparent -outline-offset-4', 'hover:outline-current/15 hover:outline-offset-0', 'focus-visible:outline-current/15 focus-visible:outline-offset-0', + // Focus ring via ::after (matches default slider.css) + 'after:absolute after:-inset-1 after:rounded-[inherit]', + 'after:shadow-[0_0_0_2px_currentColor] after:pointer-events-none', + 'after:transition-[opacity,scale] after:duration-150 after:ease-out', + 'after:opacity-0 after:scale-50', + 'focus-visible:after:opacity-100 focus-visible:after:scale-100', // Horizontal 'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)', + 'group-data-dragging/slider:data-[orientation=horizontal]:left-(--media-slider-pointer)', // Vertical - 'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]' + 'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]', + 'group-data-dragging/slider:data-[orientation=vertical]:top-[calc(100%-var(--media-slider-pointer))]' ), persistent: 'size-3', interactive: cn( diff --git a/packages/skins/src/minimal/css/components/popup.css b/packages/skins/src/minimal/css/components/popup.css index 97f23a69..846585b5 100644 --- a/packages/skins/src/minimal/css/components/popup.css +++ b/packages/skins/src/minimal/css/components/popup.css @@ -98,6 +98,13 @@ 0 2px 4px -2px oklch(0 0 0 / 0.1); backdrop-filter: var(--media-tooltip-backdrop-filter); + /* `display: flex` must not apply while closed — it overrides UA `[popover]` hiding. */ + &[data-open] { + display: flex; + column-gap: 0.25rem; + align-items: center; + } + &[data-side="top"]::before, &[data-side="bottom"]::before { height: var(--media-tooltip-side-offset); @@ -106,6 +113,19 @@ &[data-side="right"]::before { width: var(--media-tooltip-side-offset); } + + & .media-tooltip__kbd { + min-width: 1.5em; + padding: 0.1em; + margin-right: -0.25rem; + font-family: inherit; + font-size: 90%; + font-weight: 600; + line-height: 1.25; + text-align: center; + background-color: oklch(from currentColor l c h / 0.15); + border-radius: 0.25rem; + } } .media-minimal-skin .media-popover--volume:has(media-volume-slider[data-availability="unsupported"]) { diff --git a/packages/skins/src/minimal/tailwind/components/popup.ts b/packages/skins/src/minimal/tailwind/components/popup.ts index dd1ef0d6..784707bf 100644 --- a/packages/skins/src/minimal/tailwind/components/popup.ts +++ b/packages/skins/src/minimal/tailwind/components/popup.ts @@ -30,10 +30,14 @@ export const popup = { tooltip: cn( base, 'px-2 py-1 rounded-lg text-[0.75rem] whitespace-nowrap', + 'data-[open]:flex data-[open]:items-center data-[open]:gap-1', 'bg-(--media-tooltip-background-color) [backdrop-filter:var(--media-tooltip-backdrop-filter)]', 'ring-1 ring-(color:--media-tooltip-border-color) shadow-md shadow-black/10', 'text-(--media-tooltip-text-color)', 'data-[side=top]:before:h-(--media-tooltip-side-offset) data-[side=bottom]:before:h-(--media-tooltip-side-offset)', 'data-[side=left]:before:w-(--media-tooltip-side-offset) data-[side=right]:before:w-(--media-tooltip-side-offset)' ), + tooltipShortcut: cn( + 'min-w-[1.5em] -mr-1 p-[0.1em] bg-current/15 text-[90%] font-semibold font-[inherit] leading-[1.25] text-center rounded' + ), }; diff --git a/packages/skins/src/minimal/tailwind/components/slider.ts b/packages/skins/src/minimal/tailwind/components/slider.ts index 12ba821a..36e452f4 100644 --- a/packages/skins/src/minimal/tailwind/components/slider.ts +++ b/packages/skins/src/minimal/tailwind/components/slider.ts @@ -22,9 +22,11 @@ export const slider = { // Horizontal 'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0', 'data-[orientation=horizontal]:w-(--media-slider-fill,0)', + 'group-data-dragging/slider:data-[orientation=horizontal]:w-(--media-slider-pointer)', // Vertical 'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0', - 'data-[orientation=vertical]:h-(--media-slider-fill,0)' + 'data-[orientation=vertical]:h-(--media-slider-fill,0)', + 'group-data-dragging/slider:data-[orientation=vertical]:h-(--media-slider-pointer)' ), buffer: cn( 'bg-current/20 duration-250 ease-out', @@ -46,8 +48,10 @@ export const slider = { 'focus-visible:outline-current focus-visible:outline-offset-2', // Horizontal 'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill,0)', + 'group-data-dragging/slider:data-[orientation=horizontal]:left-(--media-slider-pointer)', // Vertical - 'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill,0))]' + 'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill,0))]', + 'group-data-dragging/slider:data-[orientation=vertical]:top-[calc(100%-var(--media-slider-pointer))]' ), interactive: cn( 'opacity-0 scale-70 origin-center', diff --git a/site/src/components/docs/demos/tooltip/react/css/BasicUsage.tsx b/site/src/components/docs/demos/tooltip/react/css/BasicUsage.tsx index 794ff19d..86cf06f0 100644 --- a/site/src/components/docs/demos/tooltip/react/css/BasicUsage.tsx +++ b/site/src/components/docs/demos/tooltip/react/css/BasicUsage.tsx @@ -7,7 +7,7 @@ export default function BasicUsage() { Hover me - Tooltip content + Tooltip content diff --git a/site/src/components/docs/demos/tooltip/react/css/Grouping.tsx b/site/src/components/docs/demos/tooltip/react/css/Grouping.tsx index 4dfe7804..6b813897 100644 --- a/site/src/components/docs/demos/tooltip/react/css/Grouping.tsx +++ b/site/src/components/docs/demos/tooltip/react/css/Grouping.tsx @@ -8,21 +8,21 @@ export default function Grouping() { Play - Play video + Play video Mute - Mute audio + Mute audio Fullscreen - Enter fullscreen + Enter fullscreen