mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): compound tooltips with label and shortcut parts (#1494)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user