From ad831d25f00187929e6eed93770422fa7003071c Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Thu, 14 May 2026 08:37:52 +1000 Subject: [PATCH] feat(packages): add playback rate menu (#1527) --- internal/design/ui/menus.md | 8 +- packages/core/src/core/index.ts | 3 + .../playback-rate-menu-core.ts | 116 +++++++++ .../playback-rate-menu-data-attrs.ts | 9 + .../tests/playback-rate-menu-core.test.ts | 133 ++++++++++ .../src/core/ui/popover/popover-css-vars.ts | 2 + .../src/core/ui/popover/popup-host-attr.ts | 9 + .../src/core/ui/tooltip/tooltip-css-vars.ts | 2 + .../src/dom/gesture/tests/gesture.test.ts | 16 ++ packages/core/src/dom/hotkey/coordinator.ts | 2 + .../core/src/dom/hotkey/tests/hotkey.test.ts | 14 ++ packages/core/src/dom/index.ts | 1 + packages/core/src/dom/ui/menu/create-menu.ts | 45 +++- .../src/dom/ui/menu/tests/create-menu.test.ts | 165 ++++++++++++ .../src/dom/ui/popover/popover-positioning.ts | 110 ++++++-- packages/core/src/dom/ui/popover/popover.ts | 22 +- .../core/src/dom/ui/popover/popup-group.ts | 27 ++ .../popover/tests/popover-positioning.test.ts | 59 ++++- .../src/dom/ui/popover/tests/popover.test.ts | 71 +++++- .../src/dom/ui/tooltip/tests/tooltip.test.ts | 11 + packages/core/src/dom/ui/tooltip/tooltip.ts | 10 +- packages/core/src/dom/utils/event.ts | 12 + packages/core/src/dom/utils/index.ts | 11 +- packages/core/src/dom/utils/layout.ts | 58 +++++ .../core/src/dom/utils/tests/event.test.ts | 32 +++ .../core/src/dom/utils/tests/layout.test.ts | 102 ++++++++ .../src/define/audio/minimal-skin.tailwind.ts | 28 ++- .../html/src/define/audio/minimal-skin.ts | 24 +- packages/html/src/define/audio/minimal-ui.ts | 11 +- .../html/src/define/audio/skin.tailwind.ts | 24 +- packages/html/src/define/audio/skin.ts | 23 +- packages/html/src/define/audio/ui.ts | 9 +- .../live-audio/minimal-skin.tailwind.ts | 4 +- .../src/define/live-audio/minimal-skin.ts | 4 +- .../src/define/live-audio/skin.tailwind.ts | 4 +- packages/html/src/define/live-audio/skin.ts | 4 +- .../html/src/define/ui/playback-rate-menu.ts | 18 ++ .../src/define/video/minimal-skin.tailwind.ts | 18 +- .../html/src/define/video/minimal-skin.ts | 15 +- packages/html/src/define/video/minimal-ui.ts | 10 +- .../html/src/define/video/skin.tailwind.ts | 16 +- packages/html/src/define/video/skin.ts | 15 +- packages/html/src/define/video/ui.ts | 8 + packages/html/src/index.ts | 3 + packages/html/src/player/context.ts | 3 +- packages/html/src/store/provider-mixin.ts | 27 +- .../html/src/ui/controls/controls-element.ts | 20 +- .../controls/tests/controls-element.test.ts | 170 +++++++++++++ .../src/ui/menu/menu-checkbox-item-element.ts | 2 +- packages/html/src/ui/menu/menu-element.ts | 69 ++++- .../html/src/ui/menu/menu-item-element.ts | 2 +- .../src/ui/menu/menu-radio-group-element.ts | 2 +- .../src/ui/menu/menu-radio-item-element.ts | 2 +- .../src/ui/menu/tests/menu-element.test.ts | 175 +++++++++++++ .../playback-rate-menu-element.ts | 62 +++++ .../playback-rate-menu-trigger-element.ts | 100 ++++++++ .../playback-rate-options-element.ts | 147 +++++++++++ .../tests/playback-rate-menu-element.test.ts | 235 ++++++++++++++++++ .../html/src/ui/popover/popover-element.ts | 41 ++- packages/html/src/ui/position-controller.ts | 19 +- .../html/src/ui/tooltip/tooltip-element.ts | 44 +++- .../icons/src/assets/default/cast-enter.svg | 15 +- .../icons/src/assets/default/cast-exit.svg | 18 +- packages/icons/src/assets/default/check.svg | 3 + .../icons/src/assets/default/pip-enter.svg | 2 +- packages/icons/src/assets/default/restart.svg | 2 +- packages/icons/src/assets/default/seek.svg | 2 +- packages/icons/src/assets/default/spinner.svg | 28 +-- .../icons/src/assets/minimal/cast-enter.svg | 15 +- .../icons/src/assets/minimal/cast-exit.svg | 18 +- packages/icons/src/assets/minimal/check.svg | 3 + packages/icons/src/assets/minimal/spinner.svg | 28 +-- packages/react/src/index.ts | 7 + packages/react/src/player/context.tsx | 15 +- packages/react/src/player/create-player.tsx | 4 +- .../react/src/player/tests/context.test.tsx | 19 ++ .../presets/audio/minimal-skin.tailwind.tsx | 42 +++- .../react/src/presets/audio/minimal-skin.tsx | 62 ++++- .../react/src/presets/audio/skin.tailwind.tsx | 71 +++++- packages/react/src/presets/audio/skin.tsx | 55 +++- .../live-audio/minimal-skin.tailwind.tsx | 16 +- .../src/presets/live-audio/minimal-skin.tsx | 16 +- .../src/presets/live-audio/skin.tailwind.tsx | 16 +- .../react/src/presets/live-audio/skin.tsx | 8 +- .../live-video/minimal-skin.tailwind.tsx | 2 +- .../src/presets/live-video/minimal-skin.tsx | 2 +- .../src/presets/live-video/skin.tailwind.tsx | 52 +++- .../react/src/presets/live-video/skin.tsx | 2 +- .../presets/video/minimal-skin.tailwind.tsx | 36 ++- .../react/src/presets/video/minimal-skin.tsx | 35 ++- .../react/src/presets/video/skin.tailwind.tsx | 34 ++- packages/react/src/presets/video/skin.tsx | 35 ++- packages/react/src/ui/controls/context.tsx | 4 + packages/react/src/ui/menu/context.tsx | 4 +- .../react/src/ui/menu/menu-checkbox-item.tsx | 2 +- packages/react/src/ui/menu/menu-content.tsx | 108 ++++++-- packages/react/src/ui/menu/menu-item.tsx | 2 +- .../react/src/ui/menu/menu-radio-item.tsx | 2 +- packages/react/src/ui/menu/menu-root.tsx | 35 ++- packages/react/src/ui/menu/menu-trigger.tsx | 36 ++- .../react/src/ui/menu/tests/menu.test.tsx | 171 ++++++++++++- .../src/ui/playback-rate-menu/context.tsx | 28 +++ .../src/ui/playback-rate-menu/index.parts.ts | 16 ++ .../react/src/ui/playback-rate-menu/index.ts | 6 + .../playback-rate-menu-content.tsx | 41 +++ .../playback-rate-menu-root.tsx | 49 ++++ .../playback-rate-menu-trigger.tsx | 58 +++++ .../tests/playback-rate-menu.test.tsx | 130 ++++++++++ .../use-playback-rate-menu.ts | 50 ++++ packages/react/src/ui/popover/context.tsx | 4 +- .../react/src/ui/popover/popover-popup.tsx | 46 +++- .../react/src/ui/popover/popover-root.tsx | 34 ++- .../react/src/ui/status-announcer/index.ts | 1 + packages/react/src/ui/tooltip/context.tsx | 4 +- .../react/src/ui/tooltip/tooltip-popup.tsx | 55 ++-- .../react/src/ui/tooltip/tooltip-root.tsx | 40 ++- packages/skins/src/default/css/audio.css | 3 + .../src/default/css/components/button.css | 6 +- .../src/default/css/components/menus.css | 95 +++++++ .../src/default/css/components/popup.css | 3 +- .../skins/src/default/css/components/root.css | 9 +- packages/skins/src/default/css/video.css | 7 +- .../src/default/tailwind/audio.tailwind.ts | 3 + .../src/default/tailwind/components/menu.ts | 31 +++ .../tailwind/components/playback-rate.ts | 2 +- .../src/default/tailwind/components/root.ts | 3 +- .../src/default/tailwind/video.tailwind.ts | 4 + packages/skins/src/minimal/css/audio.css | 3 + .../src/minimal/css/components/button.css | 6 +- .../src/minimal/css/components/menus.css | 72 ++++++ .../skins/src/minimal/css/components/root.css | 9 +- packages/skins/src/minimal/css/video.css | 13 +- .../src/minimal/tailwind/audio.tailwind.ts | 4 + .../src/minimal/tailwind/components/menu.ts | 21 ++ .../tailwind/components/playback-rate.ts | 2 +- .../src/minimal/tailwind/components/root.ts | 3 +- .../src/minimal/tailwind/video.tailwind.ts | 9 +- packages/store/src/core/store.ts | 2 +- packages/store/src/core/tests/store.test.ts | 9 +- packages/utils/src/dom/interactive.ts | 16 +- .../utils/src/dom/tests/interactive.test.ts | 55 +++- 141 files changed, 3990 insertions(+), 397 deletions(-) create mode 100644 packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts create mode 100644 packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts create mode 100644 packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts create mode 100644 packages/core/src/core/ui/popover/popup-host-attr.ts create mode 100644 packages/core/src/dom/ui/popover/popup-group.ts create mode 100644 packages/core/src/dom/utils/event.ts create mode 100644 packages/core/src/dom/utils/tests/event.test.ts create mode 100644 packages/core/src/dom/utils/tests/layout.test.ts create mode 100644 packages/html/src/define/ui/playback-rate-menu.ts create mode 100644 packages/html/src/ui/controls/tests/controls-element.test.ts create mode 100644 packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts create mode 100644 packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts create mode 100644 packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts create mode 100644 packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts create mode 100644 packages/icons/src/assets/default/check.svg create mode 100644 packages/icons/src/assets/minimal/check.svg create mode 100644 packages/react/src/ui/playback-rate-menu/context.tsx create mode 100644 packages/react/src/ui/playback-rate-menu/index.parts.ts create mode 100644 packages/react/src/ui/playback-rate-menu/index.ts create mode 100644 packages/react/src/ui/playback-rate-menu/playback-rate-menu-content.tsx create mode 100644 packages/react/src/ui/playback-rate-menu/playback-rate-menu-root.tsx create mode 100644 packages/react/src/ui/playback-rate-menu/playback-rate-menu-trigger.tsx create mode 100644 packages/react/src/ui/playback-rate-menu/tests/playback-rate-menu.test.tsx create mode 100644 packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts create mode 100644 packages/react/src/ui/status-announcer/index.ts create mode 100644 packages/skins/src/default/css/components/menus.css create mode 100644 packages/skins/src/default/tailwind/components/menu.ts create mode 100644 packages/skins/src/minimal/css/components/menus.css create mode 100644 packages/skins/src/minimal/tailwind/components/menu.ts diff --git a/internal/design/ui/menus.md b/internal/design/ui/menus.md index 7a64b947..f05daea9 100644 --- a/internal/design/ui/menus.md +++ b/internal/design/ui/menus.md @@ -244,6 +244,7 @@ Button that navigates back to the parent view. Placed at the top of a submenu `C **ARIA (automatic):** `aria-label` from `label` prop. **Behavior:** + - Click pops the navigation stack. - `ArrowLeft` anywhere in the submenu also pops (handled by Content). - After pop, focus returns to the `Trigger` that navigated forward. @@ -503,12 +504,7 @@ media-menu { } /* Menu open/close — fade + slight scale */ -@starting-style { - media-menu[data-open] { - opacity: 0; - transform: scale(0.97); - } -} +media-menu[data-starting-style], media-menu[data-ending-style] { opacity: 0; transform: scale(0.97); diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 161e1a30..f00e5fcd 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -38,9 +38,12 @@ export * from './ui/play-button/play-button-core'; export * from './ui/play-button/play-button-data-attrs'; export * from './ui/playback-rate-button/playback-rate-button-core'; export * from './ui/playback-rate-button/playback-rate-button-data-attrs'; +export * from './ui/playback-rate-menu/playback-rate-menu-core'; +export * from './ui/playback-rate-menu/playback-rate-menu-data-attrs'; export * from './ui/popover/popover-core'; export * from './ui/popover/popover-css-vars'; export * from './ui/popover/popover-data-attrs'; +export * from './ui/popover/popup-host-attr'; export * from './ui/poster/poster-core'; export * from './ui/poster/poster-data-attrs'; export * from './ui/seek-button/seek-button-core'; diff --git a/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts b/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts new file mode 100644 index 00000000..5f9f9624 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-core.ts @@ -0,0 +1,116 @@ +import { createState } from '@videojs/store'; +import { defaults } from '@videojs/utils/object'; +import { isFunction, isUndefined } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaPlaybackRateState } from '../../media/state'; +import type { ButtonState } from '../types'; + +export interface PlaybackRateMenuProps { + /** Custom label for the menu trigger. */ + label?: string | ((state: PlaybackRateMenuState) => string) | undefined; + /** Custom formatter for visible playback rate labels. */ + formatRate?: ((rate: number) => string) | undefined; + /** Whether playback rate selection is disabled. */ + disabled?: boolean | undefined; +} + +export interface PlaybackRateMenuState extends ButtonState { + rate: number; + rates: readonly number[]; + disabled: boolean; +} + +function formatPlaybackRate(rate: number): string { + return `${rate}×`; +} + +export class PlaybackRateMenuCore { + static readonly defaultProps: NonNullableObject = { + label: '', + formatRate: formatPlaybackRate, + disabled: false, + }; + + readonly state = createState({ + rate: 1, + rates: [], + disabled: false, + label: '', + }); + + #props = { ...PlaybackRateMenuCore.defaultProps }; + #media: MediaPlaybackRateState | null = null; + + constructor(props?: PlaybackRateMenuProps) { + if (props) this.setProps(props); + } + + setProps(props: PlaybackRateMenuProps): void { + this.#props = defaults(props, PlaybackRateMenuCore.defaultProps); + } + + getLabel(state: PlaybackRateMenuState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + return `Playback rate ${state.rate}`; + } + + getRateLabel(rate: number): string { + return this.#props.formatRate(rate); + } + + getRateValue(rate: number): string { + return String(rate); + } + + getAttrs(state: PlaybackRateMenuState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': state.disabled ? 'true' : undefined, + }; + } + + setMedia(media: MediaPlaybackRateState): void { + this.#media = media; + } + + getState(): PlaybackRateMenuState { + const media = this.#media!; + + this.state.patch({ + rate: media.playbackRate, + rates: media.playbackRates, + disabled: this.#props.disabled || media.playbackRates.length === 0, + }); + this.state.patch({ label: this.getLabel(this.state.current) }); + + return this.state.current; + } + + select(media: MediaPlaybackRateState, rate: number): void { + if (this.#props.disabled) return; + if (!media.playbackRates.includes(rate)) return; + + media.setPlaybackRate(rate); + } + + selectValue(media: MediaPlaybackRateState, value: string): void { + const rate = media.playbackRates.find((candidate) => this.getRateValue(candidate) === value); + if (isUndefined(rate)) return; + + this.select(media, rate); + } +} + +export namespace PlaybackRateMenuCore { + export type Props = PlaybackRateMenuProps; + export type State = PlaybackRateMenuState; +} diff --git a/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts b/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts new file mode 100644 index 00000000..0f106066 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-menu/playback-rate-menu-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { PlaybackRateMenuState } from './playback-rate-menu-core'; + +export const PlaybackRateMenuDataAttrs = { + /** Current playback rate. */ + rate: 'data-rate', + /** Present when playback rate selection is disabled. */ + disabled: 'data-disabled', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts b/packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts new file mode 100644 index 00000000..32c70058 --- /dev/null +++ b/packages/core/src/core/ui/playback-rate-menu/tests/playback-rate-menu-core.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaPlaybackRateState } from '../../../media/state'; +import type { PlaybackRateMenuState } from '../playback-rate-menu-core'; +import { PlaybackRateMenuCore } from '../playback-rate-menu-core'; + +function createMediaState(overrides: Partial = {}): MediaPlaybackRateState { + return { + playbackRates: [0.5, 1, 1.5, 2], + playbackRate: 1, + setPlaybackRate: vi.fn(), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): PlaybackRateMenuState { + return { + rate: 1, + rates: [0.5, 1, 1.5, 2], + disabled: false, + label: '', + ...overrides, + }; +} + +describe('PlaybackRateMenuCore', () => { + describe('getState', () => { + it('projects playbackRate and playbackRates', () => { + const core = new PlaybackRateMenuCore(); + const media = createMediaState({ playbackRate: 1.5, playbackRates: [1, 1.5] }); + core.setMedia(media); + const state = core.getState(); + + expect(state.rate).toBe(1.5); + expect(state.rates).toEqual([1, 1.5]); + }); + + it('marks state disabled when no rates are available', () => { + const core = new PlaybackRateMenuCore(); + const media = createMediaState({ playbackRates: [] }); + core.setMedia(media); + + expect(core.getState().disabled).toBe(true); + }); + }); + + describe('getLabel', () => { + it('returns default label with rate', () => { + const core = new PlaybackRateMenuCore(); + expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5'); + }); + + it('returns custom string label', () => { + const core = new PlaybackRateMenuCore({ label: 'Speed' }); + expect(core.getLabel(createState())).toBe('Speed'); + }); + + it('returns custom function label', () => { + const core = new PlaybackRateMenuCore({ + label: (state) => `${state.rate}× speed`, + }); + expect(core.getLabel(createState({ rate: 2 }))).toBe('2× speed'); + }); + }); + + describe('getRateLabel', () => { + it('formats rate labels by default', () => { + const core = new PlaybackRateMenuCore(); + expect(core.getRateLabel(1.5)).toBe('1.5×'); + }); + + it('uses a custom formatter', () => { + const core = new PlaybackRateMenuCore({ + formatRate: (rate) => (rate === 1 ? 'Normal' : `${rate}×`), + }); + + expect(core.getRateLabel(1)).toBe('Normal'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label', () => { + const core = new PlaybackRateMenuCore(); + const attrs = core.getAttrs(createState({ rate: 1.5 })); + expect(attrs['aria-label']).toBe('Playback rate 1.5'); + }); + + it('sets aria-disabled when disabled', () => { + const core = new PlaybackRateMenuCore(); + const attrs = core.getAttrs(createState({ disabled: true })); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('select', () => { + it('sets a rate from the available list', () => { + const core = new PlaybackRateMenuCore(); + const media = createMediaState(); + core.select(media, 1.5); + expect(media.setPlaybackRate).toHaveBeenCalledWith(1.5); + }); + + it('does nothing when disabled', () => { + const core = new PlaybackRateMenuCore({ disabled: true }); + const media = createMediaState(); + core.select(media, 1.5); + expect(media.setPlaybackRate).not.toHaveBeenCalled(); + }); + + it('does nothing for unavailable rates', () => { + const core = new PlaybackRateMenuCore(); + const media = createMediaState(); + core.select(media, 3); + expect(media.setPlaybackRate).not.toHaveBeenCalled(); + }); + }); + + describe('selectValue', () => { + it('sets the rate matching a menu value', () => { + const core = new PlaybackRateMenuCore(); + const media = createMediaState(); + core.selectValue(media, '2'); + expect(media.setPlaybackRate).toHaveBeenCalledWith(2); + }); + + it('does nothing for an unknown menu value', () => { + const core = new PlaybackRateMenuCore(); + const media = createMediaState(); + core.selectValue(media, '3'); + expect(media.setPlaybackRate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/core/ui/popover/popover-css-vars.ts b/packages/core/src/core/ui/popover/popover-css-vars.ts index 8422fa49..8ac1210b 100644 --- a/packages/core/src/core/ui/popover/popover-css-vars.ts +++ b/packages/core/src/core/ui/popover/popover-css-vars.ts @@ -3,6 +3,8 @@ export const PopoverCSSVars = { sideOffset: '--media-popover-side-offset', /** Distance between the popup and the trigger along the alignment axis. */ alignOffset: '--media-popover-align-offset', + /** Minimum distance between the popup and the positioning boundary. */ + boundaryOffset: '--media-popover-boundary-offset', /** The anchor element's width. */ anchorWidth: '--media-popover-anchor-width', /** The anchor element's height. */ diff --git a/packages/core/src/core/ui/popover/popup-host-attr.ts b/packages/core/src/core/ui/popover/popup-host-attr.ts new file mode 100644 index 00000000..d47d680a --- /dev/null +++ b/packages/core/src/core/ui/popover/popup-host-attr.ts @@ -0,0 +1,9 @@ +/** + * Hosted floating UI surfaces (popover, menu, tooltip, and future overlays) that support + * parent-driven lifecycle may set {@link POPUP_HOST_ATTR}. Ancestors can discover them with + * {@link POPUP_HOST_SELECTOR} and call methods such as `close('imperative-action')` when + * the element implements that contract. + */ +export const POPUP_HOST_ATTR = 'data-popup'; + +export const POPUP_HOST_SELECTOR = `[${POPUP_HOST_ATTR}]`; diff --git a/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts b/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts index 8e03bc1e..350ffecb 100644 --- a/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts +++ b/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts @@ -3,6 +3,8 @@ export const TooltipCSSVars = { sideOffset: '--media-tooltip-side-offset', /** Distance between the popup and the trigger along the alignment axis. */ alignOffset: '--media-tooltip-align-offset', + /** Minimum distance between the popup and the positioning boundary. */ + boundaryOffset: '--media-tooltip-boundary-offset', /** The anchor element's width. */ anchorWidth: '--media-tooltip-anchor-width', /** The anchor element's height. */ diff --git a/packages/core/src/dom/gesture/tests/gesture.test.ts b/packages/core/src/dom/gesture/tests/gesture.test.ts index dc9703a4..4ab2ddee 100644 --- a/packages/core/src/dom/gesture/tests/gesture.test.ts +++ b/packages/core/src/dom/gesture/tests/gesture.test.ts @@ -431,6 +431,22 @@ describe('interactive child filtering', () => { expect(handler).not.toHaveBeenCalled(); }); + it('does not fire when event originates from a child with role="menuitemradio"', () => { + const container = setup(); + const item = document.createElement('div'); + item.setAttribute('role', 'menuitemradio'); + container.appendChild(item); + + const handler = vi.fn(); + createTapGesture(container, handler); + + pointerDown(item); + vi.advanceTimersByTime(50); + pointerUp(item, { pointerType: 'mouse', clientX: 150 }); + + expect(handler).not.toHaveBeenCalled(); + }); + it('does not fire when event originates from a nested child inside an interactive element', () => { const container = setup(); const button = document.createElement('button'); diff --git a/packages/core/src/dom/hotkey/coordinator.ts b/packages/core/src/dom/hotkey/coordinator.ts index aefd88d7..a44eab2c 100644 --- a/packages/core/src/dom/hotkey/coordinator.ts +++ b/packages/core/src/dom/hotkey/coordinator.ts @@ -135,6 +135,8 @@ export class HotkeyCoordinator { // Let interactive elements handle their own activation keys. if (isInteractiveActivation(event)) return; + if (event.defaultPrevented) return; + const editable = isEditableTarget(event); for (const binding of this.#bindings) { diff --git a/packages/core/src/dom/hotkey/tests/hotkey.test.ts b/packages/core/src/dom/hotkey/tests/hotkey.test.ts index cb0b17cd..97556bfa 100644 --- a/packages/core/src/dom/hotkey/tests/hotkey.test.ts +++ b/packages/core/src/dom/hotkey/tests/hotkey.test.ts @@ -286,4 +286,18 @@ describe('createHotkey', () => { cleanup(); }); + + it('does not fire when the event has already been handled', () => { + const el = setup(); + const onActivate = vi.fn(); + + el.addEventListener('keydown', (event) => event.preventDefault(), { capture: true }); + const cleanup = createHotkey(el, { keys: 'ArrowRight', onActivate }); + + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })); + + expect(onActivate).not.toHaveBeenCalled(); + + cleanup(); + }); }); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 375e432c..9f3d9931 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -20,6 +20,7 @@ export * from './ui/menu/create-menu-view-transition'; export * from './ui/menu/menu-viewport-transition'; export * from './ui/popover/popover'; export * from './ui/popover/popover-positioning'; +export * from './ui/popover/popup-group'; export * from './ui/slider'; export * from './ui/slider-css-vars'; export * from './ui/thumbnail'; diff --git a/packages/core/src/dom/ui/menu/create-menu.ts b/packages/core/src/dom/ui/menu/create-menu.ts index 6818a928..69f2df6f 100644 --- a/packages/core/src/dom/ui/menu/create-menu.ts +++ b/packages/core/src/dom/ui/menu/create-menu.ts @@ -4,6 +4,7 @@ import { MenuItemDataAttrs } from '../../../core/ui/menu/menu-item-data-attrs'; import type { UIFocusEvent, UIKeyboardEvent } from '../event'; import { createPopover, type PopoverChangeDetails, type PopoverOpenChangeReason } from '../popover/popover'; import type { PositioningOptions } from '../popover/popover-positioning'; +import type { PopupGroup } from '../popover/popup-group'; import type { TransitionApi } from '../transition'; export type MenuOpenChangeReason = PopoverOpenChangeReason; @@ -33,11 +34,13 @@ export interface MenuOptions { closeOnOutsideClick: () => boolean; /** Called when the highlighted item changes. */ onHighlightChange?: (element: HTMLElement | null) => void; + group?: () => PopupGroup | undefined; } export interface MenuTriggerProps { /** Called when the trigger is clicked. Uses the DOM `UIEvent` type to match the Popover API. */ onClick: (event: UIEvent) => void; + onKeyDown: (event: UIKeyboardEvent) => void; } export interface MenuContentProps { @@ -46,6 +49,7 @@ export interface MenuContentProps { } export interface MenuHighlightOptions { + focus?: boolean; preventScroll?: boolean; } @@ -120,6 +124,7 @@ export function createMenu(options: MenuOptions): MenuApi { let typeaheadBuffer = ''; let typeaheadTimer: ReturnType | null = null; let openRafId = 0; + let lastCloseReason: MenuOpenChangeReason | null = null; const navigationState = createState({ stack: [], direction: 'forward' }); @@ -161,10 +166,12 @@ export function createMenu(options: MenuOptions): MenuApi { if (element) { element.tabIndex = 0; element.setAttribute(MenuItemDataAttrs.highlighted, ''); - if (highlightOptions?.preventScroll) { - element.focus({ preventScroll: true }); - } else { - element.focus(); + if (highlightOptions?.focus !== false) { + if (highlightOptions?.preventScroll) { + element.focus({ preventScroll: true }); + } else { + element.focus(); + } } } @@ -184,6 +191,10 @@ export function createMenu(options: MenuOptions): MenuApi { highlight(items[0] ?? null, options); } + function getInitialHighlightItem(): HTMLElement | null { + return items.find((item) => item.matches('[aria-checked="true"], [aria-selected="true"]')) ?? items[0] ?? null; + } + // --- Type-ahead --- function clearTypeahead(): void { @@ -201,7 +212,7 @@ export function createMenu(options: MenuOptions): MenuApi { // Guard against close() being called before the RAF fires — active // stays true during the closing animation, so also check status. if (!popover.input.current.active || popover.input.current.status === 'ending' || highlightedItem) return; - highlightFirstItem(); + highlight(getInitialHighlightItem()); }); } @@ -232,10 +243,11 @@ export function createMenu(options: MenuOptions): MenuApi { const popover = createPopover({ transition: options.transition, onOpenChange(open, details) { + lastCloseReason = open ? null : details.reason; options.onOpenChange(open, details); if (open) { - // Focus the first item after the popover element becomes visible. + // Focus the selected item after the popover element becomes visible. // One RAF ensures the element has been shown via the Popover API. scheduleInitialHighlight(); } else { @@ -249,10 +261,13 @@ export function createMenu(options: MenuOptions): MenuApi { options.onOpenChangeComplete?.(open); // Return focus to the trigger after the close animation completes // so screen readers hear the correct context. - if (!open) triggerElement?.focus(); + if (!open && lastCloseReason !== 'imperative-action' && lastCloseReason !== 'group-open') { + triggerElement?.focus(); + } }, closeOnEscape: options.closeOnEscape, closeOnOutsideClick: options.closeOnOutsideClick, + ...(options.group ? { group: options.group } : {}), }); // --- Content keyboard navigation --- @@ -262,6 +277,10 @@ export function createMenu(options: MenuOptions): MenuApi { onKeyDown(event) { const { key } = event; + if (key !== 'Escape' && isMenuNavigationKey(event) && !event.defaultPrevented) { + event.preventDefault(); + } + if (items.length === 0) return; switch (key) { @@ -303,6 +322,17 @@ export function createMenu(options: MenuOptions): MenuApi { }, }; + function handleTriggerKeyDown(event: UIKeyboardEvent): void { + const input = popover.input.current; + + if (!input.active || input.status === 'ending') return; + if (event.key === 'Escape') return; + if (!isMenuNavigationKey(event)) return; + + contentProps.onKeyDown(event); + event.stopPropagation(); + } + // --- Element setters --- function setTriggerElement(element: HTMLElement | null): void { @@ -359,6 +389,7 @@ export function createMenu(options: MenuOptions): MenuApi { // Hover and focus-based open are disabled (openOnHover not set). triggerProps: { onClick: popover.triggerProps.onClick, + onKeyDown: handleTriggerKeyDown, }, contentProps, get triggerElement(): HTMLElement | null { diff --git a/packages/core/src/dom/ui/menu/tests/create-menu.test.ts b/packages/core/src/dom/ui/menu/tests/create-menu.test.ts index 2457cb33..914f1f54 100644 --- a/packages/core/src/dom/ui/menu/tests/create-menu.test.ts +++ b/packages/core/src/dom/ui/menu/tests/create-menu.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MenuItemDataAttrs } from '../../../../core/ui/menu/menu-item-data-attrs'; import type { UIFocusEvent, UIKeyboardEvent } from '../../event'; +import { createPopupGroup } from '../../popover/popup-group'; import { completeMenuItemSelection, getRootPositionOptions, isMenuNavigationKey } from '../create-menu'; import { cleanupElement, createItemElement, createTestMenu } from './create-menu-helpers'; @@ -111,6 +112,20 @@ describe('createMenu', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + it('closes the previously open grouped menu when another opens', () => { + const group = createPopupGroup(); + const first = createTestMenu({ group: () => group }); + const second = createTestMenu({ group: () => group }); + + first.menu.open(); + first.onOpenChange.mockClear(); + + second.menu.open(); + + expect(first.onOpenChange).toHaveBeenCalledWith(false, { reason: 'group-open' }); + expect(second.onOpenChange).toHaveBeenCalledWith(true, { reason: 'click' }); + }); + it('highlights the first DOM item when items register after opening', () => { vi.useFakeTimers(); @@ -129,6 +144,66 @@ describe('createMenu', () => { vi.useRealTimers(); }); + it('highlights the checked item when opening', () => { + vi.useFakeTimers(); + + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + b.setAttribute('aria-checked', 'true'); + + menu.registerItem(a); + menu.registerItem(b); + menu.open(); + + vi.runAllTimers(); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + expect(a.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(false); + + vi.useRealTimers(); + }); + + it('highlights the checked item when items register after opening', () => { + vi.useFakeTimers(); + + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + b.setAttribute('aria-checked', 'true'); + + menu.open(); + menu.registerItem(a); + menu.registerItem(b); + + vi.runAllTimers(); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + expect(a.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(false); + + vi.useRealTimers(); + }); + + it('highlights the selected item when opening', () => { + vi.useFakeTimers(); + + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + b.setAttribute('aria-selected', 'true'); + + menu.registerItem(a); + menu.registerItem(b); + menu.open(); + + vi.runAllTimers(); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + expect(a.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(false); + + vi.useRealTimers(); + }); + it('closes when focus moves outside the menu and trigger', () => { const { menu, onOpenChange } = createTestMenu(); const trigger = document.createElement('button'); @@ -228,6 +303,84 @@ describe('createMenu', () => { expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'click' })); }); + + it('handles navigation keys while the open trigger has focus', () => { + const { menu } = createTestMenu(); + const element = addItem('Auto'); + menu.registerItem(element); + menu.open(); + + const event = makeKeyEvent('ArrowDown'); + menu.triggerProps.onKeyDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(element.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(true); + }); + + it('swallows left and right keys while the menu is open', () => { + const { menu } = createTestMenu(); + menu.open(); + + const event = makeKeyEvent('ArrowRight'); + menu.triggerProps.onKeyDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('lets Escape bubble while the menu is open', () => { + const { menu } = createTestMenu(); + menu.open(); + + const event = makeKeyEvent('Escape'); + menu.triggerProps.onKeyDown(event); + + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); + + it('lets navigation keys bubble while the menu is closed', () => { + const { menu } = createTestMenu(); + + const event = makeKeyEvent('ArrowRight'); + menu.triggerProps.onKeyDown(event); + + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); + + it('does not restore focus after imperative close', async () => { + const { menu } = createTestMenu(); + const trigger = document.createElement('button'); + const focus = vi.spyOn(trigger, 'focus'); + + menu.setTriggerElement(trigger); + menu.open(); + menu.close('imperative-action'); + + await vi.waitFor(() => { + expect(menu.input.current.active).toBe(false); + }); + + expect(focus).not.toHaveBeenCalled(); + }); + + it('does not restore focus when another grouped popup opens', async () => { + const group = createPopupGroup(); + const first = createTestMenu({ group: () => group }); + const second = createTestMenu({ group: () => group }); + const trigger = document.createElement('button'); + const focus = vi.spyOn(trigger, 'focus'); + + first.menu.setTriggerElement(trigger); + first.menu.open(); + second.menu.open(); + + await vi.waitFor(() => { + expect(first.menu.input.current.active).toBe(false); + }); + + expect(focus).not.toHaveBeenCalled(); + }); }); // ------------------------------------------------------------------------- @@ -378,6 +531,18 @@ describe('createMenu', () => { expect(focus).toHaveBeenCalledWith({ preventScroll: true }); }); + + it('can highlight an item without moving focus', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + const focus = vi.spyOn(element, 'focus'); + menu.registerItem(element); + + menu.highlight(element, { focus: false }); + + expect(element.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + expect(focus).not.toHaveBeenCalled(); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/core/src/dom/ui/popover/popover-positioning.ts b/packages/core/src/dom/ui/popover/popover-positioning.ts index 2ac95bdc..dfd024fb 100644 --- a/packages/core/src/dom/ui/popover/popover-positioning.ts +++ b/packages/core/src/dom/ui/popover/popover-positioning.ts @@ -1,6 +1,8 @@ import { resolveCSSLength, supportsAnchorPositioning } from '@videojs/utils/dom'; +import { clamp } from '@videojs/utils/number'; import type { PopoverAlign, PopoverSide } from '../../../core/ui/popover/popover-core'; import { type PopoverCSSVarKey, PopoverCSSVars } from '../../../core/ui/popover/popover-css-vars'; +import { createDOMRect } from '../../utils/layout'; export interface PositioningOptions { side: PopoverSide; @@ -10,12 +12,14 @@ export interface PositioningOptions { export interface ManualOffsets { sideOffset: number; alignOffset: number; + boundaryOffset?: number; } /** CSS custom property names for anchor-based positioning. */ export interface PositioningCSSVars { sideOffset: string; alignOffset: string; + boundaryOffset: string; anchorWidth: string; anchorHeight: string; availableWidth: string; @@ -38,6 +42,8 @@ export interface PopoverPositionStyle { right?: string; } +const ZERO_OFFSETS: ManualOffsets = { sideOffset: 0, alignOffset: 0, boundaryOffset: 0 }; + const OPPOSITE_SIDE: Record = { top: 'bottom', bottom: 'top', @@ -45,6 +51,26 @@ const OPPOSITE_SIDE: Record = { right: 'left', }; +function formatPixels(value: number): string { + return `${clamp(value, 0, Infinity)}px`; +} + +function getCrossAxisAvailable( + start: number, + end: number, + size: number, + boundaryStart: number, + boundaryEnd: number, + align: PopoverAlign, + alignOffset: number +): number { + if (align === 'start') return boundaryEnd - (start + alignOffset); + if (align === 'end') return end + alignOffset - boundaryStart; + + const center = start + size / 2 + alignOffset; + return Math.min(center - boundaryStart, boundaryEnd - center) * 2; +} + /** * Get positioning styles for the popup element. * @@ -70,15 +96,18 @@ export function getAnchorPositionStyle( cssVars: PositioningCSSVars = PopoverCSSVars ): PopoverPositionStyle & Record { if (supportsAnchorPositioning()) { - return getAnchorPositionCSS(anchorName, opts, cssVars); + return { + ...getAnchorPositionCSS(anchorName, opts, cssVars), + ...(triggerRect && boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, offsets, cssVars) : {}), + }; } // JS fallback when CSS Anchor Positioning is not supported. if (triggerRect && popupRect) { - const resolved: ManualOffsets = offsets ?? { sideOffset: 0, alignOffset: 0 }; + const resolved: ManualOffsets = offsets ?? ZERO_OFFSETS; return { ...getManualPositionStyle(triggerRect, popupRect, opts, resolved), - ...(boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts.side, cssVars) : {}), + ...(boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, resolved, cssVars) : {}), position: 'fixed', // Reset UA [popover] defaults (inset: 0; margin: auto) which would // otherwise conflict with computed positioning. @@ -163,22 +192,51 @@ function getAnchorPositionCSS( export function getPositioningCSSVars( triggerRect: DOMRect, boundaryRect: DOMRect, - side: PopoverSide, + opts: PositioningOptions, + offsets: ManualOffsets = ZERO_OFFSETS, cssVars: PositioningCSSVars = PopoverCSSVars ): Record { const vars: Record = {}; + const { side, align } = opts; + const boundaryOffset = offsets.boundaryOffset ?? 0; + const boundaryStartX = boundaryRect.left + boundaryOffset; + const boundaryEndX = boundaryRect.right - boundaryOffset; + const boundaryStartY = boundaryRect.top + boundaryOffset; + const boundaryEndY = boundaryRect.bottom - boundaryOffset; vars[cssVars.anchorWidth] = `${triggerRect.width}px`; vars[cssVars.anchorHeight] = `${triggerRect.height}px`; if (side === 'top' || side === 'bottom') { - vars[cssVars.availableHeight] = - side === 'top' ? `${triggerRect.top - boundaryRect.top}px` : `${boundaryRect.bottom - triggerRect.bottom}px`; - vars[cssVars.availableWidth] = `${boundaryRect.width}px`; + const sideSpace = side === 'top' ? triggerRect.top - boundaryStartY : boundaryEndY - triggerRect.bottom; + + vars[cssVars.availableHeight] = formatPixels(sideSpace - offsets.sideOffset); + vars[cssVars.availableWidth] = formatPixels( + getCrossAxisAvailable( + triggerRect.left, + triggerRect.right, + triggerRect.width, + boundaryStartX, + boundaryEndX, + align, + offsets.alignOffset + ) + ); } else { - vars[cssVars.availableWidth] = - side === 'left' ? `${triggerRect.left - boundaryRect.left}px` : `${boundaryRect.right - triggerRect.right}px`; - vars[cssVars.availableHeight] = `${boundaryRect.height}px`; + const sideSpace = side === 'left' ? triggerRect.left - boundaryStartX : boundaryEndX - triggerRect.right; + + vars[cssVars.availableWidth] = formatPixels(sideSpace - offsets.sideOffset); + vars[cssVars.availableHeight] = formatPixels( + getCrossAxisAvailable( + triggerRect.top, + triggerRect.bottom, + triggerRect.height, + boundaryStartY, + boundaryEndY, + align, + offsets.alignOffset + ) + ); } return vars; @@ -190,7 +248,22 @@ export function getPopoverCSSVars( boundaryRect: DOMRect, side: PopoverSide ): Partial> { - return getPositioningCSSVars(triggerRect, boundaryRect, side, PopoverCSSVars); + const vars: Partial> = { + [PopoverCSSVars.anchorWidth]: `${triggerRect.width}px`, + [PopoverCSSVars.anchorHeight]: `${triggerRect.height}px`, + }; + + if (side === 'top' || side === 'bottom') { + vars[PopoverCSSVars.availableHeight] = + side === 'top' ? `${triggerRect.top - boundaryRect.top}px` : `${boundaryRect.bottom - triggerRect.bottom}px`; + vars[PopoverCSSVars.availableWidth] = `${boundaryRect.width}px`; + } else { + vars[PopoverCSSVars.availableWidth] = + side === 'left' ? `${triggerRect.left - boundaryRect.left}px` : `${boundaryRect.right - triggerRect.right}px`; + vars[PopoverCSSVars.availableHeight] = `${boundaryRect.height}px`; + } + + return vars; } /** @@ -252,7 +325,7 @@ export function getManualPositionStyle( } /** - * Read side-offset and align-offset CSS custom properties from the + * Read positioning offset CSS custom properties from the * popup element's computed style, returning numeric pixel values. */ export function resolveOffsets(el: Element, cssVars: PositioningCSSVars = PopoverCSSVars): ManualOffsets { @@ -260,6 +333,7 @@ export function resolveOffsets(el: Element, cssVars: PositioningCSSVars = Popove return { sideOffset: resolveCSSLength(el, computed.getPropertyValue(cssVars.sideOffset)), alignOffset: resolveCSSLength(el, computed.getPropertyValue(cssVars.alignOffset)), + boundaryOffset: resolveCSSLength(el, computed.getPropertyValue(cssVars.boundaryOffset)), }; } @@ -274,16 +348,6 @@ export function getPopupPositionRect(el: HTMLElement): DOMRect { const rect = el.getBoundingClientRect(); const width = el.offsetWidth || rect.width; const height = el.offsetHeight || rect.height; - const adjustedRect = { - ...rect, - width, - height, - right: rect.left + width, - bottom: rect.top + height, - }; - return { - ...adjustedRect, - toJSON: () => adjustedRect, - }; + return createDOMRect(rect.left, rect.top, width, height); } diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts index e96aad15..dca97eae 100644 --- a/packages/core/src/dom/ui/popover/popover.ts +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -4,8 +4,17 @@ import type { PopoverInput } from '../../../core/ui/popover/popover-core'; import { createDismissLayer } from '../dismiss-layer'; import type { UIFocusEvent, UIPointerEvent } from '../event'; import type { TransitionApi } from '../transition'; +import type { PopupGroup } from './popup-group'; -export type PopoverOpenChangeReason = 'click' | 'hover' | 'focus' | 'escape' | 'outside-click' | 'blur'; +export type PopoverOpenChangeReason = + | 'click' + | 'hover' + | 'focus' + | 'escape' + | 'outside-click' + | 'blur' + | 'imperative-action' + | 'group-open'; export interface PopoverChangeDetails { reason: PopoverOpenChangeReason; @@ -22,6 +31,7 @@ export interface PopoverOptions { openOnHover?: () => boolean; delay?: () => number; closeDelay?: () => number; + group?: () => PopupGroup | undefined; } export interface PopoverTriggerProps { @@ -73,6 +83,11 @@ export function createPopover(options: PopoverOptions): PopoverApi { }); const state = layer.input; + const groupMember = { + close(reason: 'group-open') { + applyClose(reason); + }, + }; // --- Hover management --- @@ -118,6 +133,8 @@ export function createPopover(options: PopoverOptions): PopoverApi { const opening = layer.open(); if (!opening) return; + options.group?.()?.open(groupMember); + const details: PopoverChangeDetails = event ? { reason, event } : { reason }; onOpenChange(true, details); @@ -131,6 +148,8 @@ export function createPopover(options: PopoverOptions): PopoverApi { const closing = layer.close(popupEl); if (!closing) return; + options.group?.()?.close(groupMember); + const details: PopoverChangeDetails = event ? { reason, event } : { reason }; onOpenChange(false, details); @@ -168,6 +187,7 @@ export function createPopover(options: PopoverOptions): PopoverApi { // Cleanup hover timeout on destroy. layer.signal.addEventListener('abort', () => { + options.group?.()?.close(groupMember); clearHoverTimeout(); capturedPointers.clear(); triggerEl = null; diff --git a/packages/core/src/dom/ui/popover/popup-group.ts b/packages/core/src/dom/ui/popover/popup-group.ts new file mode 100644 index 00000000..8330349e --- /dev/null +++ b/packages/core/src/dom/ui/popover/popup-group.ts @@ -0,0 +1,27 @@ +export type PopupGroupCloseReason = 'group-open'; + +export interface PopupGroupMember { + close: (reason: PopupGroupCloseReason) => void; +} + +export interface PopupGroup { + open: (member: PopupGroupMember) => void; + close: (member: PopupGroupMember) => void; +} + +export function createPopupGroup(): PopupGroup { + let current: PopupGroupMember | null = null; + + return { + open(member) { + if (current === member) return; + + current?.close('group-open'); + current = member; + }, + + close(member) { + if (current === member) current = null; + }, + }; +} diff --git a/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts index 9dad41ea..8489aa5f 100644 --- a/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts @@ -6,6 +6,7 @@ import { getManualPositionStyle, getPopoverCSSVars, getPopupPositionRect, + getPositioningCSSVars, type ManualOffsets, resolveOffsets, } from '../popover-positioning'; @@ -155,6 +156,61 @@ describe('getPopoverCSSVars', () => { }); }); +describe('getPositioningCSSVars', () => { + const boundary = makeDOMRect(0, 0, 300, 200); + + it('computes available size for center-aligned top and bottom popups', () => { + const trigger = makeDOMRect(250, 150, 40, 20); + const vars = getPositioningCSSVars( + trigger, + boundary, + { side: 'bottom', align: 'center' }, + { sideOffset: 8, alignOffset: 0 } + ); + + expect(vars[PopoverCSSVars.availableHeight]).toBe('22px'); + expect(vars[PopoverCSSVars.availableWidth]).toBe('60px'); + }); + + it('applies align offset to start-aligned cross-axis size', () => { + const trigger = makeDOMRect(250, 150, 40, 20); + const vars = getPositioningCSSVars( + trigger, + boundary, + { side: 'bottom', align: 'start' }, + { sideOffset: 0, alignOffset: 10 } + ); + + expect(vars[PopoverCSSVars.availableWidth]).toBe('40px'); + }); + + it('computes available size for center-aligned left and right popups', () => { + const trigger = makeDOMRect(120, 160, 40, 20); + const vars = getPositioningCSSVars( + trigger, + boundary, + { side: 'right', align: 'center' }, + { sideOffset: 12, alignOffset: 0 } + ); + + expect(vars[PopoverCSSVars.availableWidth]).toBe('128px'); + expect(vars[PopoverCSSVars.availableHeight]).toBe('60px'); + }); + + it('subtracts boundary offset from side-axis and cross-axis sizes', () => { + const trigger = makeDOMRect(250, 150, 40, 20); + const vars = getPositioningCSSVars( + trigger, + boundary, + { side: 'bottom', align: 'center' }, + { sideOffset: 8, alignOffset: 0, boundaryOffset: 10 } + ); + + expect(vars[PopoverCSSVars.availableHeight]).toBe('12px'); + expect(vars[PopoverCSSVars.availableWidth]).toBe('40px'); + }); +}); + describe('getAnchorNameStyle', () => { it('returns empty object when anchor positioning is not supported', () => { const style = getAnchorNameStyle('my-anchor'); @@ -194,12 +250,13 @@ describe('resolveOffsets', () => { getPropertyValue(name: string) { if (name === PopoverCSSVars.sideOffset) return '0.5rem'; if (name === PopoverCSSVars.alignOffset) return '1em'; + if (name === PopoverCSSVars.boundaryOffset) return '2px'; return ''; }, }) as CSSStyleDeclaration ); - expect(resolveOffsets(el)).toEqual({ sideOffset: 8, alignOffset: 14 }); + expect(resolveOffsets(el)).toEqual({ sideOffset: 8, alignOffset: 14, boundaryOffset: 2 }); getComputedStyleSpy.mockRestore(); }); diff --git a/packages/core/src/dom/ui/popover/tests/popover.test.ts b/packages/core/src/dom/ui/popover/tests/popover.test.ts index e11f4cf3..ba233c1e 100644 --- a/packages/core/src/dom/ui/popover/tests/popover.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover.test.ts @@ -1,5 +1,6 @@ import { flush } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; +import { createPopupGroup } from '../popup-group'; import { createTestPopover } from './popover-helpers'; describe('createPopover', () => { @@ -74,6 +75,68 @@ describe('createPopover', () => { expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'hover' }); }); + + it('supports imperative close reason', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open(); + onOpenChange.mockClear(); + + popover.close('imperative-action'); + + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'imperative-action' }); + }); + + it('ignores imperative close while already closed', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.close('imperative-action'); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('closes the previously open grouped popover when another opens', () => { + const group = createPopupGroup(); + const first = createTestPopover({ group: () => group }); + const second = createTestPopover({ group: () => group }); + + first.popover.open(); + first.onOpenChange.mockClear(); + + second.popover.open(); + + expect(first.onOpenChange).toHaveBeenCalledWith(false, { reason: 'group-open' }); + expect(second.onOpenChange).toHaveBeenCalledWith(true, { reason: 'click' }); + }); + + it('does not close popovers in a different group', () => { + const firstGroup = createPopupGroup(); + const secondGroup = createPopupGroup(); + const first = createTestPopover({ group: () => firstGroup }); + const second = createTestPopover({ group: () => secondGroup }); + + first.popover.open(); + first.onOpenChange.mockClear(); + + second.popover.open(); + + expect(first.onOpenChange).not.toHaveBeenCalled(); + }); + + it('clears the grouped popover when destroyed', () => { + const group = createPopupGroup(); + const first = createTestPopover({ group: () => group }); + const second = createTestPopover({ group: () => group }); + + first.popover.open(); + first.popover.destroy(); + first.onOpenChange.mockClear(); + + second.popover.open(); + + expect(first.onOpenChange).not.toHaveBeenCalled(); + expect(second.onOpenChange).toHaveBeenCalledWith(true, { reason: 'click' }); + }); }); describe('onOpenChangeComplete', () => { @@ -128,8 +191,8 @@ describe('createPopover', () => { }); it('does not open on click on touch devices when openOnHover is enabled', () => { - const matchMedia = vi.fn((query: string) => ({ - matches: query === '(hover: hover)' ? false : false, + const matchMedia = vi.fn(() => ({ + matches: false, })); vi.stubGlobal('matchMedia', matchMedia); @@ -146,8 +209,8 @@ describe('createPopover', () => { }); it('does not open via focus on touch devices when openOnHover is enabled', () => { - const matchMedia = vi.fn((query: string) => ({ - matches: query === '(hover: hover)' ? false : false, + const matchMedia = vi.fn(() => ({ + matches: false, })); vi.stubGlobal('matchMedia', matchMedia); diff --git a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts index 9cef6ff3..cde2b892 100644 --- a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts +++ b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts @@ -57,6 +57,17 @@ describe('createTooltip', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + + it('supports imperative close reason', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.open(); + onOpenChange.mockClear(); + + tooltip.close('imperative-action'); + + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'imperative-action' }); + }); }); describe('onOpenChangeComplete', () => { diff --git a/packages/core/src/dom/ui/tooltip/tooltip.ts b/packages/core/src/dom/ui/tooltip/tooltip.ts index 972b54e2..7117f2d8 100644 --- a/packages/core/src/dom/ui/tooltip/tooltip.ts +++ b/packages/core/src/dom/ui/tooltip/tooltip.ts @@ -4,13 +4,14 @@ import { createPopover, type PopoverApi, type PopoverChangeDetails, + type PopoverOpenChangeReason, type PopoverOptions, type PopoverPopupProps, type PopoverTriggerProps, } from '../popover/popover'; import type { TransitionApi } from '../transition'; -export type TooltipOpenChangeReason = 'hover' | 'focus' | 'escape' | 'blur'; +export type TooltipOpenChangeReason = 'hover' | 'focus' | 'escape' | 'blur' | 'imperative-action'; export interface TooltipChangeDetails { reason: TooltipOpenChangeReason; @@ -38,15 +39,16 @@ export interface TooltipApi extends Omit void; - close: () => void; + close: (reason?: TooltipOpenChangeReason) => void; } /** Map popover reasons to tooltip reasons, filtering out click/outside-click. */ -const REASON_MAP: Partial> = { +const REASON_MAP: Partial> = { hover: 'hover', focus: 'focus', escape: 'escape', blur: 'blur', + 'imperative-action': 'imperative-action', }; export function createTooltip(options: TooltipOptions): TooltipApi { @@ -127,6 +129,6 @@ export function createTooltip(options: TooltipOptions): TooltipApi { return popover.triggerElement; }, open: () => popover.open('hover'), - close: () => popover.close('hover'), + close: (reason: TooltipOpenChangeReason = 'hover') => popover.close(reason), }; } diff --git a/packages/core/src/dom/utils/event.ts b/packages/core/src/dom/utils/event.ts new file mode 100644 index 00000000..0f95c8a4 --- /dev/null +++ b/packages/core/src/dom/utils/event.ts @@ -0,0 +1,12 @@ +import { isFunction } from '@videojs/utils/predicate'; + +export function isEventWithinElement(event: Event, element: Element | null): boolean { + if (!element) return false; + + if (isFunction(event.composedPath)) { + return event.composedPath().includes(element); + } + + const target = event.target; + return target instanceof Node && element.contains(target); +} diff --git a/packages/core/src/dom/utils/index.ts b/packages/core/src/dom/utils/index.ts index 16f44ddb..5accb723 100644 --- a/packages/core/src/dom/utils/index.ts +++ b/packages/core/src/dom/utils/index.ts @@ -1,6 +1,15 @@ export type { StateAttrMap } from '../../core/ui/types'; export { applyElementProps } from './element-props'; -export { forceLayout } from './layout'; +export { isEventWithinElement } from './event'; +export { + createDOMRect, + forceLayout, + getPositioningBoundaryRect, + intersectDOMRects, + type PositioningBoundary, + type ResolvePositioningBoundaryOptions, + resolvePositioningBoundary, +} from './layout'; export { logMissingFeature } from './log'; export { getPercentFromPointerEvent } from './pointer'; export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs'; diff --git a/packages/core/src/dom/utils/layout.ts b/packages/core/src/dom/utils/layout.ts index 48e43a44..00dd22d1 100644 --- a/packages/core/src/dom/utils/layout.ts +++ b/packages/core/src/dom/utils/layout.ts @@ -1,3 +1,61 @@ +import { isString } from '@videojs/utils/predicate'; + export function forceLayout(element: HTMLElement | null): void { element?.getBoundingClientRect(); } + +export type PositioningBoundary = 'viewport' | 'container' | (string & {}) | Element | null | undefined; + +export interface ResolvePositioningBoundaryOptions { + container?: Element | null; + root?: Document | ShadowRoot | Element | null; +} + +export function createDOMRect(left: number, top: number, width: number, height: number): DOMRect { + const right = left + width; + const bottom = top + height; + + return { + x: left, + y: top, + width, + height, + top, + right, + bottom, + left, + toJSON() { + return { x: left, y: top, width, height, top, right, bottom, left }; + }, + } as DOMRect; +} + +export function intersectDOMRects(firstRect: DOMRect, secondRect: DOMRect): DOMRect { + const left = Math.max(firstRect.left, secondRect.left); + const top = Math.max(firstRect.top, secondRect.top); + const right = Math.min(firstRect.right, secondRect.right); + const bottom = Math.min(firstRect.bottom, secondRect.bottom); + + return createDOMRect(left, top, Math.max(0, right - left), Math.max(0, bottom - top)); +} + +export function getPositioningBoundaryRect(boundaryElement?: Element | null): DOMRect { + const viewportRect = document.documentElement.getBoundingClientRect(); + return boundaryElement ? intersectDOMRects(viewportRect, boundaryElement.getBoundingClientRect()) : viewportRect; +} + +export function resolvePositioningBoundary( + boundary: PositioningBoundary, + options: ResolvePositioningBoundaryOptions = {} +): Element | null { + if (!boundary) return null; + if (!isString(boundary)) return boundary; + if (boundary === 'viewport') return null; + if (boundary === 'container') return options.container ?? null; + + try { + return (options.root ?? document).querySelector(boundary); + } catch { + return null; + } +} diff --git a/packages/core/src/dom/utils/tests/event.test.ts b/packages/core/src/dom/utils/tests/event.test.ts new file mode 100644 index 00000000..5aa4526e --- /dev/null +++ b/packages/core/src/dom/utils/tests/event.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isEventWithinElement } from '../event'; + +describe('isEventWithinElement', () => { + it('returns true when the event path contains the element', () => { + const element = document.createElement('div'); + const event = new Event('scroll'); + vi.spyOn(event, 'composedPath').mockReturnValue([document.createElement('span'), element, document]); + + expect(isEventWithinElement(event, element)).toBe(true); + }); + + it('returns false when the event path does not contain the element', () => { + const element = document.createElement('div'); + const event = new Event('scroll'); + vi.spyOn(event, 'composedPath').mockReturnValue([document.createElement('span'), document]); + + expect(isEventWithinElement(event, element)).toBe(false); + }); + + it('falls back to target containment when composedPath is unavailable', () => { + const element = document.createElement('div'); + const child = document.createElement('span'); + element.append(child); + + const event = new Event('scroll'); + Object.defineProperty(event, 'target', { value: child }); + Object.defineProperty(event, 'composedPath', { value: undefined }); + + expect(isEventWithinElement(event, element)).toBe(true); + }); +}); diff --git a/packages/core/src/dom/utils/tests/layout.test.ts b/packages/core/src/dom/utils/tests/layout.test.ts new file mode 100644 index 00000000..443cb25a --- /dev/null +++ b/packages/core/src/dom/utils/tests/layout.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDOMRect, getPositioningBoundaryRect, intersectDOMRects, resolvePositioningBoundary } from '../layout'; + +describe('createDOMRect', () => { + it('creates a DOMRect-like object from position and size', () => { + const rect = createDOMRect(10, 20, 30, 40); + + expect(rect.left).toBe(10); + expect(rect.top).toBe(20); + expect(rect.right).toBe(40); + expect(rect.bottom).toBe(60); + expect(rect.toJSON()).toEqual({ + x: 10, + y: 20, + width: 30, + height: 40, + top: 20, + right: 40, + bottom: 60, + left: 10, + }); + }); +}); + +describe('intersectDOMRects', () => { + it('returns the overlapping region of two rects', () => { + const rect = intersectDOMRects(createDOMRect(0, 0, 300, 200), createDOMRect(250, 150, 100, 100)); + + expect(rect.left).toBe(250); + expect(rect.top).toBe(150); + expect(rect.width).toBe(50); + expect(rect.height).toBe(50); + }); + + it('clamps non-overlapping dimensions to zero', () => { + const rect = intersectDOMRects(createDOMRect(0, 0, 100, 100), createDOMRect(200, 200, 100, 100)); + + expect(rect.width).toBe(0); + expect(rect.height).toBe(0); + }); +}); + +describe('getPositioningBoundaryRect', () => { + it('returns the viewport rect without an element boundary', () => { + const viewportRect = createDOMRect(0, 0, 300, 200); + const viewportSpy = vi.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue(viewportRect); + + expect(getPositioningBoundaryRect()).toBe(viewportRect); + + viewportSpy.mockRestore(); + }); + + it('intersects element boundaries with the viewport', () => { + const boundary = document.createElement('div'); + const viewportSpy = vi + .spyOn(document.documentElement, 'getBoundingClientRect') + .mockReturnValue(createDOMRect(0, 0, 300, 200)); + const boundarySpy = vi.spyOn(boundary, 'getBoundingClientRect').mockReturnValue(createDOMRect(250, 150, 100, 100)); + + const rect = getPositioningBoundaryRect(boundary); + + expect(rect.left).toBe(250); + expect(rect.top).toBe(150); + expect(rect.width).toBe(50); + expect(rect.height).toBe(50); + + viewportSpy.mockRestore(); + boundarySpy.mockRestore(); + }); +}); + +describe('resolvePositioningBoundary', () => { + it('returns null for the viewport boundary', () => { + expect(resolvePositioningBoundary(undefined)).toBeNull(); + expect(resolvePositioningBoundary('viewport')).toBeNull(); + }); + + it('returns the provided container for the container boundary', () => { + const container = document.createElement('div'); + + expect(resolvePositioningBoundary('container', { container })).toBe(container); + }); + + it('returns element boundaries directly', () => { + const boundary = document.createElement('div'); + + expect(resolvePositioningBoundary(boundary)).toBe(boundary); + }); + + it('resolves selector boundaries from the provided root', () => { + const root = document.createElement('div'); + const boundary = document.createElement('div'); + boundary.className = 'boundary'; + root.append(boundary); + + expect(resolvePositioningBoundary('.boundary', { root })).toBe(boundary); + }); + + it('returns null for invalid selector boundaries', () => { + expect(resolvePositioningBoundary('[')).toBeNull(); + }); +}); diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index f1c825a6..002921a5 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -1,5 +1,4 @@ import { renderIcon } from '@videojs/icons/render/minimal'; -import { playbackRate } from '@videojs/skins/default/tailwind/audio.tailwind'; import { button, buttonGroup, @@ -9,6 +8,8 @@ import { iconContainer, iconFlipped, iconState, + menu, + playbackRate, popup, root, seek, @@ -52,7 +53,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + @@ -60,7 +61,7 @@ function getTemplateHTML() { ${SEEK_TIME} - + @@ -68,7 +69,7 @@ function getTemplateHTML() { ${SEEK_TIME} - +
@@ -88,9 +89,20 @@ function getTemplateHTML() {
- - - + + + + + + + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} @@ -98,7 +110,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - + diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index 09d4fa3e..e75e2c0f 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -36,7 +36,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + @@ -44,7 +44,7 @@ function getTemplateHTML() { ${SEEK_TIME} - + @@ -52,7 +52,7 @@ function getTemplateHTML() { ${SEEK_TIME} - +
@@ -72,9 +72,19 @@ function getTemplateHTML() {
- - - + + + + + + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} @@ -82,7 +92,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} - + diff --git a/packages/html/src/define/audio/minimal-ui.ts b/packages/html/src/define/audio/minimal-ui.ts index b4f045a7..fec9ae54 100644 --- a/packages/html/src/define/audio/minimal-ui.ts +++ b/packages/html/src/define/audio/minimal-ui.ts @@ -4,13 +4,15 @@ import { MediaContainerElement } from '../../media/container-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'; +import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; +import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; +import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; 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 { safeDefine } from '../safe-define'; -import { defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; +import { defineErrorDialog, defineMenu, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { AudioPlayerElement } from './player'; @@ -25,11 +27,14 @@ defineErrorDialog(); defineTimeSlider(); defineVolumeSlider(); defineTime(); +defineMenu(); // Standalone elements. safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); -safeDefine(PlaybackRateButtonElement); +safeDefine(PlaybackRateOptionsElement); +safeDefine(PlaybackRateMenuTriggerElement); +safeDefine(PlaybackRateMenuElement); safeDefine(PopoverElement); safeDefine(SeekButtonElement); safeDefine(TooltipElement); diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index db72b6d7..fcd9698c 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -8,6 +8,7 @@ import { iconContainer, iconFlipped, iconState, + menu, playbackRate, popup, root, @@ -52,7 +53,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + @@ -60,7 +61,7 @@ function getTemplateHTML() { ${SEEK_TIME} - + @@ -68,7 +69,7 @@ function getTemplateHTML() { ${SEEK_TIME} - +
@@ -84,8 +85,19 @@ function getTemplateHTML() {
- - + + + + + + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} @@ -93,7 +105,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - + diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 1d71c322..632b9fe7 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -36,7 +36,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + @@ -44,7 +44,7 @@ function getTemplateHTML() { ${SEEK_TIME} - + @@ -52,7 +52,7 @@ function getTemplateHTML() { ${SEEK_TIME} - +
@@ -68,8 +68,19 @@ function getTemplateHTML() {
- - + + + + + + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} @@ -77,7 +88,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} - + diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index d908036c..4a56e71f 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -8,12 +8,15 @@ import { LiveButtonElement } from '../../ui/live-button/live-button-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'; +import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; +import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; +import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; 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 { safeDefine } from '../safe-define'; -import { defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; +import { defineErrorDialog, defineMenu, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; // Value import — player.ts body runs before this module's body. import { AudioPlayerElement } from './player'; @@ -28,6 +31,7 @@ defineErrorDialog(); defineTimeSlider(); defineVolumeSlider(); defineTime(); +defineMenu(); // Standalone elements. safeDefine(GestureElement); @@ -36,6 +40,9 @@ safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); +safeDefine(PlaybackRateOptionsElement); +safeDefine(PlaybackRateMenuTriggerElement); +safeDefine(PlaybackRateMenuElement); safeDefine(PopoverElement); safeDefine(SeekButtonElement); safeDefine(TooltipElement); 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 703e9e57..b43aa493 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,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - +
@@ -59,7 +59,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - + diff --git a/packages/html/src/define/live-audio/minimal-skin.ts b/packages/html/src/define/live-audio/minimal-skin.ts index 7c616dd4..70db1c47 100644 --- a/packages/html/src/define/live-audio/minimal-skin.ts +++ b/packages/html/src/define/live-audio/minimal-skin.ts @@ -34,7 +34,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + @@ -48,7 +48,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} - + diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index 47eba577..56accf04 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -45,7 +45,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: cn(icon, iconState.play.play) })} ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - + @@ -59,7 +59,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - + diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts index 9a7d51c5..4bafcd1c 100644 --- a/packages/html/src/define/live-audio/skin.ts +++ b/packages/html/src/define/live-audio/skin.ts @@ -34,7 +34,7 @@ function getTemplateHTML() { ${renderIcon('play', { class: 'media-icon media-icon--play' })} ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} - + @@ -48,7 +48,7 @@ function getTemplateHTML() { ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} - + diff --git a/packages/html/src/define/ui/playback-rate-menu.ts b/packages/html/src/define/ui/playback-rate-menu.ts new file mode 100644 index 00000000..e9bcd875 --- /dev/null +++ b/packages/html/src/define/ui/playback-rate-menu.ts @@ -0,0 +1,18 @@ +import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; +import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; +import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; +import { safeDefine } from '../safe-define'; +import { defineMenu } from './compounds'; + +defineMenu(); +safeDefine(PlaybackRateOptionsElement); +safeDefine(PlaybackRateMenuTriggerElement); +safeDefine(PlaybackRateMenuElement); + +declare global { + interface HTMLElementTagNameMap { + [PlaybackRateMenuElement.tagName]: PlaybackRateMenuElement; + [PlaybackRateMenuTriggerElement.tagName]: PlaybackRateMenuTriggerElement; + [PlaybackRateOptionsElement.tagName]: PlaybackRateOptionsElement; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 328182ff..c5b613e3 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -11,6 +11,7 @@ import { iconFlipped, iconState, inputFeedback, + menu, overlay, playbackRate, popup, @@ -110,9 +111,20 @@ function getTemplateHTML() {
- - - + + + + + + + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 3de02993..df6b61c8 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -88,8 +88,19 @@ function getTemplateHTML() {
- - + + + + + + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index cdde2559..d2db69ab 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -11,7 +11,9 @@ import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; -import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; +import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; +import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; +import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; import { PopoverElement } from '../../ui/popover/popover-element'; import { PosterElement } from '../../ui/poster/poster-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; @@ -22,6 +24,7 @@ import { defineControls, defineErrorDialog, defineInputIndicators, + defineMenu, defineTime, defineTimeSlider, defineVolumeSlider, @@ -42,6 +45,7 @@ defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); +defineMenu(); // Standalone elements. safeDefine(BufferingIndicatorElement); @@ -53,7 +57,9 @@ safeDefine(HotkeyElement); safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); -safeDefine(PlaybackRateButtonElement); +safeDefine(PlaybackRateOptionsElement); +safeDefine(PlaybackRateMenuTriggerElement); +safeDefine(PlaybackRateMenuElement); safeDefine(PopoverElement); safeDefine(PosterElement); safeDefine(SeekButtonElement); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 0cb1484f..9b55b34b 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -11,6 +11,7 @@ import { iconFlipped, iconState, inputFeedback, + menu, overlay, playbackRate, popup, @@ -106,8 +107,19 @@ function getTemplateHTML() {
- - + + + + + + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index bf16f4be..cb9e0190 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -84,8 +84,19 @@ function getTemplateHTML() {
- - + + + + + + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index ab3b0eb1..39bfb50d 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -13,6 +13,9 @@ import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; +import { PlaybackRateMenuElement } from '../../ui/playback-rate-menu/playback-rate-menu-element'; +import { PlaybackRateMenuTriggerElement } from '../../ui/playback-rate-menu/playback-rate-menu-trigger-element'; +import { PlaybackRateOptionsElement } from '../../ui/playback-rate-menu/playback-rate-options-element'; import { PopoverElement } from '../../ui/popover/popover-element'; import { PosterElement } from '../../ui/poster/poster-element'; import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; @@ -23,6 +26,7 @@ import { defineControls, defineErrorDialog, defineInputIndicators, + defineMenu, defineTime, defineTimeSlider, defineVolumeSlider, @@ -43,6 +47,7 @@ defineInputIndicators(); defineTimeSlider(); defineVolumeSlider(); defineTime(); +defineMenu(); // Standalone elements. safeDefine(BufferingIndicatorElement); @@ -56,6 +61,9 @@ safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); +safeDefine(PlaybackRateOptionsElement); +safeDefine(PlaybackRateMenuTriggerElement); +safeDefine(PlaybackRateMenuElement); safeDefine(PopoverElement); safeDefine(PosterElement); safeDefine(SeekButtonElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index f6771da2..f0ccb2df 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -67,6 +67,9 @@ export { MuteButtonElement } from './ui/mute-button/mute-button-element'; export { PiPButtonElement } from './ui/pip-button/pip-button-element'; export { PlayButtonElement } from './ui/play-button/play-button-element'; export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element'; +export { PlaybackRateMenuElement } from './ui/playback-rate-menu/playback-rate-menu-element'; +export { PlaybackRateMenuTriggerElement } from './ui/playback-rate-menu/playback-rate-menu-trigger-element'; +export { PlaybackRateOptionsElement } from './ui/playback-rate-menu/playback-rate-options-element'; export { PopoverElement } from './ui/popover/popover-element'; export { PosterElement } from './ui/poster/poster-element'; export { SeekButtonElement } from './ui/seek-button/seek-button-element'; diff --git a/packages/html/src/player/context.ts b/packages/html/src/player/context.ts index f330de57..7a24c667 100644 --- a/packages/html/src/player/context.ts +++ b/packages/html/src/player/context.ts @@ -1,4 +1,4 @@ -import type { AnyPlayerStore, Media, MediaContainer, PlayerStore } from '@videojs/core/dom'; +import type { AnyPlayerStore, Media, MediaContainer, PlayerStore, PopupGroup } from '@videojs/core/dom'; import type { ReactiveControllerHost } from '@videojs/element'; import { type Context, type ContextConsumer, createContext } from '@videojs/element/context'; @@ -46,6 +46,7 @@ export const CONTAINER_CONTEXT_KEY = Symbol.for('@videojs/container'); export interface ContainerContextValue { container: MediaContainer | null; setContainer: (container: MediaContainer | null) => void; + popupGroup?: PopupGroup; } export type ContainerContext = Context; diff --git a/packages/html/src/store/provider-mixin.ts b/packages/html/src/store/provider-mixin.ts index bf7c629d..c2fcbd76 100644 --- a/packages/html/src/store/provider-mixin.ts +++ b/packages/html/src/store/provider-mixin.ts @@ -1,4 +1,10 @@ -import type { Media, MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom'; +import { + createPopupGroup, + type Media, + type MediaContainer, + type PlayerStore, + type PlayerTarget, +} from '@videojs/core/dom'; import { ContextProvider } from '@videojs/element/context'; import { isNull } from '@videojs/utils/predicate'; import type { MediaElementConstructor } from '@/ui/media-element'; @@ -38,6 +44,7 @@ export function createProviderMixin( #detach: (() => void) | null = null; #media: Media | null = null; #container: MediaContainer | null = null; + #popupGroup = createPopupGroup(); #fallbackQueued = false; #setMedia = (media: Media | null): void => { @@ -50,7 +57,11 @@ export function createProviderMixin( #setContainer = (container: MediaContainer | null): void => { if (this.#container === container) return; this.#container = container; - this.#containerProvider.setValue({ container, setContainer: this.#setContainer }); + this.#containerProvider.setValue({ + container, + setContainer: this.#setContainer, + popupGroup: this.#popupGroup, + }); this.#tryAttach(); }; @@ -66,7 +77,11 @@ export function createProviderMixin( #containerProvider = new ContextProvider(this, { context: config.containerContext, - initialValue: { container: this.#container, setContainer: this.#setContainer }, + initialValue: { + container: this.#container, + setContainer: this.#setContainer, + popupGroup: this.#popupGroup, + }, }); get store(): Store { @@ -81,7 +96,11 @@ export function createProviderMixin( super.connectedCallback(); this.#playerProvider.setValue(this.store); this.#mediaProvider.setValue({ media: this.#media, setMedia: this.#setMedia }); - this.#containerProvider.setValue({ container: this.#container, setContainer: this.#setContainer }); + this.#containerProvider.setValue({ + container: this.#container, + setContainer: this.#setContainer, + popupGroup: this.#popupGroup, + }); this.#tryAttach(); this.#queueFallbackDiscovery(); } diff --git a/packages/html/src/ui/controls/controls-element.ts b/packages/html/src/ui/controls/controls-element.ts index 29830841..8cec5a91 100644 --- a/packages/html/src/ui/controls/controls-element.ts +++ b/packages/html/src/ui/controls/controls-element.ts @@ -1,7 +1,8 @@ -import { ControlsCore, ControlsDataAttrs } from '@videojs/core'; +import { ControlsCore, ControlsDataAttrs, POPUP_HOST_SELECTOR } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectControls } from '@videojs/core/dom'; import type { PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; +import { isFunction } from '@videojs/utils/predicate'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -14,6 +15,7 @@ export class ControlsElement extends MediaElement { readonly #core = new ControlsCore(); readonly #mediaState = new PlayerController(this, playerContext, selectControls); readonly #provider = new ContextProvider(this, { context: controlsContext }); + #visible = true; override connectedCallback(): void { super.connectedCallback(); @@ -38,5 +40,21 @@ export class ControlsElement extends MediaElement { state, stateAttrMap: ControlsDataAttrs, }); + + const wasVisible = this.#visible; + this.#visible = state.visible; + + if (wasVisible && !state.visible) { + this.#closeOwnedOverlays(); + } + } + + #closeOwnedOverlays(): void { + for (const element of this.querySelectorAll(POPUP_HOST_SELECTOR)) { + const host = element as Element & { close?: unknown }; + if (!isFunction(host.close)) continue; + + host.close('imperative-action'); + } } } diff --git a/packages/html/src/ui/controls/tests/controls-element.test.ts b/packages/html/src/ui/controls/tests/controls-element.test.ts new file mode 100644 index 00000000..211cd967 --- /dev/null +++ b/packages/html/src/ui/controls/tests/controls-element.test.ts @@ -0,0 +1,170 @@ +import { type MediaControlsState, POPUP_HOST_ATTR } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore, flush } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { MenuElement } from '../../menu/menu-element'; +import { PlaybackRateMenuElement } from '../../playback-rate-menu/playback-rate-menu-element'; +import { PopoverElement } from '../../popover/popover-element'; +import { TooltipElement } from '../../tooltip/tooltip-element'; +import { ControlsElement } from '../controls-element'; + +function ensureCustomElementDefined(Constructor: CustomElementConstructor & { readonly tagName: string }): void { + const { tagName } = Constructor; + if (!customElements.get(tagName)) { + customElements.define(tagName, Constructor); + } +} + +function createDefinedElement( + Constructor: Class +): InstanceType { + ensureCustomElementDefined(Constructor); + return document.createElement(Constructor.tagName) as InstanceType; +} + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function createControlsStore(): AnyPlayerStore { + return createStore()({ + name: 'controls', + state: ({ get, set }) => { + return { + userActive: true, + controlsVisible: true, + toggleControls() { + const visible = !(get().controlsVisible as boolean); + + set({ userActive: visible, controlsVisible: visible }); + + return visible; + }, + }; + }, + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + store = createControlsStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store }); + + override connectedCallback(): void { + super.connectedCallback(); + this.#provider.setValue(this.store); + } + + setVisible(visible: boolean): void { + const state = this.store.state as MediaControlsState; + + if (state.controlsVisible === visible) return; + + state.toggleControls(); + flush(); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +defineElement('test-controls-player-provider', TestPlayerProviderElement); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('ControlsElement', () => { + it('closes owned popovers, menus, playback-rate menus, and tooltips when controls hide', async () => { + const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement; + const controls = createDefinedElement(ControlsElement); + const popover = createDefinedElement(PopoverElement); + const menu = createDefinedElement(MenuElement); + const playbackRateMenu = createDefinedElement(PlaybackRateMenuElement); + const tooltip = createDefinedElement(TooltipElement); + const popoverClose = vi.spyOn(popover, 'close'); + const menuClose = vi.spyOn(menu, 'close'); + const playbackRateMenuClose = vi.spyOn(playbackRateMenu, 'close'); + const tooltipClose = vi.spyOn(tooltip, 'close'); + + controls.append(popover, menu, playbackRateMenu, tooltip); + document.body.append(provider); + provider.append(controls); + + await controls.updateComplete; + + expect(popover.hasAttribute(POPUP_HOST_ATTR)).toBe(true); + expect(menu.hasAttribute(POPUP_HOST_ATTR)).toBe(true); + expect(playbackRateMenu.hasAttribute(POPUP_HOST_ATTR)).toBe(true); + expect(tooltip.hasAttribute(POPUP_HOST_ATTR)).toBe(true); + + provider.setVisible(false); + + await waitForAssertion(() => { + expect(popoverClose).toHaveBeenCalledWith('imperative-action'); + expect(menuClose).toHaveBeenCalledWith('imperative-action'); + expect(playbackRateMenuClose).toHaveBeenCalledWith('imperative-action'); + expect(tooltipClose).toHaveBeenCalledWith('imperative-action'); + }); + }); + + it('does not call close on native dialogs inside controls when controls hide', async () => { + const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement; + const controls = createDefinedElement(ControlsElement); + const dialog = document.createElement('dialog'); + const closeSpy = vi.spyOn(dialog, 'close'); + + controls.append(dialog); + document.body.append(provider); + provider.append(controls); + + await controls.updateComplete; + + provider.setVisible(false); + + await waitForAssertion(() => { + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); + + it('ignores popup host markers when close is missing or not a function', async () => { + const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement; + const controls = createDefinedElement(ControlsElement); + const withoutClose = document.createElement('div'); + withoutClose.setAttribute(POPUP_HOST_ATTR, ''); + const wrongClose = document.createElement('div'); + wrongClose.setAttribute(POPUP_HOST_ATTR, ''); + Object.assign(wrongClose, { close: 'not-callable' }); + + controls.append(withoutClose, wrongClose); + document.body.append(provider); + provider.append(controls); + + await controls.updateComplete; + + expect(() => provider.setVisible(false)).not.toThrow(); + }); +}); diff --git a/packages/html/src/ui/menu/menu-checkbox-item-element.ts b/packages/html/src/ui/menu/menu-checkbox-item-element.ts index dc56a085..78b23c3e 100644 --- a/packages/html/src/ui/menu/menu-checkbox-item-element.ts +++ b/packages/html/src/ui/menu/menu-checkbox-item-element.ts @@ -60,7 +60,7 @@ export class MenuCheckboxItemElement extends MediaElement { }, onPointerenter: () => { const currentCtx = this.#ctx.value; - if (!this.disabled) currentCtx?.menu.highlight(this); + if (!this.disabled) currentCtx?.menu.highlight(this, { focus: false }); }, }, { signal: this.#disconnect.signal } diff --git a/packages/html/src/ui/menu/menu-element.ts b/packages/html/src/ui/menu/menu-element.ts index 83cd16a2..83fabaa0 100644 --- a/packages/html/src/ui/menu/menu-element.ts +++ b/packages/html/src/ui/menu/menu-element.ts @@ -1,4 +1,4 @@ -import { MenuCore, MenuDataAttrs, type MenuInput } from '@videojs/core'; +import { MenuCore, MenuDataAttrs, type MenuInput, POPUP_HOST_ATTR } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, @@ -10,13 +10,17 @@ import { getMenuViewportAttrs, getMenuViewTransitionAttrs, getPopupPositionRect, + getPositioningBoundaryRect, getRootPositionOptions, isMenuNavigationKey, type MenuApi, type MenuChangeDetails, + type MenuOpenChangeReason, type MenuViewTransitionState, type NavigationState, + type PositioningBoundary, resolveOffsets, + resolvePositioningBoundary, syncMenuViewRoot, syncMenuViewTransition, type UIFocusEvent, @@ -26,13 +30,13 @@ import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer, ContextProvider } from '@videojs/element/context'; import { SnapshotController } from '@videojs/store/html'; import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; - +import { containerContext } from '../../player/context'; import { MediaElement } from '../media-element'; import { PositionController } from '../position-controller'; import { type MenuContextValue, menuContext } from './context'; export class MenuElement extends MediaElement { - static readonly tagName = 'media-menu'; + static readonly tagName: string = 'media-menu'; static override properties = { open: { type: Boolean }, @@ -41,8 +45,9 @@ export class MenuElement extends MediaElement { align: { type: String }, closeOnEscape: { type: Boolean, attribute: 'close-on-escape' }, closeOnOutsideClick: { type: Boolean, attribute: 'close-on-outside-click' }, + boundary: { type: String }, } satisfies PropertyDeclarationMap< - 'open' | 'defaultOpen' | 'side' | 'align' | 'closeOnEscape' | 'closeOnOutsideClick' + 'open' | 'defaultOpen' | 'side' | 'align' | 'closeOnEscape' | 'closeOnOutsideClick' | 'boundary' >; open = MenuCore.defaultProps.open; @@ -51,10 +56,12 @@ export class MenuElement extends MediaElement { align = MenuCore.defaultProps.align; closeOnEscape = MenuCore.defaultProps.closeOnEscape; closeOnOutsideClick = MenuCore.defaultProps.closeOnOutsideClick; + boundary: PositioningBoundary = 'container'; readonly #core = new MenuCore(); readonly #provider = new ContextProvider(this, { context: menuContext }); readonly #position = new PositionController(this); + readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true }); // Consume parent menu context — present when this is a nested (submenu) element. readonly #parentCtx = new ContextConsumer(this, { context: menuContext, subscribe: true }); readonly #menuViewTransition = createMenuViewTransition({ @@ -84,6 +91,8 @@ export class MenuElement extends MediaElement { super.connectedCallback(); if (this.destroyed) return; + this.setAttribute(POPUP_HOST_ATTR, ''); + this.#disconnect = new AbortController(); this.#menu = createMenu({ @@ -94,6 +103,7 @@ export class MenuElement extends MediaElement { }, closeOnEscape: () => this.closeOnEscape, closeOnOutsideClick: () => this.closeOnOutsideClick, + group: () => (this.#parentCtx.value ? undefined : this.#containerCtx.value?.popupGroup), }); // The element itself is the content (popup) for root menus. @@ -143,13 +153,25 @@ export class MenuElement extends MediaElement { this.#menuViewTransition.destroy(); } + close(reason: MenuOpenChangeReason = 'imperative-action'): void { + this.#menu?.close(reason); + } + protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); const parentCtx = this.#parentCtx.value ?? null; const isSubmenu = parentCtx !== null; - this.#core.setProps({ ...this, isSubmenu }); + this.#core.setProps({ + open: this.open, + defaultOpen: this.defaultOpen, + side: this.side, + align: this.align, + closeOnEscape: this.closeOnEscape, + closeOnOutsideClick: this.closeOnOutsideClick, + isSubmenu, + }); if (this.#menu && changed.has('open') && !isSubmenu) { const { active: interactionOpen } = this.#menu.input.current; @@ -226,17 +248,22 @@ export class MenuElement extends MediaElement { const positionOptions = getRootPositionOptions(state.side, state.align); if (!positionOptions) return; + const boundaryElement = this.#getBoundaryElement(); + const triggerRect = this.#currentTrigger?.getBoundingClientRect(); + const boundaryRect = getPositioningBoundaryRect(boundaryElement); + const offsets = resolveOffsets(this); + if (supportsAnchorPositioning()) { - applyStyles(this, getAnchorPositionStyle(this.id, positionOptions)); + applyStyles( + this, + getAnchorPositionStyle(this.id, positionOptions, triggerRect, undefined, boundaryRect, offsets) + ); } else { - const triggerRect = this.#currentTrigger?.getBoundingClientRect(); const selfRect = getPopupPositionRect(this); - const boundaryRect = document.documentElement.getBoundingClientRect(); - const offsets = resolveOffsets(this); applyStyles(this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, selfRect, boundaryRect, offsets)); } - this.#position.sync(this.#currentTrigger); + this.#position.sync(this.#currentTrigger, boundaryElement); } #updateAsSubmenu(parentCtx: MenuContextValue): void { @@ -265,23 +292,32 @@ export class MenuElement extends MediaElement { } #handleContentKeyDown = (event: UIKeyboardEvent): void => { + const isNavigationKey = isMenuNavigationKey(event); + const defaultPreventedBeforeMenu = event.defaultPrevented; + this.#menu?.contentProps.onKeyDown(event); const parentCtx = this.#parentCtx.value ?? null; - if (!parentCtx) return; + if (!parentCtx) { + if (event.key === 'Escape') return; + if (isNavigationKey) { + event.stopPropagation(); + } + return; + } const stack = parentCtx.menu.navigationInput.current.stack; const topEntry = stack[stack.length - 1]; const ownsActiveSubmenu = topEntry?.menuId === this.id; const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape'; - if (isBackNavigationKey && ownsActiveSubmenu && !event.defaultPrevented) { + if (isBackNavigationKey && ownsActiveSubmenu && !defaultPreventedBeforeMenu) { event.preventDefault(); parentCtx.menu.pop(); } - if (isMenuNavigationKey(event) && (!isBackNavigationKey || ownsActiveSubmenu)) { + if (isNavigationKey && (!isBackNavigationKey || ownsActiveSubmenu)) { event.stopPropagation(); } }; @@ -318,4 +354,11 @@ export class MenuElement extends MediaElement { this.#triggerAbort = null; this.#currentTrigger = null; } + + #getBoundaryElement(): Element | null { + return resolvePositioningBoundary(this.boundary, { + container: this.#containerCtx.value?.container ?? null, + root: this.getRootNode() as Document | ShadowRoot, + }); + } } diff --git a/packages/html/src/ui/menu/menu-item-element.ts b/packages/html/src/ui/menu/menu-item-element.ts index 63a33b46..6ce24a1c 100644 --- a/packages/html/src/ui/menu/menu-item-element.ts +++ b/packages/html/src/ui/menu/menu-item-element.ts @@ -79,7 +79,7 @@ export class MenuItemElement extends MediaElement { }, onPointerenter: () => { const currentCtx = this.#ctx.value; - if (!this.disabled) currentCtx?.menu.highlight(this); + if (!this.disabled) currentCtx?.menu.highlight(this, { focus: false }); }, }, { signal: this.#disconnect.signal } diff --git a/packages/html/src/ui/menu/menu-radio-group-element.ts b/packages/html/src/ui/menu/menu-radio-group-element.ts index e880cb07..e5d4e35a 100644 --- a/packages/html/src/ui/menu/menu-radio-group-element.ts +++ b/packages/html/src/ui/menu/menu-radio-group-element.ts @@ -6,7 +6,7 @@ import { MediaElement } from '../media-element'; import { menuContext, menuRadioGroupContext } from './context'; export class MenuRadioGroupElement extends MediaElement { - static readonly tagName = 'media-menu-radio-group'; + static readonly tagName: string = 'media-menu-radio-group'; static override properties = { value: { type: String }, diff --git a/packages/html/src/ui/menu/menu-radio-item-element.ts b/packages/html/src/ui/menu/menu-radio-item-element.ts index 9f5cf4bb..0b664aab 100644 --- a/packages/html/src/ui/menu/menu-radio-item-element.ts +++ b/packages/html/src/ui/menu/menu-radio-item-element.ts @@ -63,7 +63,7 @@ export class MenuRadioItemElement extends MediaElement { }, onPointerenter: () => { const currentMenuCtx = this.#menuCtx.value; - if (!this.disabled) currentMenuCtx?.menu.highlight(this); + if (!this.disabled) currentMenuCtx?.menu.highlight(this, { focus: false }); }, }, { signal: this.#disconnect.signal } diff --git a/packages/html/src/ui/menu/tests/menu-element.test.ts b/packages/html/src/ui/menu/tests/menu-element.test.ts index 2fd69ef1..bf011465 100644 --- a/packages/html/src/ui/menu/tests/menu-element.test.ts +++ b/packages/html/src/ui/menu/tests/menu-element.test.ts @@ -1,5 +1,12 @@ +import type { MediaControlsState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore, flush } from '@videojs/store'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { playerContext } from '../../../player/context'; +import { ControlsElement } from '../../controls/controls-element'; +import { MediaElement } from '../../media-element'; import { MenuCheckboxItemElement } from '../menu-checkbox-item-element'; import { MenuElement } from '../menu-element'; import { MenuItemElement } from '../menu-item-element'; @@ -17,6 +24,53 @@ function createElement(Base: abstract new () => Ele return document.createElement(tag) as Element; } +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function createControlsStore(): AnyPlayerStore { + return createStore()({ + name: 'controls', + state: ({ get, set }) => { + return { + userActive: true, + controlsVisible: true, + toggleControls() { + const visible = !(get().controlsVisible as boolean); + + set({ userActive: visible, controlsVisible: visible }); + + return visible; + }, + }; + }, + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + store = createControlsStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store }); + + override connectedCallback(): void { + super.connectedCallback(); + this.#provider.setValue(this.store); + } + + setVisible(visible: boolean): void { + const state = this.store.state as MediaControlsState; + + if (state.controlsVisible === visible) return; + + state.toggleControls(); + flush(); + } +} + +defineElement('test-menu-player-provider', TestPlayerProviderElement); + function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } @@ -209,6 +263,24 @@ describe('MenuElement', () => { ); }); + it('highlights pointer-entered items without moving focus', async () => { + const root = createElement(MenuElement); + const item = createElement(MenuItemElement); + + root.append(item); + document.body.append(root); + + await root.updateComplete; + await item.updateComplete; + + const focus = vi.spyOn(item, 'focus'); + + item.dispatchEvent(new Event('pointerenter')); + + expect(focus).not.toHaveBeenCalled(); + expect(item.hasAttribute('data-highlighted')).toBe(true); + }); + it('closes when focus moves outside the root menu', async () => { const root = createElement(MenuElement); const item = createElement(MenuItemElement); @@ -382,4 +454,107 @@ describe('MenuElement', () => { child.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); expect(onRootKeyDown).toHaveBeenCalledTimes(1); }); + + it('stops propagation for root menu keyboard navigation', async () => { + const wrapper = document.createElement('div'); + const root = createElement(MenuElement); + const item = createElement(MenuItemElement); + const onWrapperKeyDown = vi.fn(); + + root.open = true; + item.textContent = 'Auto'; + + wrapper.addEventListener('keydown', onWrapperKeyDown); + root.append(item); + wrapper.append(root); + document.body.append(wrapper); + + await root.updateComplete; + await item.updateComplete; + + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + + expect(onWrapperKeyDown).not.toHaveBeenCalled(); + expect(item.hasAttribute('data-highlighted')).toBe(true); + + const handled = root.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }) + ); + + expect(handled).toBe(false); + expect(onWrapperKeyDown).not.toHaveBeenCalled(); + + root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + + expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); + }); + + it('stops propagation for root trigger keyboard navigation while open', async () => { + const wrapper = document.createElement('div'); + const trigger = document.createElement('button'); + const root = createElement(MenuElement); + const item = createElement(MenuItemElement); + const onWrapperKeyDown = vi.fn(); + + root.id = 'root-menu'; + root.open = true; + trigger.setAttribute('commandfor', 'root-menu'); + item.textContent = 'Auto'; + + wrapper.addEventListener('keydown', onWrapperKeyDown); + root.append(item); + wrapper.append(trigger, root); + document.body.append(wrapper); + + await root.updateComplete; + await item.updateComplete; + + const handled = trigger.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }) + ); + + expect(handled).toBe(false); + expect(onWrapperKeyDown).not.toHaveBeenCalled(); + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + + expect(onWrapperKeyDown).not.toHaveBeenCalled(); + expect(item.hasAttribute('data-highlighted')).toBe(true); + }); + + it('closes an open root menu when parent controls hide', async () => { + const provider = document.createElement('test-menu-player-provider') as TestPlayerProviderElement; + const controls = createElement(ControlsElement); + const trigger = document.createElement('button'); + const root = createElement(MenuElement); + const item = createElement(MenuItemElement); + const onOpenChange = vi.fn(); + const focus = vi.spyOn(trigger, 'focus'); + + root.id = 'root-menu'; + root.open = true; + trigger.setAttribute('commandfor', 'root-menu'); + item.textContent = 'Auto'; + + root.addEventListener('open-change', onOpenChange); + root.append(item); + controls.append(trigger, root); + document.body.append(provider); + provider.append(controls); + + await controls.updateComplete; + await root.updateComplete; + await item.updateComplete; + + provider.setVisible(false); + + await waitForAssertion(() => { + expect(root.open).toBe(false); + }); + + expect(onOpenChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ open: false, reason: 'imperative-action' }) }) + ); + expect(focus).not.toHaveBeenCalled(); + }); }); diff --git a/packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts b/packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts new file mode 100644 index 00000000..8bf6dc74 --- /dev/null +++ b/packages/html/src/ui/playback-rate-menu/playback-rate-menu-element.ts @@ -0,0 +1,62 @@ +import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; +import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MenuElement } from '../menu/menu-element'; + +export class PlaybackRateMenuElement extends MenuElement { + static override readonly tagName = 'media-playback-rate-menu'; + + static override properties = { + ...MenuElement.properties, + label: { type: String }, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap< + | 'open' + | 'defaultOpen' + | 'side' + | 'align' + | 'closeOnEscape' + | 'closeOnOutsideClick' + | 'boundary' + | 'label' + | 'disabled' + >; + + label = ''; + disabled = false; + override align: MenuElement['align'] = 'center'; + formatRate = PlaybackRateMenuCore.defaultProps.formatRate; + + readonly #core = new PlaybackRateMenuCore(); + readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate); + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { + logMissingFeature(this.localName, this.#mediaState.displayName); + } + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.#mediaState.value; + if (!media) return; + + this.#core.setProps(this); + this.#core.setMedia(media); + const state = this.#core.getState(); + + applyElementProps(this, this.#core.getAttrs(state)); + applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs); + } +} + +export namespace PlaybackRateMenuElement { + export type State = PlaybackRateMenuCore.State; +} diff --git a/packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts b/packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts new file mode 100644 index 00000000..d61c0528 --- /dev/null +++ b/packages/html/src/ui/playback-rate-menu/playback-rate-menu-trigger-element.ts @@ -0,0 +1,100 @@ +import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; +import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; + +export class PlaybackRateMenuTriggerElement extends MediaElement { + static readonly tagName = 'media-playback-rate-menu-trigger'; + + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + commandfor: { type: String }, + } satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor'>; + + label = ''; + disabled = false; + commandfor: string | undefined = undefined; + formatRate = PlaybackRateMenuCore.defaultProps.formatRate; + + readonly #core = new PlaybackRateMenuCore(); + readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate); + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + this.#disconnect = new AbortController(); + applyElementProps( + this, + { + onClick: this.#handleClick, + onKeyDown: this.#handleKeyDown, + }, + { signal: this.#disconnect.signal } + ); + + if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { + logMissingFeature(this.localName, this.#mediaState.displayName); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + /** Returns the trigger's current label derived from media state. */ + getLabel(): string | undefined { + return this.#core.state.current.label || undefined; + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.#mediaState.value; + if (!media) return; + + this.#core.setProps(this); + this.#core.setMedia(media); + const state = this.#core.getState(); + + applyElementProps(this, { + role: 'button', + tabIndex: 0, + ...this.#core.getAttrs(state), + }); + applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs); + } + + #handleClick = (event: MouseEvent): void => { + if (this.#mediaState.value && !this.#core.state.current.disabled) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + #handleKeyDown = (event: KeyboardEvent): void => { + if (event.target !== event.currentTarget) return; + + if (!this.#mediaState.value || this.#core.state.current.disabled) { + if (event.key !== 'Tab') event.preventDefault(); + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.click(); + } + }; +} + +export namespace PlaybackRateMenuTriggerElement { + export type State = PlaybackRateMenuCore.State; +} diff --git a/packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts b/packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts new file mode 100644 index 00000000..4b37fda4 --- /dev/null +++ b/packages/html/src/ui/playback-rate-menu/playback-rate-options-element.ts @@ -0,0 +1,147 @@ +import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core'; +import { applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element'; +import { MenuRadioGroupElement } from '../menu/menu-radio-group-element'; +import { MenuRadioItemElement } from '../menu/menu-radio-item-element'; + +export class PlaybackRateOptionsElement extends MenuRadioGroupElement { + static override readonly tagName = 'media-playback-rate-options'; + + static override properties = { + ...MenuRadioGroupElement.properties, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>; + + disabled = false; + formatRate = PlaybackRateMenuCore.defaultProps.formatRate; + + readonly #core = new PlaybackRateMenuCore(); + readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate); + + #ratesKey = ''; + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + this.#disconnect = new AbortController(); + this.addEventListener('value-change', this.#handleValueChange, { signal: this.#disconnect.signal }); + + if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { + logMissingFeature(this.localName, this.#mediaState.displayName); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + protected override update(changed: PropertyValues): void { + const media = this.#mediaState.value; + let state: PlaybackRateMenuCore.State | null = null; + + if (media) { + this.#core.setProps({ formatRate: this.formatRate, disabled: this.disabled }); + this.#core.setMedia(media); + state = this.#core.getState(); + + this.value = this.#core.getRateValue(state.rate); + this.label = this.label || 'Playback rate'; + this.#syncContent(state); + } + + super.update(changed); + + if (state) applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs); + } + + #syncContent(state: PlaybackRateMenuCore.State): void { + const template = this.#getTemplate(); + const templateKey = template?.innerHTML ?? ''; + const ratesKey = `${state.rates.join('|')}::${templateKey}`; + + if (ratesKey !== this.#ratesKey) { + this.#ratesKey = ratesKey; + + for (const child of [...this.children]) { + if (child instanceof HTMLTemplateElement) continue; + child.remove(); + } + + this.append(...state.rates.map((rate) => this.#createItem(rate, template))); + } + + for (const item of this.querySelectorAll(MenuRadioItemElement.tagName)) { + const checked = item.value === this.value; + + item.disabled = state.disabled; + + for (const indicator of item.querySelectorAll(MenuItemIndicatorElement.tagName)) { + indicator.checked = checked; + } + } + } + + #createItem(rate: number, template: HTMLTemplateElement | null): MenuRadioItemElement { + const item = this.#createItemFromTemplate(template); + const value = this.#core.getRateValue(rate); + + item.value = value; + item.setAttribute('data-rate', value); + this.#setLabel(item, this.#core.getRateLabel(rate)); + + return item; + } + + #createItemFromTemplate(template: HTMLTemplateElement | null): MenuRadioItemElement { + if (!template) return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement; + + const fragment = template.content.cloneNode(true) as DocumentFragment; + const root = fragment.firstElementChild; + + if (!root || root.localName !== MenuRadioItemElement.tagName || root.nextElementSibling) { + return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement; + } + + return root as MenuRadioItemElement; + } + + #setLabel(item: MenuRadioItemElement, label: string): void { + const labelPart = item.querySelector('[data-part~="label"]'); + + if (labelPart) { + labelPart.textContent = label; + } else { + item.textContent = label; + } + } + + #getTemplate(): HTMLTemplateElement | null { + for (const child of this.children) { + if (child instanceof HTMLTemplateElement) return child; + } + + return null; + } + + #handleValueChange = (event: Event): void => { + if (event.target !== this) return; + + const media = this.#mediaState.value; + if (!media) return; + + const { value } = (event as CustomEvent<{ value: string }>).detail; + this.#core.selectValue(media, value); + }; +} + +export namespace PlaybackRateOptionsElement { + export type State = PlaybackRateMenuCore.State; +} diff --git a/packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts b/packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts new file mode 100644 index 00000000..e32279d8 --- /dev/null +++ b/packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts @@ -0,0 +1,235 @@ +import type { MediaPlaybackRateState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { MenuItemIndicatorElement } from '../../menu/menu-item-indicator-element'; +import { MenuRadioGroupElement } from '../../menu/menu-radio-group-element'; +import { MenuRadioItemElement } from '../../menu/menu-radio-item-element'; +import { PlaybackRateMenuElement } from '../playback-rate-menu-element'; +import { PlaybackRateMenuTriggerElement } from '../playback-rate-menu-trigger-element'; +import { PlaybackRateOptionsElement } from '../playback-rate-options-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; +} + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +function createPlaybackRateStore({ + playbackRates = [0.5, 1, 1.5, 2], + playbackRate = 1.5, + setPlaybackRate = vi.fn(), +}: { + playbackRates?: readonly number[] | undefined; + playbackRate?: number | undefined; + setPlaybackRate?: ((rate: number) => void) | undefined; +} = {}): AnyPlayerStore { + return createStore()({ + name: 'playbackRate', + state: () => { + return { + playbackRates, + playbackRate, + setPlaybackRate, + }; + }, + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + store: AnyPlayerStore = createPlaybackRateStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext }); + + override connectedCallback(): void { + this.#provider.setValue(this.store); + super.connectedCallback(); + } + + setStore(store: AnyPlayerStore): void { + this.store = store; + this.#provider.setValue(store); + } +} + +defineElement(MenuRadioGroupElement.tagName, MenuRadioGroupElement); +defineElement(MenuRadioItemElement.tagName, MenuRadioItemElement); +defineElement(MenuItemIndicatorElement.tagName, MenuItemIndicatorElement); +defineElement(PlaybackRateOptionsElement.tagName, PlaybackRateOptionsElement); +defineElement('test-playback-rate-player', TestPlayerProviderElement); + +function setup({ + playbackRates, + playbackRate, + setPlaybackRate, + template, +}: { + playbackRates?: readonly number[] | undefined; + playbackRate?: number | undefined; + setPlaybackRate?: ((rate: number) => void) | undefined; + template?: string | undefined; +} = {}) { + const store = createPlaybackRateStore({ playbackRates, playbackRate, setPlaybackRate }); + const provider = document.createElement('test-playback-rate-player') as TestPlayerProviderElement; + const trigger = createElement(PlaybackRateMenuTriggerElement); + const menu = createElement(PlaybackRateMenuElement); + const options = createElement(PlaybackRateOptionsElement); + + provider.setStore(store); + trigger.commandfor = 'playback-rate-menu'; + menu.id = 'playback-rate-menu'; + + if (template) { + const templateElement = document.createElement('template'); + templateElement.innerHTML = template; + options.append(templateElement); + } + + menu.append(options); + provider.append(trigger, menu); + document.body.append(provider); + + return { menu, options, provider, store, trigger }; +} + +async function waitForMenu( + menu: PlaybackRateMenuElement, + trigger?: PlaybackRateMenuTriggerElement, + options?: PlaybackRateOptionsElement +): Promise { + await trigger?.updateComplete; + await menu.updateComplete; + await options?.updateComplete; + + const group = menu.querySelector(PlaybackRateOptionsElement.tagName); + await group?.updateComplete; + + const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; + const indicators = [...menu.querySelectorAll(MenuItemIndicatorElement.tagName)]; + + await Promise.all(items.map((item) => item.updateComplete)); + await Promise.all(indicators.map((indicator) => indicator.updateComplete)); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('PlaybackRateMenuElement', () => { + it('renders radio items from the available playback rates', async () => { + const { menu, trigger } = setup({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 }); + + await waitForMenu(menu, trigger); + + const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; + + expect(items.map((item) => item.textContent)).toEqual(['1×', '1.25×', '1.5×']); + await waitForAssertion(() => { + expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual(['false', 'true', 'false']); + }); + expect(menu.getAttribute('aria-label')).toBe('Playback rate 1.25'); + expect(menu.getAttribute('data-rate')).toBe('1.25'); + }); + + it('renders radio items from a template', async () => { + const { menu, options, trigger } = setup({ + template: + '', + }); + + await waitForMenu(menu, trigger, options); + + const item = menu.querySelector(MenuRadioItemElement.tagName)!; + const indicators = [...menu.querySelectorAll(MenuItemIndicatorElement.tagName)]; + + expect(item.className).toBe('custom-item'); + expect(item.querySelector('[data-part~="label"]')?.textContent).toBe('0.5×'); + expect(indicators.map((indicator) => indicator.checked)).toEqual([false, false, true, false]); + }); + + it('center aligns the root popup by default', async () => { + const { menu, trigger } = setup(); + + await waitForMenu(menu, trigger); + + expect(menu.align).toBe('center'); + expect(menu.getAttribute('data-align')).toBe('center'); + }); + + it('sets the selected playback rate', async () => { + const setPlaybackRate = vi.fn(); + const { menu, trigger } = setup({ setPlaybackRate }); + + await waitForMenu(menu, trigger); + + const item = [...menu.querySelectorAll(MenuRadioItemElement.tagName)].find( + (candidate) => candidate.value === '2' + )!; + + item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(setPlaybackRate).toHaveBeenCalledWith(2); + }); +}); + +describe('PlaybackRateMenuTriggerElement', () => { + it('renders a dynamic trigger from the current playback rate', async () => { + const { trigger } = setup({ playbackRate: 2 }); + + await trigger.updateComplete; + + expect(trigger.getAttribute('role')).toBe('button'); + expect(trigger.getAttribute('aria-label')).toBe('Playback rate 2'); + expect(trigger.getAttribute('data-rate')).toBe('2'); + }); + + it('prevents activation when there are no playback rates', async () => { + const { trigger } = setup({ playbackRates: [] }); + + await trigger.updateComplete; + + const onClick = vi.fn(); + trigger.addEventListener('click', onClick); + trigger.click(); + + expect(trigger.getAttribute('aria-disabled')).toBe('true'); + expect(trigger.hasAttribute('data-disabled')).toBe(true); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/html/src/ui/popover/popover-element.ts b/packages/html/src/ui/popover/popover-element.ts index e64d4d82..2bc82d0d 100644 --- a/packages/html/src/ui/popover/popover-element.ts +++ b/packages/html/src/ui/popover/popover-element.ts @@ -1,4 +1,4 @@ -import { PopoverCore, PopoverDataAttrs, type PopoverInput, type PopoverProps } from '@videojs/core'; +import { POPUP_HOST_ATTR, PopoverCore, PopoverDataAttrs, type PopoverInput, type PopoverProps } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, @@ -7,17 +7,21 @@ import { getAnchorNameStyle, getAnchorPositionStyle, getPopupPositionRect, + getPositioningBoundaryRect, type PopoverApi, type PopoverChangeDetails, + type PopoverOpenChangeReason, + type PositioningBoundary, resolveOffsets, + resolvePositioningBoundary, } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; import { SnapshotController } from '@videojs/store/html'; import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; - +import { containerContext } from '../../player/context'; import { MediaElement } from '../media-element'; import { PositionController } from '../position-controller'; - export class PopoverElement extends MediaElement { static readonly tagName = 'media-popover'; @@ -32,7 +36,8 @@ export class PopoverElement extends MediaElement { openOnHover: { type: Boolean, attribute: 'open-on-hover' }, delay: { type: Number }, closeDelay: { type: Number, attribute: 'close-delay' }, - } satisfies PropertyDeclarationMap; + boundary: { type: String }, + } satisfies PropertyDeclarationMap; open = PopoverCore.defaultProps.open; defaultOpen = PopoverCore.defaultProps.defaultOpen; @@ -44,8 +49,10 @@ export class PopoverElement extends MediaElement { openOnHover = PopoverCore.defaultProps.openOnHover; delay = PopoverCore.defaultProps.delay; closeDelay = PopoverCore.defaultProps.closeDelay; + boundary: PositioningBoundary = 'container'; readonly #core = new PopoverCore(); + readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true }); readonly #position = new PositionController(this); #popover: PopoverApi | null = null; #snapshot: SnapshotController | null = null; @@ -59,6 +66,8 @@ export class PopoverElement extends MediaElement { super.connectedCallback(); if (this.destroyed) return; + this.setAttribute(POPUP_HOST_ATTR, ''); + this.#disconnect = new AbortController(); this.#popover = createPopover({ @@ -72,6 +81,7 @@ export class PopoverElement extends MediaElement { openOnHover: () => this.openOnHover, delay: () => this.delay, closeDelay: () => this.closeDelay, + group: () => this.#containerCtx.value?.popupGroup, }); // Register self as the popup element — the element IS the popup. @@ -112,6 +122,10 @@ export class PopoverElement extends MediaElement { super.destroyCallback(); } + close(reason: PopoverOpenChangeReason = 'imperative-action'): void { + this.#popover?.close(reason); + } + protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); this.#core.setProps(this); @@ -168,20 +182,20 @@ export class PopoverElement extends MediaElement { // Apply positioning styles to self. const posOpts = { side: state.side, align: state.align }; + const boundaryElement = this.#getBoundaryElement(); + const triggerRect = this.#currentTrigger?.getBoundingClientRect(); + const boundaryRect = getPositioningBoundaryRect(boundaryElement); + const offsets = resolveOffsets(this); if (supportsAnchorPositioning()) { - // Native CSS Anchor Positioning — no JS rect measurements needed. - applyStyles(this, getAnchorPositionStyle(this.id, posOpts)); + applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, undefined, boundaryRect, offsets)); } else { // JS fallback: measure rects and resolve CSS var offsets. - const triggerRect = this.#currentTrigger?.getBoundingClientRect(); const selfRect = getPopupPositionRect(this); - const boundaryRect = document.documentElement.getBoundingClientRect(); - const offsets = resolveOffsets(this); applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets)); } - this.#position.sync(this.#currentTrigger); + this.#position.sync(this.#currentTrigger, boundaryElement); } // --- Trigger management --- @@ -215,4 +229,11 @@ export class PopoverElement extends MediaElement { this.#triggerAbort = null; this.#currentTrigger = null; } + + #getBoundaryElement(): Element | null { + return resolvePositioningBoundary(this.boundary, { + container: this.#containerCtx.value?.container ?? null, + root: this.getRootNode() as Document | ShadowRoot, + }); + } } diff --git a/packages/html/src/ui/position-controller.ts b/packages/html/src/ui/position-controller.ts index 443009ce..cd1a6821 100644 --- a/packages/html/src/ui/position-controller.ts +++ b/packages/html/src/ui/position-controller.ts @@ -1,5 +1,5 @@ +import { isEventWithinElement } from '@videojs/core/dom'; import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; -import { supportsAnchorPositioning } from '@videojs/utils/dom'; export type PositionControllerHost = ReactiveControllerHost & HTMLElement; @@ -8,7 +8,8 @@ export type PositionControllerHost = ReactiveControllerHost & HTMLElement; * popup elements (tooltips, popovers). Tracks scroll, resize, and * ResizeObserver events to keep the popup aligned with its trigger. * - * When native CSS Anchor Positioning is supported, `sync()` is a no-op. + * Native CSS Anchor Positioning still uses this controller for dynamic + * sizing variables such as available width/height. */ export class PositionController implements ReactiveController { readonly #host: PositionControllerHost; @@ -17,6 +18,7 @@ export class PositionController implements ReactiveController { #frame = 0; #resizeObserver: ResizeObserver | null = null; #trigger: HTMLElement | null = null; + #boundary: Element | null = null; constructor(host: PositionControllerHost) { this.#host = host; @@ -31,17 +33,19 @@ export class PositionController implements ReactiveController { } /** Start or update position tracking for the given trigger. */ - sync(trigger: HTMLElement | null): void { - if (supportsAnchorPositioning()) return; + sync(trigger: HTMLElement | null, boundary: Element | null = null): void { if (!trigger) return; - if (this.#abort && this.#trigger === trigger) return; + if (this.#abort && this.#trigger === trigger && this.#boundary === boundary) return; this.cleanup(); this.#abort = new AbortController(); this.#trigger = trigger; + this.#boundary = boundary; const { signal } = this.#abort; - const reposition = () => { + const reposition = (event?: Event) => { + if (event && isEventWithinElement(event, this.#host)) return; + cancelAnimationFrame(this.#frame); this.#frame = requestAnimationFrame(() => { if (signal.aborted) return; @@ -58,6 +62,7 @@ export class PositionController implements ReactiveController { }); this.#resizeObserver.observe(trigger); this.#resizeObserver.observe(this.#host); + if (boundary) this.#resizeObserver.observe(boundary); } reposition(); @@ -68,8 +73,10 @@ export class PositionController implements ReactiveController { this.#abort?.abort(); this.#abort = null; this.#trigger = null; + this.#boundary = null; cancelAnimationFrame(this.#frame); this.#frame = 0; + // Disconnect unobserves trigger, host, and boundary together. this.#resizeObserver?.disconnect(); this.#resizeObserver = null; } diff --git a/packages/html/src/ui/tooltip/tooltip-element.ts b/packages/html/src/ui/tooltip/tooltip-element.ts index 204fdfc7..74660fa7 100644 --- a/packages/html/src/ui/tooltip/tooltip-element.ts +++ b/packages/html/src/ui/tooltip/tooltip-element.ts @@ -1,4 +1,11 @@ -import { type ButtonState, TooltipCore, TooltipCSSVars, TooltipDataAttrs, type TooltipInput } from '@videojs/core'; +import { + type ButtonState, + POPUP_HOST_ATTR, + TooltipCore, + TooltipCSSVars, + TooltipDataAttrs, + type TooltipInput, +} from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, @@ -7,9 +14,13 @@ import { getAnchorNameStyle, getAnchorPositionStyle, getPopupPositionRect, + getPositioningBoundaryRect, + type PositioningBoundary, resolveOffsets, + resolvePositioningBoundary, type TooltipApi, type TooltipChangeDetails, + type TooltipOpenChangeReason, } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; @@ -17,6 +28,7 @@ import type { State } from '@videojs/store'; import { SnapshotController } from '@videojs/store/html'; import { applyStyles, 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'; @@ -42,7 +54,8 @@ export class TooltipElement extends MediaElement { closeDelay: { type: Number, attribute: 'close-delay' }, disableHoverablePopup: { type: Boolean, attribute: 'disable-hoverable-popup' }, disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap; + boundary: { type: String }, + } satisfies PropertyDeclarationMap; open = TooltipCore.defaultProps.open; defaultOpen = TooltipCore.defaultProps.defaultOpen; @@ -52,9 +65,11 @@ export class TooltipElement extends MediaElement { closeDelay = TooltipCore.defaultProps.closeDelay; disableHoverablePopup = TooltipCore.defaultProps.disableHoverablePopup; disabled = TooltipCore.defaultProps.disabled; + boundary: PositioningBoundary = 'container'; readonly #core = new TooltipCore(); readonly #groupConsumer = new ContextConsumer(this, { context: tooltipGroupContext }); + readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true }); readonly #position = new PositionController(this); #tooltip: TooltipApi | null = null; #snapshot: SnapshotController | null = null; @@ -68,6 +83,8 @@ export class TooltipElement extends MediaElement { super.connectedCallback(); if (this.destroyed) return; + this.setAttribute(POPUP_HOST_ATTR, ''); + this.#disconnect = new AbortController(); this.#tooltip = createTooltip({ @@ -117,6 +134,10 @@ export class TooltipElement extends MediaElement { this.#disconnect = null; } + close(reason: TooltipOpenChangeReason = 'imperative-action'): void { + this.#tooltip?.close(reason); + } + protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); this.#core.setProps(this); @@ -172,26 +193,26 @@ export class TooltipElement extends MediaElement { // Apply positioning styles to self. const posOpts = { side: state.side, align: state.align }; + const boundaryElement = this.#getBoundaryElement(); + const triggerRect = this.#currentTrigger?.getBoundingClientRect(); + const boundaryRect = getPositioningBoundaryRect(boundaryElement); + const offsets = resolveOffsets(this, TooltipCSSVars); if (supportsAnchorPositioning()) { - // Native CSS Anchor Positioning — no JS rect measurements needed. applyStyles( this, - getAnchorPositionStyle(this.id, posOpts, undefined, undefined, undefined, undefined, TooltipCSSVars) + getAnchorPositionStyle(this.id, posOpts, triggerRect, undefined, boundaryRect, offsets, TooltipCSSVars) ); } else { // JS fallback: measure rects and resolve CSS var offsets. - const triggerRect = this.#currentTrigger?.getBoundingClientRect(); const selfRect = getPopupPositionRect(this); - const boundaryRect = document.documentElement.getBoundingClientRect(); - const offsets = resolveOffsets(this, TooltipCSSVars); applyStyles( this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets, TooltipCSSVars) ); } - this.#position.sync(this.#currentTrigger); + this.#position.sync(this.#currentTrigger, boundaryElement); } // --- Trigger management --- @@ -230,4 +251,11 @@ export class TooltipElement extends MediaElement { this.#triggerAbort = null; this.#currentTrigger = null; } + + #getBoundaryElement(): Element | null { + return resolvePositioningBoundary(this.boundary, { + container: this.#containerCtx.value?.container ?? null, + root: this.getRootNode() as Document | ShadowRoot, + }); + } } diff --git a/packages/icons/src/assets/default/cast-enter.svg b/packages/icons/src/assets/default/cast-enter.svg index 9f8d3416..e32138b0 100644 --- a/packages/icons/src/assets/default/cast-enter.svg +++ b/packages/icons/src/assets/default/cast-enter.svg @@ -1,6 +1,11 @@ - - - - - + + + + + + + + + + diff --git a/packages/icons/src/assets/default/cast-exit.svg b/packages/icons/src/assets/default/cast-exit.svg index 64749aaf..67f889ca 100644 --- a/packages/icons/src/assets/default/cast-exit.svg +++ b/packages/icons/src/assets/default/cast-exit.svg @@ -1,7 +1,13 @@ - - - - - - + + + + + + + + + + + + diff --git a/packages/icons/src/assets/default/check.svg b/packages/icons/src/assets/default/check.svg new file mode 100644 index 00000000..4128f9a2 --- /dev/null +++ b/packages/icons/src/assets/default/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/assets/default/pip-enter.svg b/packages/icons/src/assets/default/pip-enter.svg index bf486975..4fd508a1 100644 --- a/packages/icons/src/assets/default/pip-enter.svg +++ b/packages/icons/src/assets/default/pip-enter.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/icons/src/assets/default/restart.svg b/packages/icons/src/assets/default/restart.svg index c4754be3..387f1394 100644 --- a/packages/icons/src/assets/default/restart.svg +++ b/packages/icons/src/assets/default/restart.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/icons/src/assets/default/seek.svg b/packages/icons/src/assets/default/seek.svg index 252e5f17..e6ef795d 100644 --- a/packages/icons/src/assets/default/seek.svg +++ b/packages/icons/src/assets/default/seek.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/icons/src/assets/default/spinner.svg b/packages/icons/src/assets/default/spinner.svg index 5cd479c3..547fdb15 100644 --- a/packages/icons/src/assets/default/spinner.svg +++ b/packages/icons/src/assets/default/spinner.svg @@ -1,21 +1,13 @@ - + - - - - - - - - + + + + + + + + diff --git a/packages/icons/src/assets/minimal/cast-enter.svg b/packages/icons/src/assets/minimal/cast-enter.svg index 56ec8792..868683d2 100644 --- a/packages/icons/src/assets/minimal/cast-enter.svg +++ b/packages/icons/src/assets/minimal/cast-enter.svg @@ -1,6 +1,11 @@ - - - - - + + + + + + + + + + diff --git a/packages/icons/src/assets/minimal/cast-exit.svg b/packages/icons/src/assets/minimal/cast-exit.svg index ad2900a6..197aa84b 100644 --- a/packages/icons/src/assets/minimal/cast-exit.svg +++ b/packages/icons/src/assets/minimal/cast-exit.svg @@ -1,7 +1,13 @@ - - - - - - + + + + + + + + + + + + diff --git a/packages/icons/src/assets/minimal/check.svg b/packages/icons/src/assets/minimal/check.svg new file mode 100644 index 00000000..4128f9a2 --- /dev/null +++ b/packages/icons/src/assets/minimal/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/assets/minimal/spinner.svg b/packages/icons/src/assets/minimal/spinner.svg index 5cd479c3..547fdb15 100644 --- a/packages/icons/src/assets/minimal/spinner.svg +++ b/packages/icons/src/assets/minimal/spinner.svg @@ -1,21 +1,13 @@ - + - - - - - - - - + + + + + + + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c0fabf07..8627c640 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -16,6 +16,7 @@ export { useContainerAttach, useMedia, useMediaAttach, + useOptionalContainer, useOptionalPlayer, usePlayer, usePlayerContext, @@ -51,6 +52,12 @@ export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; +export { + PlaybackRateMenu, + type PlaybackRateMenuOption, + type PlaybackRateMenuResult, + usePlaybackRateMenu, +} from './ui/playback-rate-menu'; export { Popover, type PopoverContextValue, usePopoverContext } from './ui/popover'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; diff --git a/packages/react/src/player/context.tsx b/packages/react/src/player/context.tsx index d78a3ad6..c72d005f 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -1,7 +1,7 @@ 'use client'; import type { Media } from '@videojs/core'; -import type { MediaContainer } from '@videojs/core/dom'; +import type { MediaContainer, PopupGroup } from '@videojs/core/dom'; import type { UnknownState, UnknownStore } from '@videojs/store'; import { useStore } from '@videojs/store/react'; import type { Dispatch, HTMLAttributes, ReactNode, PointerEvent as ReactPointerEvent, SetStateAction } from 'react'; @@ -15,6 +15,7 @@ export interface PlayerContextValue { setMedia: Dispatch>; container: MediaContainer | null; setContainer: Dispatch>; + popupGroup?: PopupGroup; } const PlayerContext = createContext(null); @@ -88,6 +89,18 @@ export function useContainer(): MediaContainer | null { return container; } +/** Access the container element when a Player Provider is available. */ +export function useOptionalContainer(): MediaContainer | null { + const ctx = useContext(PlayerContext); + return ctx?.container ?? null; +} + +/** Access the interactive popup group when a Player Provider is available. */ +export function useOptionalPopupGroup(): PopupGroup | undefined { + const ctx = useContext(PlayerContext); + return ctx?.popupGroup; +} + /** Access the media attach setter for connecting a media element to the player. */ export function useMediaAttach(): Dispatch> | undefined { const ctx = useContext(PlayerContext); diff --git a/packages/react/src/player/create-player.tsx b/packages/react/src/player/create-player.tsx index 4d28d8dd..f8d0adac 100644 --- a/packages/react/src/player/create-player.tsx +++ b/packages/react/src/player/create-player.tsx @@ -11,6 +11,7 @@ import type { VideoFeatures, VideoPlayerStore, } from '@videojs/core/dom'; +import { createPopupGroup } from '@videojs/core/dom'; import type { InferStoreState } from '@videojs/store'; import { combine, createStore } from '@videojs/store'; import { useStore } from '@videojs/store/react'; @@ -70,6 +71,7 @@ export function createPlayer( export function createPlayer(config: CreatePlayerConfig): CreatePlayerResult { function Provider({ children }: ProviderProps): ReactNode { const [store] = useState(() => createStore()(combine(...config.features))); + const [popupGroup] = useState(() => createPopupGroup()); const [media, setMedia] = useState(null); const [container, setContainer] = useState(null); @@ -81,7 +83,7 @@ export function createPlayer(config: CreatePlayerConfig): Cr }, [media, container, store]); return ( - + {children} ); diff --git a/packages/react/src/player/tests/context.test.tsx b/packages/react/src/player/tests/context.test.tsx index 3ca36ced..83ac48bf 100644 --- a/packages/react/src/player/tests/context.test.tsx +++ b/packages/react/src/player/tests/context.test.tsx @@ -11,6 +11,7 @@ import { useContainerAttach, useMedia, useMediaAttach, + useOptionalContainer, useOptionalPlayer, usePlayer, usePlayerContext, @@ -116,6 +117,24 @@ describe('useContainerAttach', () => { }); }); +describe('useOptionalContainer', () => { + it('returns null outside Provider', () => { + const { result } = renderHook(() => useOptionalContainer()); + expect(result.current).toBeNull(); + }); + + it('returns container inside Provider', () => { + const container = document.createElement('div'); + const value = createContextValue({ container }); + + const { result } = renderHook(() => useOptionalContainer(), { + wrapper: createWrapper(value), + }); + + expect(result.current).toBe(container); + }); +}); + describe('usePlayer', () => { it('returns store without selector', () => { const store = createMockStore(); diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index 4db7b85a..536816b8 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -1,4 +1,3 @@ -import { playbackRate } from '@videojs/skins/default/tailwind/audio.tailwind'; import { button, buttonGroup, @@ -8,6 +7,8 @@ import { iconContainer, iconFlipped, iconState, + menu, + playbackRate, popup, root, seek, @@ -17,6 +18,7 @@ import { import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { + CheckIcon, PauseIcon, PlayIcon, RestartIcon, @@ -27,9 +29,10 @@ import { } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; import { Time } from '@/ui/time'; @@ -103,7 +106,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + }> @@ -117,6 +120,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + /* ------------------------------------------ Skin ------------------------------------------- */ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNode { @@ -143,7 +163,7 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
- + }> @@ -156,7 +176,7 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod - + }> @@ -170,7 +190,7 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod Seek backward {SEEK_TIME} seconds - + }> @@ -202,10 +222,12 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
- - } />} /> - Toggle playback rate - + + } /> + + + +
diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index a2d6524a..b49dadb5 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -1,6 +1,7 @@ import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { + CheckIcon, PauseIcon, PlayIcon, RestartIcon, @@ -11,11 +12,14 @@ import { } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -51,7 +55,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + @@ -65,6 +69,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { const { children, className, ...rest } = props; @@ -89,7 +110,7 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
- + }> @@ -102,7 +123,7 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { - + }> @@ -116,7 +137,7 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode { Seek backward {SEEK_TIME} seconds - + }> @@ -148,17 +169,36 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
- - } />} - /> - Toggle playback rate - + + } /> + + + +
+ + {/* Hotkeys */} + + + + + + + + + + + + + + + + {/* Input Feedback */} + ); } diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index efa333d0..430866d0 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -7,6 +7,7 @@ import { iconContainer, iconFlipped, iconState, + menu, playbackRate, popup, root, @@ -16,14 +17,26 @@ import { } from '@videojs/skins/default/tailwind/audio.tailwind'; import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; -import { PauseIcon, PlayIcon, RestartIcon, SeekIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; +import { + CheckIcon, + PauseIcon, + PlayIcon, + RestartIcon, + SeekIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@/icons'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -95,7 +108,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + }> @@ -109,6 +122,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + /* ------------------------------------------ Skin ------------------------------------------- */ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { @@ -135,7 +165,7 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
- + }> @@ -148,7 +178,7 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { - + }> @@ -162,7 +192,7 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { Seek backward {SEEK_TIME} seconds - + }> @@ -190,15 +220,36 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
- - } />} /> - Toggle playback rate - + + } /> + + + +
+ + {/* Hotkeys */} + + + + + + + + + + + + + + + + {/* Input Feedback */} + ); } diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index 4cca51a2..369f1a0d 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -1,14 +1,25 @@ import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; -import { PauseIcon, PlayIcon, RestartIcon, SeekIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; +import { + CheckIcon, + PauseIcon, + PlayIcon, + RestartIcon, + SeekIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@/icons'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { SeekButton } from '@/ui/seek-button'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; @@ -44,7 +55,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + @@ -58,6 +69,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + export function AudioSkin(props: AudioSkinProps): ReactNode { const { children, className, ...rest } = props; @@ -82,7 +110,7 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
- + }> @@ -95,7 +123,7 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { - + }> @@ -109,7 +137,7 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { Seek backward {SEEK_TIME} seconds - + }> @@ -137,12 +165,12 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
- - } />} - /> - Toggle playback rate - + + } /> + + + +
@@ -164,6 +192,9 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { + + {/* Input Feedback */} + ); } 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 884f4b8f..ce253c32 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -14,10 +14,12 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Tooltip } from '@/ui/tooltip'; import { VolumeSlider } from '@/ui/volume-slider'; import type { MinimalLiveAudioSkinProps } from './minimal-skin'; @@ -79,7 +81,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + }> @@ -117,7 +119,7 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps):
- + }> @@ -140,6 +142,16 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps):
+ + {/* Hotkeys */} + + + + + + + {/* Input Feedback */} + ); } diff --git a/packages/react/src/presets/live-audio/minimal-skin.tsx b/packages/react/src/presets/live-audio/minimal-skin.tsx index c7e87a22..061e8eb0 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tsx @@ -3,10 +3,12 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Tooltip } from '@/ui/tooltip'; import { VolumeSlider } from '@/ui/volume-slider'; import type { BaseSkinProps } from '../types'; @@ -38,7 +40,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + @@ -83,7 +85,7 @@ export function MinimalLiveAudioSkin(props: MinimalLiveAudioSkinProps): ReactNod
- + }> @@ -106,6 +108,16 @@ export function MinimalLiveAudioSkin(props: MinimalLiveAudioSkinProps): ReactNod
+ + {/* Hotkeys */} + + + + + + + {/* Input Feedback */} + ); } diff --git a/packages/react/src/presets/live-audio/skin.tailwind.tsx b/packages/react/src/presets/live-audio/skin.tailwind.tsx index 61883141..eb17d2b2 100644 --- a/packages/react/src/presets/live-audio/skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/skin.tailwind.tsx @@ -14,10 +14,12 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Tooltip } from '@/ui/tooltip'; import { VolumeSlider } from '@/ui/volume-slider'; import type { LiveAudioSkinProps } from './skin'; @@ -79,7 +81,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + }> @@ -117,7 +119,7 @@ export function LiveAudioSkinTailwind(props: LiveAudioSkinProps): ReactNode {
- + }> @@ -140,6 +142,16 @@ export function LiveAudioSkinTailwind(props: LiveAudioSkinProps): ReactNode {
+ + {/* Hotkeys */} + + + + + + + {/* Input Feedback */} + ); } diff --git a/packages/react/src/presets/live-audio/skin.tsx b/packages/react/src/presets/live-audio/skin.tsx index 2ff71639..bffdcdc2 100644 --- a/packages/react/src/presets/live-audio/skin.tsx +++ b/packages/react/src/presets/live-audio/skin.tsx @@ -8,6 +8,7 @@ import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { Tooltip } from '@/ui/tooltip'; import { VolumeSlider } from '@/ui/volume-slider'; import type { BaseSkinProps } from '../types'; @@ -39,7 +40,7 @@ function VolumePopover(): ReactNode { if (volumeUnsupported) return muteButton; return ( - + @@ -83,7 +84,7 @@ export function LiveAudioSkin(props: LiveAudioSkinProps): ReactNode {
- + }> @@ -113,6 +114,9 @@ export function LiveAudioSkin(props: LiveAudioSkinProps): ReactNode { + + {/* Input Feedback */} + ); } 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 f868b5b4..7ffa03e1 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -49,7 +49,7 @@ import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; import { VolumeIndicator } from '@/ui/volume-indicator'; diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index 034fb762..94b4d614 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -33,7 +33,7 @@ import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; import { VolumeIndicator } from '@/ui/volume-indicator'; diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index 98058eca..ae9abcc2 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -49,7 +49,7 @@ import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; import { VolumeIndicator } from '@/ui/volume-indicator'; @@ -299,6 +299,56 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode {
+ + {/* Hotkeys */} + + + + + + + + + + {/* Gestures */} + + + + + {/* Input Feedback */} + +
+ + + + + + + + + + +
+ + + + + + + +
+
+ + + + + +
); } diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index bbbbe99a..c9fcba53 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -33,7 +33,7 @@ import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Tooltip } from '@/ui/tooltip'; import { VolumeIndicator } from '@/ui/volume-indicator'; diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 10804582..e8be426e 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -1,4 +1,3 @@ -import { playbackRate } from '@videojs/skins/default/tailwind/video.tailwind'; import { bufferingIndicator, button, @@ -11,7 +10,9 @@ import { iconFlipped, iconState, inputFeedback, + menu, overlay, + playbackRate, popup, poster, preview, @@ -28,6 +29,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, ChevronIcon, FullscreenEnterIcon, FullscreenExitIcon, @@ -51,16 +53,17 @@ import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; import { SeekButton } from '@/ui/seek-button'; import { SeekIndicator } from '@/ui/seek-indicator'; import { Slider } from '@/ui/slider'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; @@ -151,6 +154,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + /* ------------------------------------------ Skin ------------------------------------------- */ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode { @@ -262,10 +282,12 @@ 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 b71c3f2f..0fde7b61 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -6,6 +6,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, ChevronIcon, FullscreenEnterIcon, FullscreenExitIcon, @@ -29,16 +30,17 @@ import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; import { SeekButton } from '@/ui/seek-button'; import { SeekIndicator } from '@/ui/seek-indicator'; import { Slider } from '@/ui/slider'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; @@ -93,6 +95,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { const { children, className, poster, ...rest } = props; @@ -196,12 +215,12 @@ 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 652017e5..db2cc3c5 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -10,6 +10,7 @@ import { iconFlipped, iconState, inputFeedback, + menu, overlay, playbackRate, popup, @@ -28,6 +29,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, ChevronIcon, FullscreenEnterIcon, FullscreenExitIcon, @@ -51,16 +53,17 @@ import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; import { SeekButton } from '@/ui/seek-button'; import { SeekIndicator } from '@/ui/seek-indicator'; import { Slider } from '@/ui/slider'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; @@ -151,6 +154,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + /* ------------------------------------------ Skin ------------------------------------------- */ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { @@ -258,10 +278,12 @@ 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 e9265477..0d856909 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -6,6 +6,7 @@ import { CaptionsOnIcon, CastEnterIcon, CastExitIcon, + CheckIcon, ChevronIcon, FullscreenEnterIcon, FullscreenExitIcon, @@ -29,16 +30,17 @@ import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; +import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; -import { PlaybackRateButton } from '@/ui/playback-rate-button'; +import { PlaybackRateMenu, usePlaybackRateMenu } from '@/ui/playback-rate-menu'; import { Popover } from '@/ui/popover'; import { Poster } from '@/ui/poster'; import { SeekButton } from '@/ui/seek-button'; import { SeekIndicator } from '@/ui/seek-indicator'; import { Slider } from '@/ui/slider'; -import { StatusAnnouncer } from '@/ui/status-announcer/status-announcer'; +import { StatusAnnouncer } from '@/ui/status-announcer'; import { StatusIndicator } from '@/ui/status-indicator'; import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; @@ -93,6 +95,23 @@ function VolumePopover(): ReactNode { ); } +function PlaybackRateMenuItems(): ReactNode { + const { options, setValue, value } = usePlaybackRateMenu(); + + return ( + + {options.map((option) => ( + + {option.label} + + + + + ))} + + ); +} + export function VideoSkin(props: VideoSkinProps): ReactNode { const { children, className, poster, ...rest } = props; @@ -192,12 +211,12 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
- - } />} - /> - - + + } /> + + + + diff --git a/packages/react/src/ui/controls/context.tsx b/packages/react/src/ui/controls/context.tsx index 1e8dd244..16fbb261 100644 --- a/packages/react/src/ui/controls/context.tsx +++ b/packages/react/src/ui/controls/context.tsx @@ -17,3 +17,7 @@ export function useControlsContext(): ControlsContextValue { if (!ctx) throw new Error('Controls compound components must be used within a Controls.Root'); return ctx; } + +export function useOptionalControlsContext(): ControlsContextValue | null { + return useContext(ControlsContext); +} diff --git a/packages/react/src/ui/menu/context.tsx b/packages/react/src/ui/menu/context.tsx index 68b90e0a..dd9657fd 100644 --- a/packages/react/src/ui/menu/context.tsx +++ b/packages/react/src/ui/menu/context.tsx @@ -1,7 +1,7 @@ 'use client'; import type { MenuCore, MenuState, StateAttrMap } from '@videojs/core'; -import type { MenuApi } from '@videojs/core/dom'; +import type { MediaContainer, MenuApi, PositioningBoundary } from '@videojs/core/dom'; import { createContext, useContext } from 'react'; export interface MenuContextValue { @@ -11,6 +11,8 @@ export interface MenuContextValue { stateAttrMap: StateAttrMap; contentId: string; anchorName: string; + boundary: PositioningBoundary; + container: MediaContainer | null; /** ID of the currently visible submenu, or null when at root view. */ activeSubMenuId: string | null; /** Triggerer ID of the active submenu entry (for focus restoration on pop). */ diff --git a/packages/react/src/ui/menu/menu-checkbox-item.tsx b/packages/react/src/ui/menu/menu-checkbox-item.tsx index 4e7cbbb5..e0e3624b 100644 --- a/packages/react/src/ui/menu/menu-checkbox-item.tsx +++ b/packages/react/src/ui/menu/menu-checkbox-item.tsx @@ -42,7 +42,7 @@ export const MenuCheckboxItem = forwardRef { const element = elementRef.current; if (!element || disabled) return; - menu.highlight(element); + menu.highlight(element, { focus: false }); }, [menu, disabled]); return renderElement( diff --git a/packages/react/src/ui/menu/menu-content.tsx b/packages/react/src/ui/menu/menu-content.tsx index 9257eba0..09a5ab6f 100644 --- a/packages/react/src/ui/menu/menu-content.tsx +++ b/packages/react/src/ui/menu/menu-content.tsx @@ -8,9 +8,12 @@ import { getMenuViewportElement, getMenuViewTransitionAttrs, getPopupPositionRect, + getPositioningBoundaryRect, getRootPositionOptions, + isEventWithinElement, isMenuNavigationKey, resolveOffsets, + resolvePositioningBoundary, syncMenuViewRoot, syncMenuViewTransition, type UIFocusEvent, @@ -30,6 +33,7 @@ import { useMenuContext, useSubMenuContext } from './context'; export interface MenuContentProps extends UIComponentProps<'div', MenuState> {} const POPOVER_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 }; +const menuPreventedNativeEvents = new WeakSet(); function toUIKeyboardEvent(event: React.KeyboardEvent): UIKeyboardEvent { return { @@ -59,12 +63,53 @@ function toUIFocusEvent(event: React.FocusEvent): UIFocusEvent { }; } +function preventMenuKeyDefault(event: React.KeyboardEvent): void { + const keyboardEvent = toUIKeyboardEvent(event); + + if (event.key !== 'Escape' && isMenuNavigationKey(keyboardEvent) && !event.defaultPrevented) { + event.preventDefault(); + menuPreventedNativeEvents.add(event.nativeEvent); + } +} + +function wasDefaultPreventedByMenu(event: React.KeyboardEvent): boolean { + return menuPreventedNativeEvents.has(event.nativeEvent); +} + +function callKeyDownHandler( + handler: React.KeyboardEventHandler | undefined, + event: React.KeyboardEvent +): boolean { + const defaultPreventedBeforeHandler = event.defaultPrevented && !wasDefaultPreventedByMenu(event); + + if (!handler) return defaultPreventedBeforeHandler; + + let defaultPreventedByHandler = false; + const preventDefault = event.preventDefault; + + // Capture-phase menu handling may have already prevented default; track + // whether the consumer also calls preventDefault while their handler runs. + event.preventDefault = () => { + defaultPreventedByHandler = true; + preventDefault.call(event); + }; + + try { + handler(event); + } finally { + event.preventDefault = preventDefault; + } + + return defaultPreventedBeforeHandler || defaultPreventedByHandler; +} + /** Container for menu items. Positioned relative to the trigger at root level; renders in-place as a submenu panel when nested. */ export const MenuContent = forwardRef(function MenuContent( { render, className, style, onKeyDown, onBlur, ...elementProps }, forwardedRef ) { - const { core, menu, state, stateAttrMap, anchorName, contentId, activeSubMenuId } = useMenuContext(); + const { core, menu, state, stateAttrMap, anchorName, contentId, boundary, container, activeSubMenuId } = + useMenuContext(); const subMenuCtx = useSubMenuContext(); const isSubmenu = state.isSubmenu; @@ -115,9 +160,12 @@ export const MenuContent = forwardRef(function const handleSubMenuKeyDown = useCallback( (event: React.KeyboardEvent) => { - (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); - + const defaultPreventedByUser = callKeyDownHandler( + onKeyDown as React.KeyboardEventHandler | undefined, + event + ); const keyboardEvent = toUIKeyboardEvent(event); + const isNavigationKey = isMenuNavigationKey(keyboardEvent); menu.contentProps.onKeyDown(keyboardEvent); const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape'; @@ -127,12 +175,12 @@ export const MenuContent = forwardRef(function parentMenu.menu.navigationInput.current.stack[parentMenu.menu.navigationInput.current.stack.length - 1] ?.menuId === subMenuId; - if (isBackNavigationKey && ownsActiveSubmenu && !event.defaultPrevented) { + if (isBackNavigationKey && ownsActiveSubmenu && !defaultPreventedByUser) { event.preventDefault(); parentMenu.pop(); } - if (isMenuNavigationKey(keyboardEvent) && (!isBackNavigationKey || ownsActiveSubmenu)) { + if (isNavigationKey && (!isBackNavigationKey || ownsActiveSubmenu)) { event.stopPropagation(); } }, @@ -142,7 +190,12 @@ export const MenuContent = forwardRef(function const handleRootMenuKeyDown = useCallback( (event: React.KeyboardEvent) => { (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); - menu.contentProps.onKeyDown(toUIKeyboardEvent(event)); + const keyboardEvent = toUIKeyboardEvent(event); + menu.contentProps.onKeyDown(keyboardEvent); + if (event.key === 'Escape') return; + if (isMenuNavigationKey(keyboardEvent)) { + event.stopPropagation(); + } }, [onKeyDown, menu] ); @@ -199,7 +252,6 @@ export const MenuContent = forwardRef(function useLayoutEffect(() => { if (isSubmenu) return; - if (supportsAnchorPositioning()) return; if (!positionOptions) return; if (!state.open) { setManualStyle(null); @@ -214,29 +266,39 @@ export const MenuContent = forwardRef(function if (!triggerElement || !contentElement) return; const triggerRect = triggerElement.getBoundingClientRect(); - const contentRect = getPopupPositionRect(contentElement); - const boundaryRect = document.documentElement.getBoundingClientRect(); + const root = contentElement.getRootNode() as Document | ShadowRoot; + const boundaryElement = resolvePositioningBoundary(boundary, { container, root }); + const contentRect = supportsAnchorPositioning() ? undefined : getPopupPositionRect(contentElement); + const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(contentElement); - setManualStyle( - getAnchorPositionStyle( - anchorName, - rootPositionOptions, - triggerRect, - contentRect, - boundaryRect, - offsets - ) as CSSProperties + const { positionAnchor: _, ...nextStyle } = getAnchorPositionStyle( + anchorName, + rootPositionOptions, + triggerRect, + contentRect, + boundaryRect, + offsets ); + + setManualStyle(nextStyle as CSSProperties); } measure(); const triggerElement = menu.triggerElement; const contentElement = internalRef.current; + const boundaryElement = contentElement + ? resolvePositioningBoundary(boundary, { + container, + root: contentElement.getRootNode() as Document | ShadowRoot, + }) + : null; let animationFrameId = 0; - function reposition(): void { + function reposition(event?: Event): void { + if (event && isEventWithinElement(event, internalRef.current)) return; + cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(measure); } @@ -247,6 +309,7 @@ export const MenuContent = forwardRef(function if (triggerElement && resizeObserver) resizeObserver.observe(triggerElement); if (contentElement && resizeObserver) resizeObserver.observe(contentElement); + if (boundaryElement && resizeObserver) resizeObserver.observe(boundaryElement); window.addEventListener('scroll', reposition, { capture: true, passive: true }); window.addEventListener('resize', reposition); @@ -257,7 +320,7 @@ export const MenuContent = forwardRef(function window.removeEventListener('scroll', reposition, true); window.removeEventListener('resize', reposition); }; - }, [isSubmenu, state.open, anchorName, positionOptions, menu]); + }, [isSubmenu, state.open, anchorName, positionOptions, menu, boundary, container]); // ─── Render ─────────────────────────────────────────────────────────────── @@ -277,6 +340,7 @@ export const MenuContent = forwardRef(function role: 'menu' as const, tabIndex: -1, 'data-submenu': '', + onKeyDownCapture: preventMenuKeyDefault, onKeyDown: handleSubMenuKeyDown, onBlur, }, @@ -294,7 +358,7 @@ export const MenuContent = forwardRef(function if (!state.open) return null; - const positioningStyle = anchorStyle ?? manualStyle ?? POPOVER_RESET; + const positioningStyle = manualStyle ?? anchorStyle ?? POPOVER_RESET; return renderElement( 'div', @@ -310,7 +374,7 @@ export const MenuContent = forwardRef(function ...core.getContentAttrs(state), ...getMenuViewportAttrs(), }, - { onKeyDown: handleRootMenuKeyDown, onBlur: handleRootMenuBlur }, + { onKeyDownCapture: preventMenuKeyDefault, onKeyDown: handleRootMenuKeyDown, onBlur: handleRootMenuBlur }, elementProps, ], } diff --git a/packages/react/src/ui/menu/menu-item.tsx b/packages/react/src/ui/menu/menu-item.tsx index 8d8747b3..f6b41b61 100644 --- a/packages/react/src/ui/menu/menu-item.tsx +++ b/packages/react/src/ui/menu/menu-item.tsx @@ -44,7 +44,7 @@ export const MenuItem = forwardRef(function MenuI const handlePointerEnter = useCallback(() => { const element = elementRef.current; if (!element || disabled) return; - menu.highlight(element); + menu.highlight(element, { focus: false }); }, [menu, disabled]); return renderElement( diff --git a/packages/react/src/ui/menu/menu-radio-item.tsx b/packages/react/src/ui/menu/menu-radio-item.tsx index e44b9b7e..7d459a7d 100644 --- a/packages/react/src/ui/menu/menu-radio-item.tsx +++ b/packages/react/src/ui/menu/menu-radio-item.tsx @@ -46,7 +46,7 @@ export const MenuRadioItem = forwardRef(func const handlePointerEnter = useCallback(() => { const element = elementRef.current; if (!element || disabled) return; - menu.highlight(element); + menu.highlight(element, { focus: false }); }, [menu, disabled]); return renderElement( diff --git a/packages/react/src/ui/menu/menu-root.tsx b/packages/react/src/ui/menu/menu-root.tsx index c5552df7..45ae240f 100644 --- a/packages/react/src/ui/menu/menu-root.tsx +++ b/packages/react/src/ui/menu/menu-root.tsx @@ -1,17 +1,20 @@ 'use client'; import { MenuCore, MenuDataAttrs } from '@videojs/core'; -import { createMenu, createTransition, type MenuChangeDetails } from '@videojs/core/dom'; +import { createMenu, createTransition, type MenuChangeDetails, type PositioningBoundary } from '@videojs/core/dom'; import { useSnapshot } from '@videojs/store/react'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; - +import { useOptionalContainer, useOptionalPopupGroup } from '../../player/context'; 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 { MenuContextProvider, SubMenuContextProvider, useOptionalMenuContext } from './context'; export interface MenuRootProps extends MenuCore.Props { + /** Boundary used to constrain the root menu popup size. */ + boundary?: PositioningBoundary; /** Called when the menu open state changes (fires immediately, before animations). */ onOpenChange?: (open: boolean, details: MenuChangeDetails) => void; /** Called after open/close animations complete. */ @@ -24,12 +27,16 @@ export function MenuRoot({ defaultOpen = MenuCore.defaultProps.defaultOpen, onOpenChange: onOpenChangeProp, onOpenChangeComplete: onOpenChangeCompleteProp, + boundary = 'container', children, ...coreProps }: MenuRootProps): ReactNode { // Detect if we are nested inside a parent Menu.Content — if so, operate as // a submenu: no popover positioning, Trigger acts as a parent item. const parentMenu = useOptionalMenuContext(); + const controls = useOptionalControlsContext(); + const container = useOptionalContainer(); + const popupGroup = useOptionalPopupGroup(); const isSubmenu = parentMenu !== null; const { side, align, closeOnEscape, closeOnOutsideClick } = coreProps; @@ -41,6 +48,8 @@ export function MenuRoot({ const onOpenChangeCompleteRef = useLatestRef(onOpenChangeCompleteProp); const closeOnEscapeRef = useLatestRef(closeOnEscape); const closeOnOutsideClickRef = useLatestRef(closeOnOutsideClick); + const popupGroupRef = useLatestRef(popupGroup); + const isSubmenuRef = useLatestRef(isSubmenu); const [menu] = useState(() => { const instance = createMenu({ @@ -53,6 +62,7 @@ export function MenuRoot({ }, closeOnEscape: () => closeOnEscapeRef.current ?? MenuCore.defaultProps.closeOnEscape, closeOnOutsideClick: () => closeOnOutsideClickRef.current ?? MenuCore.defaultProps.closeOnOutsideClick, + group: () => (isSubmenuRef.current ? undefined : popupGroupRef.current), }); if (!isControlled && defaultOpen) { @@ -79,6 +89,12 @@ export function MenuRoot({ } }, [controlledOpen, menu]); + useEffect(() => { + if (isSubmenu || controls?.state.visible !== false) return; + + menu.close('imperative-action'); + }, [controls?.state.visible, isSubmenu, menu]); + useDestroy(menu); const input = useSnapshot(menu.input); @@ -103,13 +119,26 @@ export function MenuRoot({ stateAttrMap: MenuDataAttrs, contentId, anchorName, + boundary, + container, activeSubMenuId, activeSubMenuTriggerId, navigationDirection, push: menu.push, pop: menu.pop, }), - [core, menu, state, contentId, anchorName, activeSubMenuId, activeSubMenuTriggerId, navigationDirection] + [ + core, + menu, + state, + contentId, + anchorName, + boundary, + container, + activeSubMenuId, + activeSubMenuTriggerId, + navigationDirection, + ] ); const subMenuContextValue = useMemo( diff --git a/packages/react/src/ui/menu/menu-trigger.tsx b/packages/react/src/ui/menu/menu-trigger.tsx index 904bc637..dce292cd 100644 --- a/packages/react/src/ui/menu/menu-trigger.tsx +++ b/packages/react/src/ui/menu/menu-trigger.tsx @@ -1,6 +1,7 @@ 'use client'; import type { MenuState } from '@videojs/core'; +import { isMenuNavigationKey, type UIKeyboardEvent } from '@videojs/core/dom'; import { supportsAnchorPositioning } from '@videojs/utils/dom'; import { forwardRef, useCallback, useEffect, useRef } from 'react'; @@ -14,6 +15,31 @@ export interface MenuTriggerProps extends UIComponentProps<'button', MenuState> disabled?: boolean; } +function toUIKeyboardEvent(event: React.KeyboardEvent): UIKeyboardEvent { + return { + get defaultPrevented() { + return event.defaultPrevented; + }, + key: event.key, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + metaKey: event.metaKey, + target: event.target instanceof Node ? event.target : event.currentTarget, + currentTarget: event.currentTarget, + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopPropagation(), + }; +} + +function preventMenuKeyDefault(event: React.KeyboardEvent): void { + const keyboardEvent = toUIKeyboardEvent(event); + + if (event.key !== 'Escape' && isMenuNavigationKey(keyboardEvent) && !event.defaultPrevented) { + event.preventDefault(); + } +} + /** * Button that toggles the menu visibility. At root level renders a `} /> + , + { wrapper: Wrapper } + ); + + expect(screen.getByText('Text').textContent).toBe('Text'); + }); + + it('renders radio items from the available playback rates', () => { + renderPlaybackRateMenu({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 }); + + expect(screen.getByRole('menuitemradio', { name: '1×' }).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByRole('menuitemradio', { name: '1.25×' }).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByRole('menuitemradio', { name: '1.5×' }).getAttribute('aria-checked')).toBe('false'); + }); + + it('center aligns the popup by default', () => { + renderPlaybackRateMenu(); + + expect(screen.getByTestId('content').getAttribute('data-align')).toBe('center'); + }); + + it('sets the selected playback rate', () => { + const setPlaybackRate = vi.fn(); + renderPlaybackRateMenu({ setPlaybackRate }); + + fireEvent.click(screen.getByRole('menuitemradio', { name: '2×' })); + + expect(setPlaybackRate).toHaveBeenCalledWith(2); + }); + + it('uses a custom rate formatter for the trigger and items', () => { + renderPlaybackRateMenu({ + playbackRate: 1, + formatRate: (rate) => (rate === 1 ? 'Normal' : `${rate}×`), + }); + + expect(screen.getByTestId('trigger').textContent).toBe('Normal'); + expect(screen.getByRole('menuitemradio', { name: 'Normal' }).getAttribute('aria-checked')).toBe('true'); + }); + + it('disables the trigger when there are no rates', () => { + renderPlaybackRateMenu({ playbackRates: [] }); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger.hasAttribute('disabled')).toBe(true); + expect(trigger.hasAttribute('data-disabled')).toBe(true); + }); +}); diff --git a/packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts b/packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts new file mode 100644 index 00000000..88302669 --- /dev/null +++ b/packages/react/src/ui/playback-rate-menu/use-playback-rate-menu.ts @@ -0,0 +1,50 @@ +'use client'; + +import type { PlaybackRateMenuCore } from '@videojs/core'; +import { useCallback } from 'react'; + +import { usePlaybackRateMenuContext } from './context'; + +export interface PlaybackRateMenuOption { + rate: number; + value: string; + label: string; + disabled: boolean; +} + +export interface PlaybackRateMenuResult { + state: PlaybackRateMenuCore.State; + rate: number; + value: string; + options: PlaybackRateMenuOption[]; + disabled: boolean; + setRate: (rate: number) => void; + setValue: (value: string) => void; +} + +export function usePlaybackRateMenu(): PlaybackRateMenuResult { + const { core, media, state } = usePlaybackRateMenuContext(); + + const setRate = useCallback((rate: number) => core.select(media, rate), [core, media]); + const setValue = useCallback((value: string) => core.selectValue(media, value), [core, media]); + + return { + state, + rate: state.rate, + value: core.getRateValue(state.rate), + options: state.rates.map((rate) => ({ + rate, + value: core.getRateValue(rate), + label: core.getRateLabel(rate), + disabled: state.disabled, + })), + disabled: state.disabled, + setRate, + setValue, + }; +} + +export namespace usePlaybackRateMenu { + export type Result = PlaybackRateMenuResult; + export type Option = PlaybackRateMenuOption; +} diff --git a/packages/react/src/ui/popover/context.tsx b/packages/react/src/ui/popover/context.tsx index a69c8ab3..9c4f49c5 100644 --- a/packages/react/src/ui/popover/context.tsx +++ b/packages/react/src/ui/popover/context.tsx @@ -1,7 +1,7 @@ 'use client'; import type { PopoverCore, StateAttrMap } from '@videojs/core'; -import type { PopoverApi } from '@videojs/core/dom'; +import type { MediaContainer, PopoverApi, PositioningBoundary } from '@videojs/core/dom'; import { createContext, useContext } from 'react'; export interface PopoverContextValue { @@ -11,6 +11,8 @@ export interface PopoverContextValue { stateAttrMap: StateAttrMap; anchorName: string; popupId: string; + boundary: PositioningBoundary; + container: MediaContainer | null; } const PopoverContext = createContext(null); diff --git a/packages/react/src/ui/popover/popover-popup.tsx b/packages/react/src/ui/popover/popover-popup.tsx index f02f7164..f0d64239 100644 --- a/packages/react/src/ui/popover/popover-popup.tsx +++ b/packages/react/src/ui/popover/popover-popup.tsx @@ -1,7 +1,14 @@ 'use client'; import type { PopoverState } from '@videojs/core'; -import { getAnchorPositionStyle, getPopupPositionRect, resolveOffsets } from '@videojs/core/dom'; +import { + getAnchorPositionStyle, + getPopupPositionRect, + getPositioningBoundaryRect, + isEventWithinElement, + resolveOffsets, + resolvePositioningBoundary, +} from '@videojs/core/dom'; import { supportsAnchorPositioning } from '@videojs/utils/dom'; import type { CSSProperties } from 'react'; import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -20,7 +27,7 @@ export const PopoverPopup = forwardRef(functi { render, className, style, ...elementProps }, forwardedRef ) { - const { core, popover, state, stateAttrMap, anchorName, popupId } = usePopoverContext(); + const { core, popover, state, stateAttrMap, anchorName, popupId, boundary, container } = usePopoverContext(); const internalRef = useRef(null); const popupRef = useCallback( @@ -52,7 +59,6 @@ export const PopoverPopup = forwardRef(functi const [manualStyle, setManualStyle] = useState(null); useLayoutEffect(() => { - if (supportsAnchorPositioning()) return; if (!state.open) { setManualStyle(null); return; @@ -64,21 +70,38 @@ export const PopoverPopup = forwardRef(functi if (!triggerEl || !popupEl) return; const triggerRect = triggerEl.getBoundingClientRect(); - const popupRect = getPopupPositionRect(popupEl); - const boundaryRect = document.documentElement.getBoundingClientRect(); + const root = popupEl.getRootNode() as Document | ShadowRoot; + const boundaryElement = resolvePositioningBoundary(boundary, { container, root }); + const popupRect = supportsAnchorPositioning() ? undefined : getPopupPositionRect(popupEl); + const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(popupEl); - setManualStyle( - getAnchorPositionStyle(anchorName, posOpts, triggerRect, popupRect, boundaryRect, offsets) as CSSProperties + const { positionAnchor: _, ...nextStyle } = getAnchorPositionStyle( + anchorName, + posOpts, + triggerRect, + popupRect, + boundaryRect, + offsets ); + + setManualStyle(nextStyle as CSSProperties); } measure(); const triggerEl = popover.triggerElement; const popupEl = internalRef.current; + const boundaryElement = popupEl + ? resolvePositioningBoundary(boundary, { + container, + root: popupEl.getRootNode() as Document | ShadowRoot, + }) + : null; let rafId = 0; - function reposition(): void { + function reposition(event?: Event): void { + if (event && isEventWithinElement(event, internalRef.current)) return; + cancelAnimationFrame(rafId); rafId = requestAnimationFrame(measure); } @@ -101,6 +124,9 @@ export const PopoverPopup = forwardRef(functi if (popupEl && resizeObserver) { resizeObserver.observe(popupEl); } + if (boundaryElement && resizeObserver) { + resizeObserver.observe(boundaryElement); + } window.addEventListener('scroll', reposition, { capture: true, passive: true }); window.addEventListener('resize', reposition); @@ -111,11 +137,11 @@ export const PopoverPopup = forwardRef(functi window.removeEventListener('scroll', reposition, true); window.removeEventListener('resize', reposition); }; - }, [state.open, anchorName, posOpts, popover]); + }, [state.open, anchorName, posOpts, popover, boundary, container]); // Anchor path uses computed styles; manual path uses measured styles; // fallback resets UA [popover] defaults until positioning is computed. - const positioningStyle = anchorStyle ?? manualStyle ?? POPOVER_RESET; + const positioningStyle = manualStyle ?? anchorStyle ?? POPOVER_RESET; // --- Visibility --- diff --git a/packages/react/src/ui/popover/popover-root.tsx b/packages/react/src/ui/popover/popover-root.tsx index a2fbaf95..aa3f1bca 100644 --- a/packages/react/src/ui/popover/popover-root.tsx +++ b/packages/react/src/ui/popover/popover-root.tsx @@ -1,17 +1,26 @@ 'use client'; import { type PopoverProps as CorePopoverProps, PopoverCore, PopoverDataAttrs } from '@videojs/core'; -import { createPopover, createTransition, type PopoverChangeDetails } from '@videojs/core/dom'; +import { + createPopover, + createTransition, + type PopoverChangeDetails, + type PositioningBoundary, +} from '@videojs/core/dom'; import { useSnapshot } from '@videojs/store/react'; +import { isUndefined } from '@videojs/utils/predicate'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; - +import { useOptionalContainer, useOptionalPopupGroup } from '../../player/context'; 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 { PopoverContextProvider } from './context'; export interface PopoverRootProps extends CorePopoverProps { + /** Boundary used to constrain the popup size. */ + boundary?: PositioningBoundary; /** Called when the popover open state changes (fires immediately, before animations). */ onOpenChange?: (open: boolean, details: PopoverChangeDetails) => void; /** Called after open/close animations complete. */ @@ -27,13 +36,17 @@ export function PopoverRoot({ openOnHover = PopoverCore.defaultProps.openOnHover, delay = PopoverCore.defaultProps.delay, closeDelay = PopoverCore.defaultProps.closeDelay, + boundary = 'container', children, ...coreProps }: PopoverRootProps): ReactNode { + const container = useOptionalContainer(); + const popupGroup = useOptionalPopupGroup(); + const controls = useOptionalControlsContext(); const [core] = useState(() => new PopoverCore(coreProps)); core.setProps(coreProps); - const isControlled = controlledOpen !== undefined; + const isControlled = !isUndefined(controlledOpen); // Keep refs that always point to the latest values so the // createPopover closure never reads stale props. @@ -44,6 +57,7 @@ export function PopoverRoot({ const openOnHoverRef = useLatestRef(openOnHover); const delayRef = useLatestRef(delay); const closeDelayRef = useLatestRef(closeDelay); + const popupGroupRef = useLatestRef(popupGroup); const [popover] = useState(() => { const instance = createPopover({ @@ -59,6 +73,7 @@ export function PopoverRoot({ openOnHover: () => openOnHoverRef.current, delay: () => delayRef.current, closeDelay: () => closeDelayRef.current, + group: () => popupGroupRef.current, }); // Apply defaultOpen on creation (uncontrolled only) @@ -74,7 +89,7 @@ export function PopoverRoot({ // Sync controlled open prop -> internal input state. useEffect(() => { - if (controlledOpen === undefined) return; + if (isUndefined(controlledOpen)) return; const { active: inputOpen } = popover.input.current; if (controlledOpen === inputOpen) return; @@ -86,6 +101,13 @@ export function PopoverRoot({ } }, [controlledOpen, popover]); + useEffect(() => { + if (isUndefined(controls?.state.visible)) return; + if (controls.state.visible) return; + + popover.close('imperative-action'); + }, [controls?.state.visible, popover]); + useDestroy(popover); const input = useSnapshot(popover.input); @@ -93,7 +115,9 @@ export function PopoverRoot({ const state = core.getState(); return ( - + {children} ); diff --git a/packages/react/src/ui/status-announcer/index.ts b/packages/react/src/ui/status-announcer/index.ts new file mode 100644 index 00000000..e3191c04 --- /dev/null +++ b/packages/react/src/ui/status-announcer/index.ts @@ -0,0 +1 @@ +export * from './status-announcer'; diff --git a/packages/react/src/ui/tooltip/context.tsx b/packages/react/src/ui/tooltip/context.tsx index 3c177fec..16c569ab 100644 --- a/packages/react/src/ui/tooltip/context.tsx +++ b/packages/react/src/ui/tooltip/context.tsx @@ -1,7 +1,7 @@ 'use client'; import type { StateAttrMap, TooltipCore } from '@videojs/core'; -import type { TooltipApi } from '@videojs/core/dom'; +import type { MediaContainer, PositioningBoundary, TooltipApi } from '@videojs/core/dom'; import { createContext, useContext } from 'react'; export interface TooltipContextValue { @@ -13,6 +13,8 @@ export interface TooltipContextValue { popupId: string; content: string | undefined; setContent: (content: string | undefined) => void; + boundary: PositioningBoundary; + container: MediaContainer | null; } const TooltipContext = createContext(null); diff --git a/packages/react/src/ui/tooltip/tooltip-popup.tsx b/packages/react/src/ui/tooltip/tooltip-popup.tsx index 2373ac20..4e32f34f 100644 --- a/packages/react/src/ui/tooltip/tooltip-popup.tsx +++ b/packages/react/src/ui/tooltip/tooltip-popup.tsx @@ -2,7 +2,14 @@ import type { TooltipState } from '@videojs/core'; import { TooltipCSSVars } from '@videojs/core'; -import { getAnchorPositionStyle, getPopupPositionRect, resolveOffsets } from '@videojs/core/dom'; +import { + getAnchorPositionStyle, + getPopupPositionRect, + getPositioningBoundaryRect, + isEventWithinElement, + resolveOffsets, + resolvePositioningBoundary, +} from '@videojs/core/dom'; import { supportsAnchorPositioning } from '@videojs/utils/dom'; import type { CSSProperties } from 'react'; import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -21,7 +28,7 @@ export const TooltipPopup = forwardRef(functi { render, className, style, ...elementProps }, forwardedRef ) { - const { core, tooltip, state, stateAttrMap, anchorName, popupId, content } = useTooltipContext(); + const { core, tooltip, state, stateAttrMap, anchorName, popupId, content, boundary, container } = useTooltipContext(); const internalRef = useRef(null); const popupRef = useCallback( @@ -61,7 +68,6 @@ export const TooltipPopup = forwardRef(functi const [manualStyle, setManualStyle] = useState(null); useLayoutEffect(() => { - if (supportsAnchorPositioning()) return; if (!state.open) { setManualStyle(null); return; @@ -73,29 +79,39 @@ export const TooltipPopup = forwardRef(functi if (!triggerEl || !popupEl) return; const triggerRect = triggerEl.getBoundingClientRect(); - const popupRect = getPopupPositionRect(popupEl); - const boundaryRect = document.documentElement.getBoundingClientRect(); + const root = popupEl.getRootNode() as Document | ShadowRoot; + const boundaryElement = resolvePositioningBoundary(boundary, { container, root }); + const popupRect = supportsAnchorPositioning() ? undefined : getPopupPositionRect(popupEl); + const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(popupEl, TooltipCSSVars); - setManualStyle( - getAnchorPositionStyle( - anchorName, - posOpts, - triggerRect, - popupRect, - boundaryRect, - offsets, - TooltipCSSVars - ) as CSSProperties + const { positionAnchor: _, ...nextStyle } = getAnchorPositionStyle( + anchorName, + posOpts, + triggerRect, + popupRect, + boundaryRect, + offsets, + TooltipCSSVars ); + + setManualStyle(nextStyle as CSSProperties); } measure(); const triggerEl = tooltip.triggerElement; const popupEl = internalRef.current; + const boundaryElement = popupEl + ? resolvePositioningBoundary(boundary, { + container, + root: popupEl.getRootNode() as Document | ShadowRoot, + }) + : null; let rafId = 0; - function reposition(): void { + function reposition(event?: Event): void { + if (event && isEventWithinElement(event, internalRef.current)) return; + cancelAnimationFrame(rafId); rafId = requestAnimationFrame(measure); } @@ -118,6 +134,9 @@ export const TooltipPopup = forwardRef(functi if (popupEl && resizeObserver) { resizeObserver.observe(popupEl); } + if (boundaryElement && resizeObserver) { + resizeObserver.observe(boundaryElement); + } window.addEventListener('scroll', reposition, { capture: true, passive: true }); window.addEventListener('resize', reposition); @@ -128,11 +147,11 @@ export const TooltipPopup = forwardRef(functi window.removeEventListener('scroll', reposition, true); window.removeEventListener('resize', reposition); }; - }, [state.open, anchorName, posOpts, tooltip]); + }, [state.open, anchorName, posOpts, tooltip, boundary, container]); // Anchor path uses computed styles; manual path uses measured styles; // fallback resets UA [popover] defaults until positioning is computed. - const positioningStyle = anchorStyle ?? manualStyle ?? POPUP_RESET; + const positioningStyle = manualStyle ?? anchorStyle ?? POPUP_RESET; // --- Visibility --- diff --git a/packages/react/src/ui/tooltip/tooltip-root.tsx b/packages/react/src/ui/tooltip/tooltip-root.tsx index c0255aa7..a51b89dc 100644 --- a/packages/react/src/ui/tooltip/tooltip-root.tsx +++ b/packages/react/src/ui/tooltip/tooltip-root.tsx @@ -1,18 +1,27 @@ 'use client'; import { type TooltipProps as CoreTooltipProps, TooltipCore, TooltipDataAttrs } from '@videojs/core'; -import { createTooltip, createTransition, type TooltipChangeDetails } from '@videojs/core/dom'; +import { + createTooltip, + createTransition, + type PositioningBoundary, + type TooltipChangeDetails, +} from '@videojs/core/dom'; import { useSnapshot } from '@videojs/store/react'; +import { isUndefined } from '@videojs/utils/predicate'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; - +import { useOptionalContainer } from '../../player/context'; 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 { useTooltipGroup } from './group-context'; export interface TooltipRootProps extends CoreTooltipProps { + /** Boundary used to constrain the popup size. */ + boundary?: PositioningBoundary; /** Called when the tooltip open state changes (fires immediately, before animations). */ onOpenChange?: (open: boolean, details: TooltipChangeDetails) => void; /** Called after open/close animations complete. */ @@ -29,13 +38,16 @@ export function TooltipRoot({ closeDelay = TooltipCore.defaultProps.closeDelay, disableHoverablePopup = TooltipCore.defaultProps.disableHoverablePopup, disabled = TooltipCore.defaultProps.disabled, + boundary = 'container', children, ...coreProps }: TooltipRootProps): ReactNode { + const container = useOptionalContainer(); + const controls = useOptionalControlsContext(); const [core] = useState(() => new TooltipCore(coreProps)); core.setProps(coreProps); - const isControlled = controlledOpen !== undefined; + const isControlled = !isUndefined(controlledOpen); const groupFromContext = useTooltipGroup(); @@ -80,7 +92,7 @@ export function TooltipRoot({ // Sync controlled open prop -> internal input state. useEffect(() => { - if (controlledOpen === undefined) return; + if (isUndefined(controlledOpen)) return; const { active: inputOpen } = tooltip.input.current; if (controlledOpen === inputOpen) return; @@ -92,6 +104,13 @@ export function TooltipRoot({ } }, [controlledOpen, tooltip]); + useEffect(() => { + if (isUndefined(controls?.state.visible)) return; + if (controls.state.visible) return; + + tooltip.close('imperative-action'); + }, [controls?.state.visible, tooltip]); + useDestroy(tooltip); const input = useSnapshot(tooltip.input); @@ -100,7 +119,18 @@ export function TooltipRoot({ return ( {children} diff --git a/packages/skins/src/default/css/audio.css b/packages/skins/src/default/css/audio.css index 962601ae..ed854321 100644 --- a/packages/skins/src/default/css/audio.css +++ b/packages/skins/src/default/css/audio.css @@ -8,6 +8,7 @@ @import "./components/button.css"; @import "./components/button-group.css"; @import "./components/icons.css"; +@import "./components/menus.css"; @import "./components/slider.css"; @import "./components/popup.css"; @import "../../shared/css/audio/icon-state.css"; @@ -28,7 +29,9 @@ --media-popup-transition-duration: 100ms; --media-popup-transition-timing-function: ease-out; --media-tooltip-side-offset: 0.75rem; + --media-tooltip-boundary-offset: 0.75rem; --media-popover-side-offset: 0.75rem; + --media-popover-boundary-offset: 0.75rem; @media (prefers-reduced-motion: reduce) { --media-error-dialog-transition-duration: 50ms; diff --git a/packages/skins/src/default/css/components/button.css b/packages/skins/src/default/css/components/button.css index 3652eff9..57852c5d 100644 --- a/packages/skins/src/default/css/components/button.css +++ b/packages/skins/src/default/css/components/button.css @@ -108,12 +108,16 @@ /* Playback rate button */ .media-default-skin .media-button--playback-rate { padding: 0; + font-variant-numeric: tabular-nums; &::after { width: 4ch; - font-variant-numeric: tabular-nums; content: attr(data-rate) "\00D7"; } + + &[data-inline-rate-label]::after { + content: none; + } } /* Live button — wide pill button with a status dot (gray → red at the live diff --git a/packages/skins/src/default/css/components/menus.css b/packages/skins/src/default/css/components/menus.css new file mode 100644 index 00000000..0bee0382 --- /dev/null +++ b/packages/skins/src/default/css/components/menus.css @@ -0,0 +1,95 @@ +/* ========================================================================== + Menus + Note: Menus use `.media-popover` styles for positioning and transitions. + ========================================================================== */ + +.media-default-skin .media-popover.media-menu { + box-sizing: border-box; + min-width: min(6rem, var(--media-popover-available-width, 6rem)); + max-width: var(--media-popover-available-width, none); + max-height: var(--media-popover-available-height, none); + padding: 0.375rem; + overflow: auto; + overscroll-behavior: none; + border-radius: 1.25rem; + + &::before { + display: none; + } +} + +.media-default-skin .media-popover.media-menu .media-menu__group { + position: relative; + display: flex; + flex-direction: column; + gap: 0.125rem; + + &::before { + position: absolute; + position-anchor: --media-menu-item-highlight-anchor; + inset: anchor(inside); + pointer-events: none; + content: ""; + background-color: oklch(from currentColor l c h / 0.1); + border-radius: calc(infinity * 1px); + transition: inset ease-in-out 100ms; + } + + @supports not (top: anchor(top)) { + &::before { + display: none; + } + } +} + +.media-default-skin .media-popover.media-menu .media-menu__item { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + min-height: 2rem; + padding: 0 0.75rem; + font-variant-numeric: tabular-nums; + color: inherit; + cursor: pointer; + outline: 2px solid transparent; + outline-offset: -2px; + border-radius: calc(infinity * 1px); + + &:hover, + &[data-highlighted] { + anchor-name: --media-menu-item-highlight-anchor; + } + + @supports not (top: anchor(top)) { + &:hover, + &[data-highlighted] { + background-color: oklch(from currentColor l c h / 0.1); + } + } + + &:focus-visible { + outline-color: currentColor; + outline-offset: 2px; + } + + &[aria-checked="true"] .media-menu__indicator { + opacity: 1; + } + + &[aria-disabled="true"] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } +} + +.media-default-skin .media-popover.media-menu .media-menu__indicator { + flex-shrink: 0; + margin-right: -0.25rem; + opacity: 0; +} + +.media-default-skin .media-popover.media-menu .media-menu__indicator .media-icon { + filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); +} diff --git a/packages/skins/src/default/css/components/popup.css b/packages/skins/src/default/css/components/popup.css index 15e5c3ec..6a211816 100644 --- a/packages/skins/src/default/css/components/popup.css +++ b/packages/skins/src/default/css/components/popup.css @@ -8,6 +8,7 @@ overflow: visible; color: inherit; border: 0; + filter: blur(0px); transition-timing-function: var(--media-popup-transition-timing-function); transition-duration: var(--media-popup-transition-duration); transition-property: scale, opacity, filter; @@ -16,7 +17,7 @@ &[data-ending-style] { opacity: 0; filter: blur(8px); - scale: 0.5; + scale: 0.85; } &[data-instant] { diff --git a/packages/skins/src/default/css/components/root.css b/packages/skins/src/default/css/components/root.css index 60784d21..bc4166cd 100644 --- a/packages/skins/src/default/css/components/root.css +++ b/packages/skins/src/default/css/components/root.css @@ -17,6 +17,7 @@ ui-sans-serif, system-ui, sans-serif; + font-size: 0.8125rem; /* 13px at 100% font size */ -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; line-height: 1.5; @@ -33,12 +34,4 @@ outline-color: currentColor; outline-offset: 2px; } - - & > * { - font-size: 0.75rem; /* 12px at 100% font size */ - - @container media-root (width > 48rem) { - font-size: 0.875rem; /* 14px at 100% font size */ - } - } } diff --git a/packages/skins/src/default/css/video.css b/packages/skins/src/default/css/video.css index bc259810..79222611 100644 --- a/packages/skins/src/default/css/video.css +++ b/packages/skins/src/default/css/video.css @@ -10,6 +10,7 @@ @import "./components/button.css"; @import "./components/button-group.css"; @import "./components/icons.css"; +@import "./components/menus.css"; @import "./components/poster.css"; @import "./components/preview.css"; @import "./components/slider.css"; @@ -51,7 +52,9 @@ --media-popup-transition-duration: 100ms; --media-popup-transition-timing-function: ease-out; --media-tooltip-side-offset: 0.75rem; + --media-tooltip-boundary-offset: 0.5rem; --media-popover-side-offset: 0.5rem; + --media-popover-boundary-offset: 0.5rem; background: oklch(0 0 0); @media (prefers-reduced-motion: reduce) { @@ -159,6 +162,8 @@ bottom: 0.5rem; z-index: 10; flex-wrap: wrap; + max-width: 56rem; + margin-inline: auto; color: var(--media-color-primary, oklch(1 0 0)); transform-origin: bottom; transition-timing-function: var(--media-controls-transition-timing-function); @@ -177,7 +182,7 @@ &:not([data-visible]) { pointer-events: none; opacity: 0; - scale: 0.9; + scale: 0.95; @media (pointer: fine) and (prefers-reduced-motion: no-preference) { filter: blur(8px); diff --git a/packages/skins/src/default/tailwind/audio.tailwind.ts b/packages/skins/src/default/tailwind/audio.tailwind.ts index 1682be7c..b9c4007b 100644 --- a/packages/skins/src/default/tailwind/audio.tailwind.ts +++ b/packages/skins/src/default/tailwind/audio.tailwind.ts @@ -24,7 +24,9 @@ export const root = cn( '[--media-popup-transition-duration:100ms]', '[--media-popup-transition-timing-function:ease-out]', '[--media-tooltip-side-offset:0.75rem]', + '[--media-tooltip-boundary-offset:0.75rem]', '[--media-popover-side-offset:0.75rem]', + '[--media-popover-boundary-offset:0.75rem]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]', 'motion-reduce:[--media-error-dialog-transition-delay:0ms]', 'motion-reduce:[--media-popup-transition-duration:0ms]', @@ -104,6 +106,7 @@ export { iconState } from '../../shared/tailwind/icon-state'; export { button } from './components/button'; export { buttonGroup } from './components/button-group'; export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon'; +export { menu } from './components/menu'; export { playbackRate } from './components/playback-rate'; export { seek } from './components/seek'; export { time } from './components/time'; diff --git a/packages/skins/src/default/tailwind/components/menu.ts b/packages/skins/src/default/tailwind/components/menu.ts new file mode 100644 index 00000000..5fc61d63 --- /dev/null +++ b/packages/skins/src/default/tailwind/components/menu.ts @@ -0,0 +1,31 @@ +import { cn } from '@videojs/utils/style'; + +export const menu = { + root: cn( + 'box-border min-w-[min(6rem,var(--media-popover-available-width,6rem))]', + 'max-w-(--media-popover-available-width) max-h-(--media-popover-available-height)', + 'p-1.5 !overflow-auto overscroll-none rounded-[1.25rem]', + 'before:hidden' + ), + group: cn( + 'relative flex flex-col gap-0.5', + 'before:hidden supports-[top:anchor(top)]:before:block', + 'before:absolute before:pointer-events-none before:rounded-full before:bg-current/10', + 'before:transition-[inset] before:duration-100 before:ease-in-out', + 'before:[position-anchor:--media-menu-item-highlight-anchor] before:[inset:anchor(inside)]' + ), + item: cn( + 'group/menu-item flex min-h-8 cursor-pointer items-center justify-between gap-2 rounded-full px-3', + 'tabular-nums text-inherit outline-2 -outline-offset-2 outline-transparent', + 'hover:bg-current/10 data-highlighted:bg-current/10', + 'supports-[top:anchor(top)]:hover:bg-transparent supports-[top:anchor(top)]:data-highlighted:bg-transparent', + 'supports-[top:anchor(top)]:hover:[anchor-name:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]', + 'focus-visible:outline-current focus-visible:outline-offset-2', + 'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50' + ), + indicator: cn( + '-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', + '[&_.media-icon]:drop-shadow-[0_1px_0_var(--media-current-shadow-color)]' + ), +}; diff --git a/packages/skins/src/default/tailwind/components/playback-rate.ts b/packages/skins/src/default/tailwind/components/playback-rate.ts index 5040a887..d576e897 100644 --- a/packages/skins/src/default/tailwind/components/playback-rate.ts +++ b/packages/skins/src/default/tailwind/components/playback-rate.ts @@ -1,3 +1,3 @@ export const playbackRate = { - button: `after:content-[attr(data-rate)_'×'] after:w-[4ch] after:tabular-nums`, + button: `tabular-nums after:w-[4ch] after:content-[attr(data-rate)_'×'] data-[inline-rate-label]:after:content-none`, }; diff --git a/packages/skins/src/default/tailwind/components/root.ts b/packages/skins/src/default/tailwind/components/root.ts index 2245029f..d673b2d2 100644 --- a/packages/skins/src/default/tailwind/components/root.ts +++ b/packages/skins/src/default/tailwind/components/root.ts @@ -7,8 +7,7 @@ export const root = cn( 'block relative isolate h-full w-full @container/media-root', // Appearance 'rounded-(--media-border-radius,2rem)', - 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] leading-normal subpixel-antialiased', - '*:text-xs @3xl/media-root:*:text-sm', + 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased', // Focus ring 'outline-2 outline-transparent -outline-offset-4', 'transition-[outline-offset,outline-color] duration-100 ease-out', diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts index 8128ac0c..b103e41b 100644 --- a/packages/skins/src/default/tailwind/video.tailwind.ts +++ b/packages/skins/src/default/tailwind/video.tailwind.ts @@ -47,7 +47,9 @@ export const root = (isShadowDOM: boolean) => 'motion-reduce:[--media-error-dialog-transition-delay:0ms]', 'motion-reduce:[--media-error-dialog-transition-timing-function:ease-out]', '[--media-tooltip-side-offset:0.75rem]', + '[--media-tooltip-boundary-offset:0.5rem]', '[--media-popover-side-offset:0.5rem]', + '[--media-popover-boundary-offset:0.5rem]', 'motion-reduce:[--media-popup-transition-duration:0ms]', '[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]', 'contrast-more:[--media-surface-background-color:oklch(0_0_0)]', @@ -94,6 +96,7 @@ export const controls = cn( surface, // Position & wrapping layout (small) 'absolute bottom-2 inset-x-2 flex-wrap', + 'mx-auto max-w-4xl', '[color:var(--media-color-primary,oklch(1_0_0))] z-10', 'peer-data-open/error:hidden', 'ease-(--media-controls-transition-timing-function) origin-bottom', @@ -209,6 +212,7 @@ export { iconState } from '../../shared/tailwind/icon-state'; export { button } from './components/button'; export { buttonGroup } from './components/button-group'; export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon'; +export { menu } from './components/menu'; export { overlay } from './components/overlay'; export { playbackRate } from './components/playback-rate'; export { poster } from './components/poster'; diff --git a/packages/skins/src/minimal/css/audio.css b/packages/skins/src/minimal/css/audio.css index 6ba07682..9c9379d2 100644 --- a/packages/skins/src/minimal/css/audio.css +++ b/packages/skins/src/minimal/css/audio.css @@ -9,6 +9,7 @@ @import "./components/icons.css"; @import "./components/slider.css"; @import "./components/popup.css"; +@import "./components/menus.css"; @import "../../shared/css/audio/icon-state.css"; /* ========================================================================== @@ -29,7 +30,9 @@ --media-tooltip-backdrop-filter: blur(16px) saturate(1.5); --media-tooltip-text-color: currentColor; --media-tooltip-side-offset: 0.75rem; + --media-tooltip-boundary-offset: 0.75rem; --media-popover-side-offset: 0.75rem; + --media-popover-boundary-offset: 0.75rem; @media (prefers-reduced-motion: reduce) { --media-error-dialog-transition-duration: 50ms; diff --git a/packages/skins/src/minimal/css/components/button.css b/packages/skins/src/minimal/css/components/button.css index 91b3b8de..e015370d 100644 --- a/packages/skins/src/minimal/css/components/button.css +++ b/packages/skins/src/minimal/css/components/button.css @@ -114,12 +114,16 @@ /* Playback rate button */ .media-minimal-skin .media-button--playback-rate { padding: 0; + font-variant-numeric: tabular-nums; &::after { width: 4ch; - font-variant-numeric: tabular-nums; content: attr(data-rate) "\00D7"; } + + &[data-inline-rate-label]::after { + content: none; + } } /* Live button — wide pill button with a status dot (gray → red at the live diff --git a/packages/skins/src/minimal/css/components/menus.css b/packages/skins/src/minimal/css/components/menus.css new file mode 100644 index 00000000..5fb9b939 --- /dev/null +++ b/packages/skins/src/minimal/css/components/menus.css @@ -0,0 +1,72 @@ +/* ========================================================================== + Menus + Note: Menus use `.media-popover` styles for positioning and transitions. + ========================================================================== */ + +.media-minimal-skin .media-popover.media-menu { + box-sizing: border-box; + min-width: min(6rem, var(--media-popover-available-width, 6rem)); + max-width: var(--media-popover-available-width, none); + max-height: var(--media-popover-available-height, none); + padding: 0.25rem; + overflow: auto; + overscroll-behavior: none; + background-color: var(--media-tooltip-background-color); + border-radius: 0.75rem; + box-shadow: + 0 0 0 1px var(--media-tooltip-border-color), + 0 4px 6px -1px oklch(0 0 0 / 0.1), + 0 2px 4px -2px oklch(0 0 0 / 0.1); + backdrop-filter: var(--media-tooltip-backdrop-filter); + + &::before { + display: none; + } +} + +.media-minimal-skin .media-popover.media-menu .media-menu__group { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.media-minimal-skin .media-popover.media-menu .media-menu__item { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + min-height: 2rem; + padding: 0 0.625rem; + font-variant-numeric: tabular-nums; + color: inherit; + cursor: pointer; + outline: 2px solid transparent; + outline-offset: -2px; + border-radius: 0.5rem; + + &:hover, + &[data-highlighted] { + background-color: oklch(from currentColor l c h / 0.1); + } + + &:focus-visible { + outline-color: currentColor; + outline-offset: 2px; + } + + &[aria-disabled="true"] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } +} + +.media-minimal-skin .media-popover.media-menu .media-menu__indicator { + flex-shrink: 0; + margin-right: -0.25rem; + opacity: 0; +} + +.media-minimal-skin .media-popover.media-menu .media-menu__item[aria-checked="true"] .media-menu__indicator { + opacity: 1; +} diff --git a/packages/skins/src/minimal/css/components/root.css b/packages/skins/src/minimal/css/components/root.css index ddcfd334..63a07044 100644 --- a/packages/skins/src/minimal/css/components/root.css +++ b/packages/skins/src/minimal/css/components/root.css @@ -17,6 +17,7 @@ ui-sans-serif, system-ui, sans-serif; + font-size: 0.8125rem; /* 13px at 100% font size */ -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; line-height: 1.5; @@ -33,12 +34,4 @@ outline-color: currentColor; outline-offset: 2px; } - - & > * { - font-size: 0.75rem; /* 12px at 100% font size */ - - @container media-root (width > 48rem) { - font-size: 0.875rem; /* 14px at 100% font size */ - } - } } diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css index d9445841..99b963bc 100644 --- a/packages/skins/src/minimal/css/video.css +++ b/packages/skins/src/minimal/css/video.css @@ -13,6 +13,7 @@ @import "./components/preview.css"; @import "./components/slider.css"; @import "./components/popup.css"; +@import "./components/menus.css"; @import "./components/captions.css"; @import "./components/input-feedback.css"; @import "../../shared/css/video/icon-state.css"; @@ -39,7 +40,9 @@ --media-tooltip-backdrop-filter: blur(16px) saturate(1.5); --media-tooltip-text-color: currentColor; --media-tooltip-side-offset: 0.5rem; + --media-tooltip-boundary-offset: 0.5rem; --media-popover-side-offset: 1.5rem; + --media-popover-boundary-offset: 0.5rem; overflow: clip; background: oklch(0 0 0); @@ -60,7 +63,7 @@ @container media-root (width > 42rem) { & > * { - --media-popover-side-offset: 0rem; + --media-popover-side-offset: 0.5rem; } } @@ -159,7 +162,9 @@ z-index: 10; flex-wrap: wrap; column-gap: 0.5rem; + max-width: 56rem; padding: 0.25rem; + margin-inline: auto; color: oklch(1 0 0); border-radius: 0.75rem; transition-timing-function: var(--media-controls-transition-timing-function); @@ -246,6 +251,12 @@ @media (prefers-reduced-transparency: reduce) or (prefers-contrast: more) { background: var(--media-controls-background-color); } + + @container media-root (width > 42rem) { + & > * { + --media-popover-side-offset: 0rem; + } + } } /* ========================================================================== diff --git a/packages/skins/src/minimal/tailwind/audio.tailwind.ts b/packages/skins/src/minimal/tailwind/audio.tailwind.ts index 65a73797..75c9dbdf 100644 --- a/packages/skins/src/minimal/tailwind/audio.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/audio.tailwind.ts @@ -23,7 +23,9 @@ export const root = cn( '[--media-tooltip-backdrop-filter:blur(16px)_saturate(1.5)]', '[--media-tooltip-text-color:currentColor]', '[--media-tooltip-side-offset:0.75rem]', + '[--media-tooltip-boundary-offset:0.75rem]', '[--media-popover-side-offset:0.75rem]', + '[--media-popover-boundary-offset:0.75rem]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]', 'motion-reduce:[--media-error-dialog-transition-delay:0ms]', 'motion-reduce:[--media-popup-transition-duration:0ms]', @@ -93,6 +95,8 @@ export { bufferingIndicator } from './components/buffering'; export { button } from './components/button'; export { buttonGroup } from './components/button-group'; export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon'; +export { menu } from './components/menu'; +export { playbackRate } from './components/playback-rate'; export { seek } from './components/seek'; export { slider } from './components/slider'; export { time } from './components/time'; diff --git a/packages/skins/src/minimal/tailwind/components/menu.ts b/packages/skins/src/minimal/tailwind/components/menu.ts new file mode 100644 index 00000000..f4fe0cd6 --- /dev/null +++ b/packages/skins/src/minimal/tailwind/components/menu.ts @@ -0,0 +1,21 @@ +import { cn } from '@videojs/utils/style'; + +export const menu = { + root: cn( + 'box-border min-w-[min(6rem,var(--media-popover-available-width,6rem))]', + 'max-w-(--media-popover-available-width) max-h-(--media-popover-available-height)', + 'bg-(--media-tooltip-background-color) p-1 !overflow-auto overscroll-none rounded-xl', + '[backdrop-filter:var(--media-tooltip-backdrop-filter)]', + 'ring-1 ring-(color:--media-tooltip-border-color) shadow-md shadow-black/10', + 'before:hidden' + ), + group: 'flex flex-col gap-0.5', + item: cn( + 'group/menu-item flex min-h-8 cursor-pointer items-center justify-between gap-2 rounded-lg px-2.5', + 'tabular-nums text-inherit outline-2 -outline-offset-2 outline-transparent', + 'hover:bg-current/10 data-highlighted:bg-current/10', + 'focus-visible:outline-current focus-visible:outline-offset-2', + 'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50' + ), + indicator: '-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', +}; diff --git a/packages/skins/src/minimal/tailwind/components/playback-rate.ts b/packages/skins/src/minimal/tailwind/components/playback-rate.ts index 5040a887..d576e897 100644 --- a/packages/skins/src/minimal/tailwind/components/playback-rate.ts +++ b/packages/skins/src/minimal/tailwind/components/playback-rate.ts @@ -1,3 +1,3 @@ export const playbackRate = { - button: `after:content-[attr(data-rate)_'×'] after:w-[4ch] after:tabular-nums`, + button: `tabular-nums after:w-[4ch] after:content-[attr(data-rate)_'×'] data-[inline-rate-label]:after:content-none`, }; diff --git a/packages/skins/src/minimal/tailwind/components/root.ts b/packages/skins/src/minimal/tailwind/components/root.ts index 5423a8e1..3202f9c6 100644 --- a/packages/skins/src/minimal/tailwind/components/root.ts +++ b/packages/skins/src/minimal/tailwind/components/root.ts @@ -7,8 +7,7 @@ export const root = cn( 'block relative isolate h-full w-full @container/media-root', // Appearance 'rounded-(--media-border-radius,0.75rem)', - 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] leading-normal subpixel-antialiased', - '*:text-xs @3xl/media-root:*:text-sm', + 'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased', // Focus ring 'outline-2 outline-transparent -outline-offset-4', 'transition-[outline-offset,outline-color] duration-100 ease-out', diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index 054d38e1..1d8aeb90 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -40,7 +40,9 @@ export const root = (isShadowDOM: boolean) => '[--media-tooltip-backdrop-filter:blur(16px)_saturate(1.5)]', '[--media-tooltip-text-color:currentColor]', '[--media-tooltip-side-offset:0.5rem]', + '[--media-tooltip-boundary-offset:0.5rem]', '[--media-popover-side-offset:1.5rem]', + '[--media-popover-boundary-offset:0.5rem]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]', 'motion-reduce:[--media-error-dialog-transition-delay:0ms]', 'motion-reduce:[--media-popup-transition-duration:0ms]', @@ -48,7 +50,7 @@ export const root = (isShadowDOM: boolean) => 'contrast-more:[--media-controls-background-color:oklch(0_0_0)]', '[@media(prefers-reduced-transparency:reduce)]:[--media-tooltip-background-color:oklch(0_0_0)]', 'contrast-more:[--media-tooltip-background-color:oklch(0_0_0)]', - '@2xl/media-root:*:[--media-popover-side-offset:0rem]', + '@2xl/media-root:*:[--media-popover-side-offset:0.5rem]', 'pointer-fine:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]', 'pointer-coarse:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]', 'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:50ms]', @@ -88,6 +90,7 @@ export const controls = cn( // Position & wrapping layout (small) 'absolute bottom-1 inset-x-1', 'p-1 gap-x-2 flex-wrap rounded-xl', + 'mx-auto max-w-4xl', 'text-white z-10', 'peer-data-open/error:hidden', 'ease-(--media-controls-transition-timing-function)', @@ -101,7 +104,8 @@ export const controls = cn( 'motion-safe:not-data-visible:translate-y-full', 'pointer-fine:motion-safe:not-data-visible:blur-sm', // Single-row layout (large) - '@2xl/media-root:flex-nowrap @2xl/media-root:bottom-2 @2xl/media-root:inset-x-2' + '@2xl/media-root:flex-nowrap @2xl/media-root:bottom-2 @2xl/media-root:inset-x-2', + '@2xl/media-root:*:[--media-popover-side-offset:0rem]' ); /* ========================================================================== @@ -193,6 +197,7 @@ export { button } from './components/button'; export { buttonGroup } from './components/button-group'; export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon'; export { inputFeedback } from './components/input-feedback'; +export { menu } from './components/menu'; export { overlay } from './components/overlay'; export { playbackRate } from './components/playback-rate'; export { poster } from './components/poster'; diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index fe809c9b..cf73a1ae 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -6,7 +6,7 @@ import type { AttachContext, Slice, StateContext } from './slice'; import type { StateChange, State as StateContainer, SubscribeOptions, UnknownState, WritableState } from './state'; import { createState } from './state'; -const STORE_SYMBOL = Symbol('@videojs/store'); +const STORE_SYMBOL = Symbol.for('@videojs/store'); export interface StoreOptions extends StoreCallbacks {} diff --git a/packages/store/src/core/tests/store.test.ts b/packages/store/src/core/tests/store.test.ts index adfbfbbd..92bfe63c 100644 --- a/packages/store/src/core/tests/store.test.ts +++ b/packages/store/src/core/tests/store.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { combine } from '../combine'; import { defineSlice } from '../slice'; import { flush } from '../state'; -import { createStore } from '../store'; +import { createStore, isStore } from '../store'; describe('store', () => { // Mock target that mimics HTMLVideoElement @@ -97,6 +97,13 @@ describe('store', () => { signal: expect.any(AbortSignal), }); }); + + it('identifies stores across package entrypoints', () => { + const store = createStore()(audioSlice); + + expect(isStore(store)).toBe(true); + expect(Symbol.for('@videojs/store') in store).toBe(true); + }); }); describe('attach', () => { diff --git a/packages/utils/src/dom/interactive.ts b/packages/utils/src/dom/interactive.ts index 7d0da34a..db6c4250 100644 --- a/packages/utils/src/dom/interactive.ts +++ b/packages/utils/src/dom/interactive.ts @@ -1,7 +1,19 @@ import { resolveEventTarget } from './event'; -export const INTERACTIVE_SELECTOR = - 'button,input,select,textarea,a[href],[role="slider"],[role="button"],[data-interactive]'; +export const INTERACTIVE_SELECTOR = [ + 'button', + 'input', + 'select', + 'textarea', + 'a[href]', + '[role="button"]', + '[role="menu"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="slider"]', + '[data-interactive]', +].join(','); const EDITABLE_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number']; diff --git a/packages/utils/src/dom/tests/interactive.test.ts b/packages/utils/src/dom/tests/interactive.test.ts index 942480e3..45c5686b 100644 --- a/packages/utils/src/dom/tests/interactive.test.ts +++ b/packages/utils/src/dom/tests/interactive.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isEditableTarget, isInteractiveActivation } from '../interactive'; +import { isEditableTarget, isInteractiveActivation, isInteractiveTarget } from '../interactive'; function keydown(target: EventTarget, options?: KeyboardEventInit): KeyboardEvent { const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, ...options }); @@ -8,6 +8,12 @@ function keydown(target: EventTarget, options?: KeyboardEventInit): KeyboardEven return event; } +function pointerup(target: EventTarget): Event { + const event = new Event('pointerup', { bubbles: true }); + target.dispatchEvent(event); + return event; +} + describe('isEditableTarget', () => { it('returns true for text input', () => { const input = document.createElement('input'); @@ -168,3 +174,50 @@ describe('isInteractiveActivation', () => { div.remove(); }); }); + +describe('isInteractiveTarget', () => { + it('returns true for menu item radio targets', () => { + const item = document.createElement('div'); + item.setAttribute('role', 'menuitemradio'); + document.body.appendChild(item); + + const event = pointerup(item); + expect(isInteractiveTarget(event)).toBe(true); + + item.remove(); + }); + + it('returns true for nested targets inside menu items', () => { + const item = document.createElement('div'); + const label = document.createElement('span'); + item.setAttribute('role', 'menuitemradio'); + item.appendChild(label); + document.body.appendChild(item); + + const event = pointerup(label); + expect(isInteractiveTarget(event)).toBe(true); + + item.remove(); + }); + + it('returns true for menu surfaces', () => { + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + document.body.appendChild(menu); + + const event = pointerup(menu); + expect(isInteractiveTarget(event)).toBe(true); + + menu.remove(); + }); + + it('returns false for plain div targets', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + const event = pointerup(div); + expect(isInteractiveTarget(event)).toBe(false); + + div.remove(); + }); +});