feat(packages): compound tooltips with label and shortcut parts (#1494)

This commit is contained in:
Sam Potts
2026-06-18 08:12:16 +10:00
committed by GitHub
parent 9275f93504
commit 035b509c7a
82 changed files with 1509 additions and 238 deletions
@@ -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;
}
}
@@ -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();
});
});
+26 -2
View File
@@ -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<Core extends MediaButtonComponent> 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<ButtonState> {
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<Core extends MediaButtonComponent> 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<Core extends MediaButtonComponent> 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));
}
}
@@ -21,6 +21,7 @@ export class PlaybackRateButtonElement extends MediaButtonElement<PlaybackRateBu
protected readonly core = new PlaybackRateButtonCore();
protected readonly stateAttrMap = PlaybackRateButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
protected override readonly hotkeyAction = 'speedUp';
protected activate(state: MediaPlaybackRateState, event?: UIEvent): void {
if (this.commandfor) {
@@ -19,6 +19,11 @@ export class SeekButtonElement extends MediaButtonElement<SeekButtonCore> {
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);
@@ -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<Element extends HTMLElement>(Base: abstract new () => Element): Element {
const tag = uniqueTag('test-el');
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
return document.createElement(tag) as Element;
}
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');
});
});
@@ -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<Element extends HTMLElement>(Base: abstract new () => Element): Element {
const tag = uniqueTag('test-el');
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
return document.createElement(tag) as Element;
}
class TestTriggerElement extends HTMLElement {
$state = createState<ButtonState>({ 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);
});
});
@@ -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<ButtonState>;
};
@@ -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 {
@@ -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;
}
}
@@ -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;
}
}
}