From f3652bd90be293268449295f96439a465b7a1bc3 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Tue, 30 Jun 2026 11:32:09 +1000 Subject: [PATCH] fix(skin): improvements to menu styles (#1725) --- .../dom/ui/menu/menu-viewport-transition.ts | 81 ++++++-- .../tests/menu-viewport-transition.test.ts | 181 +++++++++++++++++- .../src/define/audio/minimal-skin.tailwind.ts | 2 +- .../html/src/define/audio/skin.tailwind.ts | 2 +- .../live-video/minimal-skin.tailwind.ts | 2 +- .../src/define/live-video/skin.tailwind.ts | 2 +- .../src/define/video/minimal-skin.tailwind.ts | 26 +-- .../html/src/define/video/skin.tailwind.ts | 26 +-- packages/html/src/ui/menu/menu-element.ts | 31 ++- packages/icons/src/assets/default/quality.svg | 3 + packages/icons/src/assets/minimal/quality.svg | 3 + .../presets/audio/minimal-skin.tailwind.tsx | 2 +- .../react/src/presets/audio/skin.tailwind.tsx | 2 +- .../live-video/minimal-skin.tailwind.tsx | 2 +- .../src/presets/live-video/skin.tailwind.tsx | 2 +- .../presets/video/minimal-skin.tailwind.tsx | 24 +-- .../react/src/presets/video/minimal-skin.tsx | 4 +- .../react/src/presets/video/skin.tailwind.tsx | 24 +-- packages/react/src/presets/video/skin.tsx | 4 +- packages/react/src/ui/menu/menu-content.tsx | 43 ++++- .../react/src/ui/menu/tests/menu.test.tsx | 32 ++++ .../src/default/css/components/menus.css | 76 +++++--- .../src/default/css/components/popup.css | 32 ++-- .../skins/src/default/css/components/root.css | 13 ++ .../src/default/tailwind/components/menu.ts | 64 +++++-- .../src/default/tailwind/components/popup.ts | 20 +- .../src/default/tailwind/components/root.ts | 4 + packages/skins/src/minimal/css/audio.css | 16 +- .../src/minimal/css/components/menus.css | 90 ++++++--- .../src/minimal/css/components/popup.css | 35 ++-- .../skins/src/minimal/css/components/root.css | 13 ++ packages/skins/src/minimal/css/video.css | 12 +- .../src/minimal/tailwind/audio.tailwind.ts | 16 +- .../src/minimal/tailwind/components/menu.ts | 60 ++++-- .../src/minimal/tailwind/components/popup.ts | 21 +- .../src/minimal/tailwind/components/root.ts | 4 + .../src/minimal/tailwind/video.tailwind.ts | 12 +- 37 files changed, 727 insertions(+), 259 deletions(-) create mode 100644 packages/icons/src/assets/default/quality.svg create mode 100644 packages/icons/src/assets/minimal/quality.svg diff --git a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts index b5168662..4e82ea69 100644 --- a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts +++ b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts @@ -1,9 +1,12 @@ +import { resolveCSSLength } from '@videojs/utils/dom'; +import { PopoverCSSVars } from '../../../core/ui/popover/popover-css-vars'; import { TransitionDataAttrs } from '../../../core/ui/transition'; import { forceLayout } from '../../utils/layout'; import type { MenuViewTransitionState } from './create-menu-view-transition'; export interface MenuViewportTransitionOptions { minWidth?: number; + availableWidth?: number | string; } export interface MenuViewportAttrs { @@ -28,6 +31,7 @@ interface InlineStyleSnapshotEntry { interface PendingMenuViewTransition { entering: HTMLElement; + availableWidth: number | null; fromSize: MenuViewSize; toSize: MenuViewSize; } @@ -129,6 +133,19 @@ function resolveMinWidth(options: MenuViewportTransitionOptions | undefined): nu return options?.minWidth ?? DEFAULT_MENU_VIEWPORT_MIN_WIDTH; } +function resolveAvailableWidth( + content: HTMLElement, + options: MenuViewportTransitionOptions | undefined +): number | null { + const inlineWidth = content.style.getPropertyValue(PopoverCSSVars.availableWidth); + const value = + options?.availableWidth || inlineWidth || getComputedStyle(content).getPropertyValue(PopoverCSSVars.availableWidth); + + const width = typeof value === 'number' ? value : resolveCSSLength(content, value); + + return Number.isFinite(width) && width > 0 ? width : null; +} + function snapshotInlineStyle(element: HTMLElement): InlineStyleSnapshotEntry[] { return MENU_VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({ property, @@ -147,8 +164,14 @@ function restoreInlineStyle(element: HTMLElement, snapshot: InlineStyleSnapshotE } } -function measureMenuView(view: HTMLElement, minWidth: number): MenuViewSize { +function measureMenuView( + content: HTMLElement, + view: HTMLElement, + minWidth: number, + options?: MenuViewportTransitionOptions +): MenuViewSize { const snapshot = snapshotInlineStyle(view); + const availableWidth = resolveAvailableWidth(content, options); try { view.style.setProperty('position', 'absolute'); @@ -162,10 +185,19 @@ function measureMenuView(view: HTMLElement, minWidth: number): MenuViewSize { view.style.setProperty('max-width', 'none'); forceLayout(view); - const rect = view.getBoundingClientRect(); + let rect = view.getBoundingClientRect(); + const naturalWidth = Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth)); + const width = Math.ceil(availableWidth ? Math.max(minWidth, Math.min(naturalWidth, availableWidth)) : naturalWidth); + + if (width !== naturalWidth) { + view.style.setProperty('width', `${width}px`); + view.style.setProperty('max-width', `${width}px`); + forceLayout(view); + rect = view.getBoundingClientRect(); + } return { - width: Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth)), + width, height: Math.ceil(Math.max(rect.height, view.scrollHeight)), }; } finally { @@ -200,10 +232,11 @@ function prepareEnteringMenuView( options?: MenuViewportTransitionOptions ): void { const minWidth = resolveMinWidth(options); - const fromSize = measureMenuView(rootView, minWidth); - const toSize = measureMenuView(entering, minWidth); + const availableWidth = resolveAvailableWidth(content, options); + const fromSize = measureMenuView(content, rootView, minWidth, options); + const toSize = measureMenuView(content, entering, minWidth, options); - state.pending = { entering, fromSize, toSize }; + state.pending = { entering, availableWidth, fromSize, toSize }; setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE); setViewportSize(content, fromSize); forceLayout(content); @@ -217,13 +250,15 @@ function startEnteringMenuView( options?: MenuViewportTransitionOptions ): void { const minWidth = resolveMinWidth(options); + const availableWidth = resolveAvailableWidth(content, options); const current = - state.pending?.entering === entering + state.pending?.entering === entering && state.pending.availableWidth === availableWidth ? state.pending : { entering, - fromSize: measureMenuView(rootView, minWidth), - toSize: measureMenuView(entering, minWidth), + availableWidth, + fromSize: measureMenuView(content, rootView, minWidth, options), + toSize: measureMenuView(content, entering, minWidth, options), }; state.pending = null; @@ -245,8 +280,8 @@ function startExitingMenuView( transitionState.pending = null; const minWidth = resolveMinWidth(options); - const fromSize = measureMenuView(exiting, minWidth); - const toSize = measureMenuView(rootView, minWidth); + const fromSize = measureMenuView(content, exiting, minWidth, options); + const toSize = measureMenuView(content, rootView, minWidth, options); setViewportSize(content, fromSize); setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE); @@ -261,14 +296,27 @@ export function syncMenuViewRoot( hasActiveChildView: boolean, options?: MenuViewportTransitionOptions ): void { - if (!content || hasActiveChildView) return; + if (!content) return; const viewport = getViewportElement(content); const rootView = getRootViewElement(viewport); - if (!rootView || getActiveMenuViewElement(viewport)) return; + if (!rootView) return; - const size = measureMenuView(rootView, resolveMinWidth(options)); + const activeView = getActiveMenuViewElement(viewport); + + if (activeView) { + if (rootView.getAttribute(MENU_VIEW_STATE_ATTR) === MENU_VIEW_INACTIVE_STATE) { + const size = measureMenuView(content, activeView, resolveMinWidth(options), options); + setViewportSize(content, size); + } + + return; + } + + if (hasActiveChildView) return; + + const size = measureMenuView(content, rootView, resolveMinWidth(options), options); setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE); setViewportSize(content, size); @@ -318,7 +366,10 @@ export function syncMenuViewTransition( const state = getViewportTransitionState(content); const phaseKey = `${viewState.phase}:${viewState.direction}`; - if (state.phaseKeys.get(view) === phaseKey) return; + const shouldResyncActiveView = + viewState.phase === 'active' && rootView.getAttribute(MENU_VIEW_STATE_ATTR) !== MENU_VIEW_INACTIVE_STATE; + + if (state.phaseKeys.get(view) === phaseKey && !shouldResyncActiveView) return; state.phaseKeys.set(view, phaseKey); diff --git a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts index c30ef800..09a5ff2f 100644 --- a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts +++ b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts @@ -55,11 +55,15 @@ function mockMenuViewSize( currentHeight, naturalWidth, naturalHeight, + constrainedWidth, + constrainedHeight, }: { currentWidth: number; currentHeight: number; naturalWidth: number; naturalHeight: number; + constrainedWidth?: number; + constrainedHeight?: number; } ): void { function isMeasuringNaturalSize(): boolean { @@ -68,18 +72,35 @@ function mockMenuViewSize( ); } - element.getBoundingClientRect = vi.fn(() => - isMeasuringNaturalSize() ? createRect(naturalWidth, naturalHeight) : createRect(currentWidth, currentHeight) - ); + function isMeasuringConstrainedSize(): boolean { + return element.style.getPropertyValue('width') === `${constrainedWidth}px`; + } + + function getSize(): { width: number; height: number } { + if (isMeasuringNaturalSize()) { + return { width: naturalWidth, height: naturalHeight }; + } + + if (constrainedWidth && constrainedHeight && isMeasuringConstrainedSize()) { + return { width: constrainedWidth, height: constrainedHeight }; + } + + return { width: currentWidth, height: currentHeight }; + } + + element.getBoundingClientRect = vi.fn(() => { + const size = getSize(); + return createRect(size.width, size.height); + }); Object.defineProperty(element, 'scrollWidth', { configurable: true, - get: () => (isMeasuringNaturalSize() ? naturalWidth : currentWidth), + get: () => getSize().width, }); Object.defineProperty(element, 'scrollHeight', { configurable: true, - get: () => (isMeasuringNaturalSize() ? naturalHeight : currentHeight), + get: () => getSize().height, }); } @@ -190,6 +211,156 @@ describe('menu-viewport-transition', () => { expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px'); }); + it('measures the root view height at the available menu width', () => { + const content = addElement(); + const rootView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + content.style.setProperty('--media-popover-available-width', '180px'); + content.append(rootView); + + mockMenuViewSize(rootView, { + currentWidth: 160, + currentHeight: 80, + naturalWidth: 260, + naturalHeight: 80, + constrainedWidth: 180, + constrainedHeight: 128, + }); + + syncMenuViewRoot(content, false); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('128px'); + }); + + it('measures an entering submenu height at the available menu width', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + content.style.setProperty('--media-popover-available-width', '180px'); + content.append(rootView, menuView); + + mockMenuViewSize(rootView, { + currentWidth: 160, + currentHeight: 100, + naturalWidth: 160, + naturalHeight: 100, + }); + mockMenuViewSize(menuView, { + currentWidth: 160, + currentHeight: 100, + naturalWidth: 260, + naturalHeight: 100, + constrainedWidth: 180, + constrainedHeight: 148, + }); + + syncMenuViewTransition(content, menuView, { + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + syncMenuViewTransition(content, menuView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-1', + }); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('148px'); + }); + + it('remeasures a pending submenu when the available menu width changes before active', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + content.append(rootView, menuView); + + mockMenuViewSize(rootView, { + currentWidth: 160, + currentHeight: 100, + naturalWidth: 160, + naturalHeight: 100, + }); + mockMenuViewSize(menuView, { + currentWidth: 160, + currentHeight: 100, + naturalWidth: 260, + naturalHeight: 100, + constrainedWidth: 180, + constrainedHeight: 148, + }); + + syncMenuViewTransition(content, menuView, { + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + + content.style.setProperty('--media-popover-available-width', '180px'); + + syncMenuViewTransition(content, menuView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-1', + }); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('148px'); + }); + + it('resyncs an active submenu when the available menu width changes', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + content.append(rootView, menuView); + + mockMenuViewSize(rootView, { + currentWidth: 160, + currentHeight: 100, + naturalWidth: 160, + naturalHeight: 100, + }); + mockMenuViewSize(menuView, { + currentWidth: 260, + currentHeight: 100, + naturalWidth: 260, + naturalHeight: 100, + constrainedWidth: 180, + constrainedHeight: 148, + }); + + syncMenuViewTransition(content, menuView, { + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + syncMenuViewTransition(content, menuView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-1', + }); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('260px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('100px'); + + content.style.setProperty('--media-popover-available-width', '180px'); + syncMenuViewRoot(content, true); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('148px'); + }); + it('forces root view layout around the active submenu transition', () => { const content = addElement(); const rootView = document.createElement('div'); diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index 82ef8020..72dacbe8 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -109,7 +109,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 7e4d5850..0d683112 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -104,7 +104,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts index 611833f4..02c9936f 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -94,7 +94,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index 3f036d14..d89b9e2b 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -96,7 +96,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 8acdef9f..ba26d216 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -145,27 +145,27 @@ function getTemplateHTML() {
- ${renderIcon('switches', { class: icon })} + ${renderIcon('switches', { class: cn(icon, menu.icon) })} Quality - ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} - ${renderIcon('speed', { class: icon })} + ${renderIcon('speed', { class: cn(icon, menu.icon) })} Speed - ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} - ${renderIcon('captions-off', { class: icon })} + ${renderIcon('captions-off', { class: cn(icon, menu.icon) })} Captions - ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
@@ -173,7 +173,7 @@ function getTemplateHTML() { - ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} Quality
@@ -184,9 +184,9 @@ function getTemplateHTML() { - + - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} @@ -195,7 +195,7 @@ function getTemplateHTML() { - ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} Speed
@@ -204,7 +204,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} @@ -213,7 +213,7 @@ function getTemplateHTML() { - ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} Captions
@@ -222,7 +222,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index f09441b4..581366e8 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -141,27 +141,27 @@ function getTemplateHTML() {
- ${renderIcon('switches', { class: icon })} + ${renderIcon('switches', { class: cn(icon, menu.icon) })} Quality - ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} - ${renderIcon('speed', { class: icon })} + ${renderIcon('speed', { class: cn(icon, menu.icon) })} Speed - ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} - ${renderIcon('captions-off', { class: icon })} + ${renderIcon('captions-off', { class: cn(icon, menu.icon) })} Captions - ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
@@ -169,7 +169,7 @@ function getTemplateHTML() { - ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} Quality
@@ -180,9 +180,9 @@ function getTemplateHTML() { - + - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} @@ -191,7 +191,7 @@ function getTemplateHTML() { - ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} Speed
@@ -200,7 +200,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} @@ -209,7 +209,7 @@ function getTemplateHTML() { - ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} Captions
@@ -218,7 +218,7 @@ function getTemplateHTML() { - ${renderIcon('check', { class: icon })} + ${renderIcon('check', { class: cn(icon, menu.icon) })} diff --git a/packages/html/src/ui/menu/menu-element.ts b/packages/html/src/ui/menu/menu-element.ts index 1af9be37..7d0a1e26 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, POPUP_HOST_ATTR } from '@videojs/core'; +import { MenuCore, MenuDataAttrs, type MenuInput, POPUP_HOST_ATTR, PopoverCSSVars } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, @@ -262,15 +262,28 @@ export class MenuElement extends MediaElement { const triggerRect = this.#currentTrigger?.getBoundingClientRect(); const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(this); - - if (supportsAnchorPositioning()) { - applyStyles( - this, - getAnchorPositionStyle(this.id, positionOptions, triggerRect, undefined, boundaryRect, offsets) + const anchorSupported = supportsAnchorPositioning(); + const getNextStyle = () => + getAnchorPositionStyle( + this.id, + positionOptions, + triggerRect, + anchorSupported ? undefined : getPopupPositionRect(this), + boundaryRect, + offsets ); - } else { - const selfRect = getPopupPositionRect(this); - applyStyles(this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, selfRect, boundaryRect, offsets)); + let nextStyle = getNextStyle(); + + if (anchorSupported) { + applyStyles(this, nextStyle); + } + + const availableWidth = nextStyle[PopoverCSSVars.availableWidth]; + syncMenuViewRoot(this, this.#navState.stack.length > 0, availableWidth ? { availableWidth } : undefined); + + if (!anchorSupported) { + nextStyle = getNextStyle(); + applyStyles(this, nextStyle); } this.#position.sync(this.#currentTrigger, boundaryElement); diff --git a/packages/icons/src/assets/default/quality.svg b/packages/icons/src/assets/default/quality.svg new file mode 100644 index 00000000..ce6cf9bc --- /dev/null +++ b/packages/icons/src/assets/default/quality.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/assets/minimal/quality.svg b/packages/icons/src/assets/minimal/quality.svg new file mode 100644 index 00000000..ce6cf9bc --- /dev/null +++ b/packages/icons/src/assets/minimal/quality.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index 3beacf15..d07523f7 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -133,7 +133,7 @@ function PlaybackRateRadioGroup(): ReactNode { {option.label} - + ))} diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index 026c7748..cdb71ef1 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -135,7 +135,7 @@ function PlaybackRateRadioGroup(): ReactNode { {option.label} - + ))} 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 be290cd4..6b99c602 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -185,7 +185,7 @@ function CaptionsTrigger(): ReactNode { {option.label} - + ))} diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index bc70bd8f..8d25ff0a 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -185,7 +185,7 @@ function CaptionsTrigger(): ReactNode { {option.label} - + ))} diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 79addcd5..46510823 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -40,11 +40,11 @@ import { PipEnterIcon, PipExitIcon, PlayIcon, + QualityIcon, RestartIcon, SeekIcon, SpeedIcon, SpinnerIcon, - SwitchesIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon, @@ -162,7 +162,7 @@ function VolumePopover(): ReactNode { } function MenuChevron({ flipped = false }: { flipped?: boolean }): ReactNode { - return ; + return ; } function SettingsMenu(): ReactNode { @@ -194,7 +194,7 @@ function SettingsMenu(): ReactNode { className={cn(menu.item, 'media-menu__item--submenu')} render={(props) => (
- + Quality @@ -222,15 +222,17 @@ function SettingsMenu(): ReactNode { value={option.value} disabled={option.disabled} > - {option.label} - {option.tier ? {option.tier} : null} - {option.badge ? {option.badge} : null} + + {option.label} + {option.tier ? {option.tier} : null} + + {option.badge ? {option.badge} : null} - + ))} @@ -245,7 +247,7 @@ function SettingsMenu(): ReactNode { className={cn(menu.item, 'media-menu__item--submenu')} render={(props) => (
- + Speed @@ -279,7 +281,7 @@ function SettingsMenu(): ReactNode { forceMount className={menu.indicator} > - + ))} @@ -294,7 +296,7 @@ function SettingsMenu(): ReactNode { className={cn(menu.item, 'media-menu__item--submenu')} render={(props) => (
- + Captions @@ -328,7 +330,7 @@ function SettingsMenu(): ReactNode { forceMount className={menu.indicator} > - + ))} diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index 0f73aabf..1b631e03 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -17,11 +17,11 @@ import { PipEnterIcon, PipExitIcon, PlayIcon, + QualityIcon, RestartIcon, SeekIcon, SpeedIcon, SpinnerIcon, - SwitchesIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon, @@ -131,7 +131,7 @@ function SettingsMenu(): ReactNode { className="media-menu__item media-menu__item--submenu" render={(props) => (
- + Quality diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 10a8b2d8..bb6edeb9 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -40,11 +40,11 @@ import { PipEnterIcon, PipExitIcon, PlayIcon, + QualityIcon, RestartIcon, SeekIcon, SpeedIcon, SpinnerIcon, - SwitchesIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon, @@ -162,7 +162,7 @@ function VolumePopover(): ReactNode { } function MenuChevron({ flipped = false }: { flipped?: boolean }): ReactNode { - return ; + return ; } function SettingsMenu(): ReactNode { @@ -194,7 +194,7 @@ function SettingsMenu(): ReactNode { className={cn(menu.item, 'media-menu__item--submenu')} render={(props) => (
- + Quality @@ -222,15 +222,17 @@ function SettingsMenu(): ReactNode { value={option.value} disabled={option.disabled} > - {option.label} - {option.tier ? {option.tier} : null} - {option.badge ? {option.badge} : null} + + {option.label} + {option.tier ? {option.tier} : null} + + {option.badge ? {option.badge} : null} - + ))} @@ -245,7 +247,7 @@ function SettingsMenu(): ReactNode { className={cn(menu.item, 'media-menu__item--submenu')} render={(props) => (
- + Speed @@ -279,7 +281,7 @@ function SettingsMenu(): ReactNode { forceMount className={menu.indicator} > - + ))} @@ -294,7 +296,7 @@ function SettingsMenu(): ReactNode { className={cn(menu.item, 'media-menu__item--submenu')} render={(props) => (
- + Captions @@ -328,7 +330,7 @@ function SettingsMenu(): ReactNode { forceMount className={menu.indicator} > - + ))} diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 3571377e..16429bfc 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -17,11 +17,11 @@ import { PipEnterIcon, PipExitIcon, PlayIcon, + QualityIcon, RestartIcon, SeekIcon, SpeedIcon, SpinnerIcon, - SwitchesIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon, @@ -131,7 +131,7 @@ function SettingsMenu(): ReactNode { className="media-menu__item media-menu__item--submenu" render={(props) => (
- + Quality diff --git a/packages/react/src/ui/menu/menu-content.tsx b/packages/react/src/ui/menu/menu-content.tsx index ab5d8751..7008fcee 100644 --- a/packages/react/src/ui/menu/menu-content.tsx +++ b/packages/react/src/ui/menu/menu-content.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { MenuState } from '@videojs/core'; +import { type MenuState, PopoverCSSVars } from '@videojs/core'; import { createMenuViewTransition, getAnchorPositionStyle, @@ -135,6 +135,9 @@ export const MenuContent = forwardRef(function const menuViewTransitionState = useSnapshot(menuViewTransition.input); const menuViewElementRef = useRef(null); const parentContentElementRef = useRef(null); + const activeSubMenuIdRef = useRef(activeSubMenuId); + + activeSubMenuIdRef.current = activeSubMenuId; useLayoutEffect(() => { return () => menuViewTransition.destroy(); @@ -241,11 +244,6 @@ export const MenuContent = forwardRef(function if (!state.open) return; syncMenuViewRoot(internalRef.current, activeSubMenuId !== null); - }, [isSubmenu, state.open, activeSubMenuId]); - - useLayoutEffect(() => { - if (isSubmenu) return; - if (!state.open) return; const contentElement = internalRef.current; if (!contentElement) return; @@ -265,12 +263,16 @@ export const MenuContent = forwardRef(function useLayoutEffect(() => { if (isSubmenu) return; - if (!positionOptions) return; if (!state.open) { setManualStyle(null); return; } + if (!positionOptions) { + syncMenuViewRoot(internalRef.current, activeSubMenuIdRef.current !== null); + return; + } + const rootPositionOptions = positionOptions; function measure(): void { @@ -281,11 +283,12 @@ export const MenuContent = forwardRef(function const triggerRect = triggerElement.getBoundingClientRect(); const root = contentElement.getRootNode() as Document | ShadowRoot; const boundaryElement = resolvePositioningBoundary(boundary, { container, root }); - const contentRect = supportsAnchorPositioning() ? undefined : getPopupPositionRect(contentElement); + const anchorSupported = supportsAnchorPositioning(); + const contentRect = anchorSupported ? undefined : getPopupPositionRect(contentElement); const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(contentElement); - const { positionAnchor: _, ...nextStyle } = getAnchorPositionStyle( + let nextStyle = getAnchorPositionStyle( anchorName, rootPositionOptions, triggerRect, @@ -294,7 +297,27 @@ export const MenuContent = forwardRef(function offsets ); - setManualStyle(nextStyle as CSSProperties); + const availableWidth = nextStyle[PopoverCSSVars.availableWidth]; + syncMenuViewRoot( + contentElement, + activeSubMenuIdRef.current !== null, + availableWidth ? { availableWidth } : undefined + ); + + if (!anchorSupported) { + nextStyle = getAnchorPositionStyle( + anchorName, + rootPositionOptions, + triggerRect, + getPopupPositionRect(contentElement), + boundaryRect, + offsets + ); + } + + const { positionAnchor: _, ...rootStyle } = nextStyle; + + setManualStyle(rootStyle as CSSProperties); } measure(); diff --git a/packages/react/src/ui/menu/tests/menu.test.tsx b/packages/react/src/ui/menu/tests/menu.test.tsx index 515da054..bb9de2d7 100644 --- a/packages/react/src/ui/menu/tests/menu.test.tsx +++ b/packages/react/src/ui/menu/tests/menu.test.tsx @@ -499,6 +499,38 @@ describe('MenuContent', () => { expect(screen.getByTestId('root-view').hasAttribute('data-menu-view')).toBe(true); }); + it('syncs root viewport size without position options', async () => { + const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + + HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRectMock() { + if (this.getAttribute('data-testid') === 'root-view') { + return new DOMRect(0, 0, 180, 96); + } + + return getBoundingClientRect.call(this); + }; + + try { + render( + + Settings + + + Auto + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('root-content').style.getPropertyValue('--media-menu-width')).toBe('180px'); + expect(screen.getByTestId('root-content').style.getPropertyValue('--media-menu-height')).toBe('96px'); + }); + } finally { + HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; + } + }); + it('remeasures an open root view when menu items are added', async () => { const { rerender } = render(); const content = screen.getByTestId('content'); diff --git a/packages/skins/src/default/css/components/menus.css b/packages/skins/src/default/css/components/menus.css index 760f8bf0..e43d243d 100644 --- a/packages/skins/src/default/css/components/menus.css +++ b/packages/skins/src/default/css/components/menus.css @@ -6,16 +6,16 @@ .media-default-skin .media-menu { --menu-transition-duration: 250ms; --menu-max-height: 14rem; - --popup-transition-property: , width, height; - --popup-transition-duration: , var(--menu-transition-duration), var(--menu-transition-duration); + --menu-padding: 0.25rem; + --menu-border-radius: 0.75rem; + --menu-item-border-radius: calc(var(--menu-border-radius) - var(--menu-padding)); box-sizing: border-box; - min-width: 6rem; max-width: var(--media-popover-available-width, none); max-height: min(var(--media-popover-available-height, var(--menu-max-height)), var(--menu-max-height)); - padding: 0.25rem; + padding: var(--menu-padding); overflow: auto; overscroll-behavior: none; - border-radius: 0.75rem; + border-radius: var(--menu-border-radius); @media (prefers-reduced-motion: reduce) { --menu-transition-duration: 0ms; @@ -24,7 +24,7 @@ & .media-menu__panel { position: absolute; inset: 0; - padding: 0.25rem; + padding: var(--menu-padding); overflow: auto; overscroll-behavior: none; outline: none; @@ -43,6 +43,11 @@ &[data-menu-root-view][data-menu-view-state="inactive"] { filter: blur(8px); translate: -100% 0; + + /* Hide the indicator while transitioning */ + & .media-menu__group::before { + display: none; + } } /* Submenu panels — slide in/out alongside the root view */ @@ -82,20 +87,34 @@ display: flex; flex-direction: column; gap: 0.125rem; + + @supports (top: anchor(top)) { + &::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: var(--menu-item-border-radius); + transition: inset ease-in-out 100ms; + } + } } & .media-menu__item, & .media-menu__back { display: flex; - gap: 0.5rem; + gap: 0.375rem; align-items: center; - padding: 0.375rem 0.75rem; + padding: 0.375rem 0.5rem; + text-align: left; text-shadow: 0 1px 0 var(--media-current-shadow-color); cursor: pointer; user-select: none; outline: 2px solid transparent; outline-offset: -2px; - border-radius: 0.5rem; + border-radius: var(--menu-item-border-radius); transition: background-color, color; transition-timing-function: ease-out; transition-duration: 100ms; @@ -108,23 +127,18 @@ &:hover, &[data-highlighted] { background-color: oklch(from currentColor l c h / 0.1); - } - &[data-availability="unavailable"], - &[data-availability="unsupported"] { - display: none; + @supports (top: anchor(top)) { + anchor-name: --media-menu-item-highlight-anchor; + background-color: transparent; + } } & .media-icon { + flex-shrink: 0; + color: oklch(from currentColor l c h / 0.65); filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); } - - & .media-menu__chevron:first-child { - margin-left: -0.25rem; - } - & .media-menu__chevron:last-child { - margin-right: -0.25rem; - } } & .media-menu__indicator { @@ -152,10 +166,15 @@ &[aria-checked="true"] .media-menu__indicator { opacity: 1; } + + &[data-availability="unavailable"], + &[data-availability="unsupported"] { + display: none; + } } & .media-menu__tier { - padding-top: 0.0625rem; + padding-top: 1px; padding-left: 0.125rem; font-size: 0.5625rem; font-weight: 600; @@ -166,7 +185,6 @@ & .media-menu__back { width: 100%; margin-bottom: 0.125rem; - font-weight: 500; } & .media-menu__hint { @@ -174,6 +192,7 @@ gap: 0.25rem; align-items: center; min-width: 0; + padding-left: 0.5rem; margin-left: auto; font-size: 0.75rem; color: oklch(from currentColor l c h / 0.65); @@ -194,8 +213,19 @@ /* Settings menu */ &.media-menu--settings { width: var(--media-menu-width); - min-width: 11rem; + min-width: 12rem; height: var(--media-menu-height); overflow: hidden; + /* Add height and width transitions. */ + transition: + var(--media-popup-transition), + height var(--media-popup-transition-timing-function) var(--menu-transition-duration), + width var(--media-popup-transition-timing-function) var(--menu-transition-duration); + + /* Don't transition width and height on open/close. */ + &[data-starting-style], + &[data-ending-style] { + transition: var(--media-popup-transition); + } } } diff --git a/packages/skins/src/default/css/components/popup.css b/packages/skins/src/default/css/components/popup.css index 81df1ec8..30ed8072 100644 --- a/packages/skins/src/default/css/components/popup.css +++ b/packages/skins/src/default/css/components/popup.css @@ -2,44 +2,52 @@ Popups & Tooltips ========================================================================== */ +.media-default-skin { + --media-popup-transition: + opacity var(--media-popup-transition-timing-function) var(--media-popup-transition-duration), + filter var(--media-popup-transition-timing-function) var(--media-popup-transition-duration), + transform var(--media-popup-transition-timing-function) var(--media-popup-transition-duration), + scale var(--media-popup-transition-timing-function) var(--media-popup-transition-duration); +} + .media-default-skin .media-popover, .media-default-skin .media-tooltip { + --popup-translate-distance: 0.5rem; margin: 0; 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), - var(--media-popup-transition-duration), - var(--media-popup-transition-duration) var(--popup-transition-duration,); - transition-property: - scale, - opacity, - filter var(--popup-transition-property,); + transition: var(--media-popup-transition); &[data-starting-style], &[data-ending-style] { opacity: 0; - filter: blur(8px); - scale: 0.85; + filter: blur(4px); + /* We have to use transform here for translate as the translate property is used for positioning by core. */ + transform: translate(var(--popup-translate-x-distance, 0), var(--popup-translate-y-distance, 0)); + scale: 0.95; } &[data-ending-style] { + transform: none; + /* Speed up the exit transition. */ transition-duration: max(0ms, calc(var(--media-popup-transition-duration) - 50ms)); } &[data-side="top"] { + --popup-translate-y-distance: var(--popup-translate-distance); transform-origin: bottom; } &[data-side="bottom"] { + --popup-translate-y-distance: calc(var(--popup-translate-distance) * -1); transform-origin: top; } &[data-side="left"] { + --popup-translate-x-distance: var(--popup-translate-distance); transform-origin: right; } &[data-side="right"] { + --popup-translate-x-distance: calc(var(--popup-translate-distance) * -1); transform-origin: left; } diff --git a/packages/skins/src/default/css/components/root.css b/packages/skins/src/default/css/components/root.css index bc4166cd..d5d687fb 100644 --- a/packages/skins/src/default/css/components/root.css +++ b/packages/skins/src/default/css/components/root.css @@ -6,6 +6,7 @@ --media-current-shadow-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); --media-current-shadow-color-subtle: oklch(from var(--media-current-shadow-color) l c h / calc(alpha * 0.4)); --media-icon-size: 18px; + --media-color-scrollbar-thumb: oklch(from currentColor l c h / 0.3); position: relative; display: block; width: 100%; @@ -24,6 +25,8 @@ letter-spacing: normal; outline: 2px solid transparent; outline-offset: -4px; + scrollbar-color: var(--media-color-scrollbar-thumb) transparent; + scrollbar-width: thin; border-radius: var(--media-border-radius, 2rem); isolation: isolate; transition-timing-function: ease-out; @@ -34,4 +37,14 @@ outline-color: currentColor; outline-offset: 2px; } + + &::-webkit-scrollbar-thumb { + background: var(--media-color-scrollbar-thumb); + border-radius: 9999px; + } + + @media (prefers-reduced-transparency: reduce) or (prefers-contrast: more) { + --media-color-scrollbar-thumb: oklch(from currentColor l c h / 0.8); + scrollbar-width: auto; + } } diff --git a/packages/skins/src/default/tailwind/components/menu.ts b/packages/skins/src/default/tailwind/components/menu.ts index 248e90f8..f1f20420 100644 --- a/packages/skins/src/default/tailwind/components/menu.ts +++ b/packages/skins/src/default/tailwind/components/menu.ts @@ -4,13 +4,14 @@ import { popup } from './popup'; import { surface } from './surface'; const panelBase = cn( - 'absolute inset-0 overflow-auto overscroll-none p-1 outline-none translate-none', + 'absolute inset-0 overflow-auto overscroll-none p-(--menu-padding) outline-none translate-none', 'data-starting-style:overflow-hidden data-ending-style:overflow-hidden', - 'transition-[translate,filter] duration-(--menu-transition-duration) ease-out will-change-[translate,filter]' + 'transition-[translate,filter] duration-(--menu-transition-duration) ease-in-out will-change-[translate,filter]' ); const rootView = cn( panelBase, + 'group/menu-root-view', 'data-[menu-view-state=inactive]:-translate-x-full data-[menu-view-state=inactive]:blur' ); @@ -28,35 +29,61 @@ const submenuPanel = cn( ); const itemBase = cn( - 'flex cursor-pointer select-none items-center gap-2 rounded-lg py-1.5 px-3', + 'flex cursor-pointer select-none items-center gap-1.5 rounded-(--menu-item-border-radius) py-1.5 px-2', + 'text-left', 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)', 'outline-2 -outline-offset-2 outline-transparent', 'transition-colors duration-100 ease-out', 'hover:bg-current/10 data-highlighted:bg-current/10', - 'focus-visible:outline-current focus-visible:outline-offset-2', - '[&_.media-icon]:drop-shadow-[0_1px_0_var(--media-current-shadow-color)]' + 'supports-[top:anchor(top)]:hover:[anchor-name:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:hover:bg-transparent', + 'supports-[top:anchor(top)]:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:data-highlighted:bg-transparent', + 'focus-visible:outline-current focus-visible:outline-offset-2' ); const menuTokens = cn( - '[--menu-transition-duration:250ms] [--menu-max-height:14rem]', - '[--popup-transition-property:,_width,_height] [--popup-transition-duration:,_var(--menu-transition-duration),_var(--menu-transition-duration)]', + '[--menu-transition-duration:250ms] [--menu-max-height:14rem] [--menu-padding:0.25rem]', + '[--menu-border-radius:0.75rem] [--menu-item-border-radius:calc(var(--menu-border-radius)_-_var(--menu-padding))]', 'motion-reduce:[--menu-transition-duration:0ms]' ); +const group = cn( + 'flex flex-col gap-0.5', + 'supports-[top:anchor(top)]:before:absolute', + 'supports-[top:anchor(top)]:before:[position-anchor:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:before:[inset:anchor(inside)]', + 'supports-[top:anchor(top)]:before:pointer-events-none', + 'supports-[top:anchor(top)]:before:bg-current/10', + 'supports-[top:anchor(top)]:before:rounded-(--menu-item-border-radius)', + 'supports-[top:anchor(top)]:before:transition-[inset]', + 'supports-[top:anchor(top)]:before:duration-100', + 'supports-[top:anchor(top)]:before:ease-in-out', + 'group-data-[menu-view-state=inactive]/menu-root-view:before:hidden' +); + const menuHostShell = cn( popup.popover, surface, menuTokens, 'max-w-(--media-popover-available-width,none) max-h-[min(var(--media-popover-available-height,var(--menu-max-height)),var(--menu-max-height))]', - 'box-border rounded-xl p-1 overscroll-none' + 'box-border rounded-(--menu-border-radius) p-(--menu-padding) overscroll-none' ); export const menu = { /** Standalone menu popover host (audio playback rate, sandbox demos). */ - root: cn(menuHostShell, 'min-w-24 !overflow-auto'), + root: cn(menuHostShell, 'overflow-auto!'), /** Settings menu viewport host with nested submenu navigation. */ - settings: cn(menuHostShell, 'min-w-44 w-(--media-menu-width) h-(--media-menu-height)', '!overflow-hidden'), - group: 'flex flex-col gap-0.5', + settings: cn( + menuHostShell, + // Add height and width transitions. + '[--media-popup-transition:var(--media-popup-base-transition),height_var(--media-popup-transition-timing-function)_var(--menu-transition-duration),width_var(--media-popup-transition-timing-function)_var(--menu-transition-duration)]', + // Don't transition width and height on open/close. + 'data-starting-style:[--media-popup-transition:var(--media-popup-base-transition)] data-ending-style:[--media-popup-transition:var(--media-popup-base-transition)]', + 'min-w-48 w-(--media-menu-width) h-(--media-menu-height)', + '!overflow-hidden' + ), + group, item: cn( itemBase, 'group/menu-item justify-between tabular-nums text-inherit', @@ -64,20 +91,17 @@ export const menu = { 'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50' ), separator: 'my-1 border-b border-[oklch(0_0_0/0.1)] shadow-[0_1px_0_0_oklch(1_0_0/0.075)]', - tier: 'self-start -ml-1 pt-px text-[0.5625rem] font-semibold leading-none text-current/70', - badge: 'ml-auto', - 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)]' - ), + tier: 'pl-0.5 pt-px text-[0.5625rem] font-semibold leading-none text-current/70', + indicator: 'ml-auto -mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', + icon: 'shrink-0 text-current/65 drop-shadow-[0_1px_0_var(--media-current-shadow-color)]', /** Root settings view — slides out when a submenu is active. */ rootView, /** Submenu panel — slides in/out alongside the root view. */ submenuPanel, - back: cn(itemBase, 'mb-0.5 w-full font-medium'), - hint: 'ml-auto flex min-w-0 items-center gap-1 text-xs text-current/65', + back: cn(itemBase, 'mb-0.5 w-full'), + hint: 'ml-auto inline-flex min-w-0 items-center gap-1 pl-2 text-xs text-current/65', hintLabel: 'max-w-24 overflow-hidden text-ellipsis whitespace-nowrap', - chevron: 'size-3.5 first:-ml-1 last:-mr-1', + chevron: 'size-3.5', settingsGroup: 'group/settings', settingsTrigger: 'group hidden group-has-[[data-availability=available]]/settings:grid', settingsIcon: 'transition-transform duration-150 ease-in-out group-aria-expanded:rotate-90 motion-reduce:duration-0', diff --git a/packages/skins/src/default/tailwind/components/popup.ts b/packages/skins/src/default/tailwind/components/popup.ts index c56e6074..a650c8f5 100644 --- a/packages/skins/src/default/tailwind/components/popup.ts +++ b/packages/skins/src/default/tailwind/components/popup.ts @@ -2,16 +2,19 @@ import { cn } from '@videojs/utils/style'; const base = cn( // Reset default popover styles - 'm-0 border-0 text-inherit overflow-visible', + '[--media-popup-base-transition:opacity_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration),filter_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration),transform_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration),scale_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration)]', + '[--popup-translate-distance:0.5rem] m-0 border-0 text-inherit overflow-visible', // Animation - 'transition-[scale,opacity,filter_var(--popup-transition-property,)]', - 'duration-[var(--media-popup-transition-duration),var(--media-popup-transition-duration),var(--media-popup-transition-duration)_var(--popup-transition-duration,)]', - 'ease-(--media-popup-transition-timing-function)', - 'data-starting-style:opacity-0 data-starting-style:scale-50 data-starting-style:blur-sm', - 'data-ending-style:opacity-0 data-ending-style:scale-50 data-ending-style:blur-sm', - 'data-ending-style:duration-[max(0ms,calc(var(--media-popup-transition-duration)-50ms))]', + '[transition:var(--media-popup-transition,var(--media-popup-base-transition))]', + // We have to use transform here for translate as the translate property is used for positioning by core. + 'data-starting-style:opacity-0 data-starting-style:blur-xs data-starting-style:scale-95 data-starting-style:[transform:translate(var(--popup-translate-x-distance,0),var(--popup-translate-y-distance,0))]', + 'data-ending-style:opacity-0 data-ending-style:blur-xs data-ending-style:scale-95 data-ending-style:transform-none', + // Speed up the exit transition. + 'data-ending-style:[transition-duration:max(0ms,calc(var(--media-popup-transition-duration)-50ms))]', // Ensure we animate from the correct origin based on the side the popover is on 'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left', + 'data-[side=top]:[--popup-translate-y-distance:var(--popup-translate-distance)] data-[side=bottom]:[--popup-translate-y-distance:calc(var(--popup-translate-distance)*-1)]', + 'data-[side=left]:[--popup-translate-x-distance:var(--popup-translate-distance)] data-[side=right]:[--popup-translate-x-distance:calc(var(--popup-translate-distance)*-1)]', // Safe area between trigger and popup 'before:absolute before:pointer-events-[inherit]', 'data-[side=top]:before:left-0 data-[side=top]:before:right-0 data-[side=top]:before:top-full', @@ -29,8 +32,7 @@ export const popup = { tooltip: cn( base, 'py-1 px-2.5 rounded-full text-[0.75rem] whitespace-nowrap', - /* Flex only while open — unconditional `flex` overrides UA `[popover]` `display:none`. */ - 'data-[open]:flex data-[open]:items-center data-[open]:gap-1', + 'data-open:flex data-open:items-center data-open:gap-1', 'data-[side=top]:before:h-(--media-tooltip-side-offset) data-[side=bottom]:before:h-(--media-tooltip-side-offset)', 'data-[side=left]:before:w-(--media-tooltip-side-offset) data-[side=right]:before:w-(--media-tooltip-side-offset)' ), diff --git a/packages/skins/src/default/tailwind/components/root.ts b/packages/skins/src/default/tailwind/components/root.ts index d673b2d2..bc22c839 100644 --- a/packages/skins/src/default/tailwind/components/root.ts +++ b/packages/skins/src/default/tailwind/components/root.ts @@ -12,6 +12,10 @@ export const root = cn( 'outline-2 outline-transparent -outline-offset-4', 'transition-[outline-offset,outline-color] duration-100 ease-out', 'focus-visible:outline-current focus-visible:outline-offset-2', + // Scrollbars + 'scrollbar-thin scrollbar-thumb-current/30', + '[@media_(prefers-reduced-transparency:reduce)_or_(prefers-contrast:more)]:scrollbar-auto', + '[@media_(prefers-reduced-transparency:reduce)_or_(prefers-contrast:more)]:scrollbar-thumb-current/80', // Shadow color variables (derived from currentColor lightness) '[--media-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]', '[--media-current-shadow-color-subtle:oklch(from_var(--media-current-shadow-color)_l_c_h/calc(alpha*0.4))]', diff --git a/packages/skins/src/minimal/css/audio.css b/packages/skins/src/minimal/css/audio.css index 41bd6d68..057b9fbd 100644 --- a/packages/skins/src/minimal/css/audio.css +++ b/packages/skins/src/minimal/css/audio.css @@ -20,23 +20,23 @@ .media-minimal-skin--audio { --media-controls-background-color: oklch(1 0 0); --media-controls-backdrop-filter: blur(16px) saturate(1.5); - --media-controls-border-color: oklch(0 0 0 / 0.05); + --media-controls-border-color: oklch(0 0 0 / 0.1); --media-controls-text-color: var(--media-color-primary, oklch(0 0 0)); --media-error-dialog-transition-duration: 250ms; --media-error-dialog-transition-delay: 100ms; --media-popup-transition-duration: 100ms; --media-popup-transition-timing-function: ease-out; - --media-tooltip-background-color: oklch(1 0 0 / 0.1); - --media-tooltip-border-color: oklch(0 0 0 / 0.05); + --media-tooltip-background-color: oklch(1 0 0); + --media-tooltip-border-color: oklch(0 0 0 / 0.1); --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-background-color: oklch(1 0 0 / 0.1); - --media-popover-border-color: oklch(0 0 0 / 0.05); - --media-popover-backdrop-filter: blur(16px) saturate(1.5); - --media-popover-side-offset: 0.75rem; - --media-popover-boundary-offset: 0.75rem; + --media-popover-background-color: var(--media-tooltip-background-color); + --media-popover-border-color: var(--media-tooltip-border-color); + --media-popover-backdrop-filter: var(--media-tooltip-backdrop-filter); + --media-popover-side-offset: var(--media-tooltip-side-offset); + --media-popover-boundary-offset: var(--media-tooltip-boundary-offset); @media (prefers-reduced-motion: reduce) { --media-error-dialog-transition-duration: 50ms; diff --git a/packages/skins/src/minimal/css/components/menus.css b/packages/skins/src/minimal/css/components/menus.css index 5a6da061..1288af42 100644 --- a/packages/skins/src/minimal/css/components/menus.css +++ b/packages/skins/src/minimal/css/components/menus.css @@ -6,23 +6,22 @@ .media-minimal-skin .media-menu { --menu-transition-duration: 250ms; --menu-max-height: 14rem; - --popup-transition-property: , width, height; - --popup-transition-duration: , var(--menu-transition-duration), var(--menu-transition-duration); + --menu-padding: 0.5rem; + --menu-border-radius: 1rem; + --menu-item-border-radius: calc(var(--menu-border-radius) - var(--menu-padding)); box-sizing: border-box; - min-width: 6rem; max-width: var(--media-popover-available-width, none); max-height: min(var(--media-popover-available-height, var(--menu-max-height)), var(--menu-max-height)); - padding: 0.25rem; + padding: var(--menu-padding); overflow: auto; overscroll-behavior: none; background-color: var(--media-popover-background-color); - border-radius: 0.75rem; + border-radius: var(--menu-border-radius); box-shadow: 0 0 0 1px var(--media-popover-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-popover-backdrop-filter); - transition-timing-function: ease-out; @media (prefers-reduced-motion: reduce) { --menu-transition-duration: 0ms; @@ -31,7 +30,7 @@ & .media-menu__panel { position: absolute; inset: 0; - padding: 0.25rem; + padding: var(--menu-padding); overflow: auto; overscroll-behavior: none; outline: none; @@ -47,10 +46,13 @@ } /* Root settings view — slides out when a submenu is active */ - &[data-menu-root-view] { - &[data-menu-view-state="inactive"] { - filter: blur(8px); - translate: -100% 0; + &[data-menu-root-view][data-menu-view-state="inactive"] { + filter: blur(8px); + translate: -100% 0; + + /* Hide the indicator while transitioning */ + & .media-menu__group::before { + display: none; } } @@ -82,29 +84,42 @@ } & .media-menu__separator { - margin: 0.25rem -0.25rem; - border-bottom: 1px solid oklch(0 0 0 / 0.075); - box-shadow: 0 1px 0 0 oklch(1 0 0 / 0.05); + margin: 0.25rem 0; + border-bottom: 1px solid oklch(1 0 0 / 0.1); } & .media-menu__group { display: flex; flex-direction: column; gap: 0.125rem; + + @supports (top: anchor(top)) { + &::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: var(--menu-item-border-radius); + transition: inset ease-in-out 100ms; + } + } } & .media-menu__item, & .media-menu__back { display: flex; - gap: 0.5rem; + gap: 0.375rem; align-items: center; - padding: 0.375rem 0.625rem; + padding: 0.375rem 0.5rem; + text-align: left; text-shadow: 0 1px 0 var(--media-current-shadow-color); cursor: pointer; user-select: none; outline: 2px solid transparent; outline-offset: -2px; - border-radius: 0.5rem; + border-radius: var(--menu-item-border-radius); transition: background-color, color; transition-timing-function: ease-out; transition-duration: 100ms; @@ -117,18 +132,17 @@ &:hover, &[data-highlighted] { background-color: oklch(from currentColor l c h / 0.1); + + @supports (top: anchor(top)) { + anchor-name: --media-menu-item-highlight-anchor; + background-color: transparent; + } } - &[data-availability="unavailable"], - &[data-availability="unsupported"] { - display: none; - } - - & .media-menu__chevron:first-child { - margin-left: -0.25rem; - } - & .media-menu__chevron:last-child { - margin-right: -0.25rem; + & .media-icon { + flex-shrink: 0; + color: oklch(from currentColor l c h / 0.5); + filter: drop-shadow(0 1px 0 var(--media-current-shadow-color)); } } @@ -153,10 +167,15 @@ &[aria-checked="true"] .media-menu__indicator { opacity: 1; } + + &[data-availability="unavailable"], + &[data-availability="unsupported"] { + display: none; + } } & .media-menu__tier { - padding-top: 0.0625rem; + padding-top: 1px; padding-left: 0.125rem; font-size: 0.5625rem; font-weight: 600; @@ -167,7 +186,6 @@ & .media-menu__back { width: 100%; margin-bottom: 0.125rem; - font-weight: 500; } & .media-menu__hint { @@ -175,6 +193,7 @@ gap: 0.25rem; align-items: center; min-width: 0; + padding-left: 0.5rem; margin-left: auto; font-size: 0.75rem; color: oklch(from currentColor l c h / 0.65); @@ -195,8 +214,19 @@ /* Settings menu */ &.media-menu--settings { width: var(--media-menu-width); - min-width: 11rem; + min-width: 12rem; height: var(--media-menu-height); overflow: hidden; + /* Add height and width transitions. */ + transition: + var(--media-popup-transition), + height var(--media-popup-transition-timing-function) var(--menu-transition-duration), + width var(--media-popup-transition-timing-function) var(--menu-transition-duration); + + /* Don't transition width and height on open/close. */ + &[data-starting-style], + &[data-ending-style] { + transition: var(--media-popup-transition); + } } } diff --git a/packages/skins/src/minimal/css/components/popup.css b/packages/skins/src/minimal/css/components/popup.css index 846585b5..085912e7 100644 --- a/packages/skins/src/minimal/css/components/popup.css +++ b/packages/skins/src/minimal/css/components/popup.css @@ -2,43 +2,52 @@ Popups & Animations ========================================================================== */ +.media-minimal-skin { + --media-popup-transition: + opacity var(--media-popup-transition-timing-function) var(--media-popup-transition-duration), + filter var(--media-popup-transition-timing-function) var(--media-popup-transition-duration), + transform var(--media-popup-transition-timing-function) var(--media-popup-transition-duration), + scale var(--media-popup-transition-timing-function) var(--media-popup-transition-duration); +} + .media-minimal-skin .media-popover, .media-minimal-skin .media-tooltip { + --popup-translate-distance: 0.5rem; margin: 0; overflow: visible; color: inherit; border: 0; - transition-timing-function: var(--media-popup-transition-timing-function); - transition-duration: - var(--media-popup-transition-duration), - var(--media-popup-transition-duration), - var(--media-popup-transition-duration) var(--popup-transition-duration,); - transition-property: - scale, - opacity, - filter var(--popup-transition-property,); + transition: var(--media-popup-transition); &[data-starting-style], &[data-ending-style] { opacity: 0; - filter: blur(8px); - scale: 0.5; + filter: blur(4px); + /* We have to use transform here for translate as the translate property is used for positioning by core. */ + transform: translate(var(--popup-translate-x-distance, 0), var(--popup-translate-y-distance, 0)); + scale: 0.95; } &[data-ending-style] { + transform: none; + /* Speed up the exit transition. */ transition-duration: max(0ms, calc(var(--media-popup-transition-duration) - 50ms)); } &[data-side="top"] { + --popup-translate-y-distance: var(--popup-translate-distance); transform-origin: bottom; } &[data-side="bottom"] { + --popup-translate-y-distance: calc(var(--popup-translate-distance) * -1); transform-origin: top; } &[data-side="left"] { + --popup-translate-x-distance: var(--popup-translate-distance); transform-origin: right; } &[data-side="right"] { + --popup-translate-x-distance: calc(var(--popup-translate-distance) * -1); transform-origin: left; } @@ -94,8 +103,8 @@ border-radius: 0.5rem; 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); + 0 4px 6px -1px oklch(0 0 0 / 0.2), + 0 2px 4px -2px oklch(0 0 0 / 0.2); backdrop-filter: var(--media-tooltip-backdrop-filter); /* `display: flex` must not apply while closed — it overrides UA `[popover]` hiding. */ diff --git a/packages/skins/src/minimal/css/components/root.css b/packages/skins/src/minimal/css/components/root.css index 63a07044..22d703ed 100644 --- a/packages/skins/src/minimal/css/components/root.css +++ b/packages/skins/src/minimal/css/components/root.css @@ -6,6 +6,7 @@ --media-current-shadow-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15)); --media-current-shadow-color-subtle: oklch(from var(--media-current-shadow-color) l c h / calc(alpha * 0.4)); --media-icon-size: 18px; + --media-color-scrollbar-thumb: oklch(from currentColor l c h / 0.3); position: relative; display: block; width: 100%; @@ -24,6 +25,8 @@ letter-spacing: normal; outline: 2px solid transparent; outline-offset: -4px; + scrollbar-color: var(--media-color-scrollbar-thumb) transparent; + scrollbar-width: thin; border-radius: var(--media-border-radius, 0.75rem); isolation: isolate; transition-timing-function: ease-out; @@ -34,4 +37,14 @@ outline-color: currentColor; outline-offset: 2px; } + + &::-webkit-scrollbar-thumb { + background: var(--media-color-scrollbar-thumb); + border-radius: 9999px; + } + + @media (prefers-reduced-transparency: reduce) or (prefers-contrast: more) { + --media-color-scrollbar-thumb: oklch(from currentColor l c h / 0.8); + scrollbar-width: auto; + } } diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css index 89a845e8..b6aa65ab 100644 --- a/packages/skins/src/minimal/css/video.css +++ b/packages/skins/src/minimal/css/video.css @@ -36,17 +36,17 @@ --media-error-dialog-transition-timing-function: ease-out; --media-popup-transition-duration: 100ms; --media-popup-transition-timing-function: ease-out; - --media-tooltip-background-color: oklch(1 0 0 / 0.1); - --media-tooltip-border-color: transparent; + --media-tooltip-background-color: oklch(0 0 0 / 0.5); + --media-tooltip-border-color: oklch(1 0 0 / 0.1); --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-background-color: oklch(1 0 0 / 0.1); - --media-popover-border-color: transparent; - --media-popover-backdrop-filter: blur(16px) saturate(1.5); + --media-popover-background-color: var(--media-tooltip-background-color); + --media-popover-border-color: var(--media-tooltip-border-color); + --media-popover-backdrop-filter: var(--media-tooltip-backdrop-filter); --media-popover-side-offset: 1.5rem; - --media-popover-boundary-offset: 0.5rem; + --media-popover-boundary-offset: var(--media-tooltip-boundary-offset); overflow: clip; background: oklch(0 0 0); diff --git a/packages/skins/src/minimal/tailwind/audio.tailwind.ts b/packages/skins/src/minimal/tailwind/audio.tailwind.ts index 370bb555..337dbd86 100644 --- a/packages/skins/src/minimal/tailwind/audio.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/audio.tailwind.ts @@ -13,23 +13,23 @@ export const root = cn( baseRoot, '[--media-controls-background-color:oklch(1_0_0)]', '[--media-controls-backdrop-filter:blur(16px)_saturate(1.5)]', - '[--media-controls-border-color:oklch(0_0_0/0.05)]', + '[--media-controls-border-color:oklch(0_0_0/0.1)]', '[--media-controls-text-color:var(--media-color-primary,oklch(0_0_0))]', '[--media-error-dialog-transition-duration:250ms]', '[--media-error-dialog-transition-delay:100ms]', '[--media-popup-transition-duration:100ms]', '[--media-popup-transition-timing-function:ease-out]', - '[--media-tooltip-background-color:oklch(1_0_0/0.1)]', - '[--media-tooltip-border-color:oklch(0_0_0/0.05)]', + '[--media-tooltip-background-color:oklch(1_0_0)]', + '[--media-tooltip-border-color:oklch(0_0_0/0.1)]', '[--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-background-color:oklch(1_0_0/0.1)]', - '[--media-popover-border-color:oklch(0_0_0/0.05)]', - '[--media-popover-backdrop-filter:blur(16px)_saturate(1.5)]', - '[--media-popover-side-offset:0.75rem]', - '[--media-popover-boundary-offset:0.75rem]', + '[--media-popover-background-color:var(--media-tooltip-background-color)]', + '[--media-popover-border-color:var(--media-tooltip-border-color)]', + '[--media-popover-backdrop-filter:var(--media-tooltip-backdrop-filter)]', + '[--media-popover-side-offset:var(--media-tooltip-side-offset)]', + '[--media-popover-boundary-offset:var(--media-tooltip-boundary-offset)]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]', 'motion-reduce:[--media-error-dialog-transition-delay:0ms]', 'motion-reduce:[--media-popup-transition-duration:0ms]', diff --git a/packages/skins/src/minimal/tailwind/components/menu.ts b/packages/skins/src/minimal/tailwind/components/menu.ts index 23f6b2ab..6a9d9d53 100644 --- a/packages/skins/src/minimal/tailwind/components/menu.ts +++ b/packages/skins/src/minimal/tailwind/components/menu.ts @@ -3,13 +3,14 @@ import { cn } from '@videojs/utils/style'; import { popup } from './popup'; const panelBase = cn( - 'absolute inset-0 overflow-auto overscroll-none p-1 outline-none translate-none', + 'absolute inset-0 overflow-auto overscroll-none p-(--menu-padding) outline-none translate-none', 'data-starting-style:overflow-hidden data-ending-style:overflow-hidden', - 'transition-[translate,filter] duration-(--menu-transition-duration) ease-out will-change-[translate,filter]' + 'transition-[translate,filter] duration-(--menu-transition-duration) ease-in-out will-change-[translate,filter]' ); const rootView = cn( panelBase, + 'group/menu-root-view', 'data-[menu-view-state=inactive]:-translate-x-full data-[menu-view-state=inactive]:blur' ); @@ -27,53 +28,80 @@ const submenuPanel = cn( ); const itemBase = cn( - 'flex cursor-pointer select-none items-center gap-2 rounded-lg py-1.5 px-2.5', + 'flex cursor-pointer select-none items-center gap-1.5 rounded-(--menu-item-border-radius) py-1.5 px-2', + 'text-left', 'text-shadow-2xs text-shadow-(color:--media-current-shadow-color)', 'outline-2 -outline-offset-2 outline-transparent', 'transition-colors duration-100 ease-out', 'hover:bg-current/10 data-highlighted:bg-current/10', + 'supports-[top:anchor(top)]:hover:[anchor-name:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:hover:bg-transparent', + 'supports-[top:anchor(top)]:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:data-highlighted:bg-transparent', 'focus-visible:outline-current focus-visible:outline-offset-2' ); const menuTokens = cn( - '[--menu-transition-duration:250ms] [--menu-max-height:14rem]', - '[--popup-transition-property:,_width,_height] [--popup-transition-duration:,_var(--menu-transition-duration),_var(--menu-transition-duration)]', + '[--menu-transition-duration:250ms] [--menu-max-height:14rem] [--menu-padding:0.5rem]', + '[--menu-border-radius:1rem] [--menu-item-border-radius:calc(var(--menu-border-radius)_-_var(--menu-padding))]', 'motion-reduce:[--menu-transition-duration:0ms]' ); +const group = cn( + 'flex flex-col gap-0.5', + 'supports-[top:anchor(top)]:before:absolute', + 'supports-[top:anchor(top)]:before:[position-anchor:--media-menu-item-highlight-anchor]', + 'supports-[top:anchor(top)]:before:[inset:anchor(inside)]', + 'supports-[top:anchor(top)]:before:pointer-events-none', + 'supports-[top:anchor(top)]:before:bg-current/10', + 'supports-[top:anchor(top)]:before:rounded-(--menu-item-border-radius)', + 'supports-[top:anchor(top)]:before:transition-[inset]', + 'supports-[top:anchor(top)]:before:duration-100', + 'supports-[top:anchor(top)]:before:ease-in-out', + 'group-data-[menu-view-state=inactive]/menu-root-view:before:hidden' +); + const menuHostShell = cn( popup.popover, menuTokens, 'max-w-(--media-popover-available-width,none) max-h-[min(var(--media-popover-available-height,var(--menu-max-height)),var(--menu-max-height))]', 'bg-(--media-popover-background-color) [backdrop-filter:var(--media-popover-backdrop-filter)]', 'shadow-[0_0_0_1px_var(--media-popover-border-color),0_4px_6px_-1px_oklch(0_0_0/0.1),0_2px_4px_-2px_oklch(0_0_0/0.1)]', - 'box-border rounded-xl p-1 overscroll-none' + 'box-border rounded-(--menu-border-radius) p-(--menu-padding) overscroll-none' ); export const menu = { /** Standalone menu popover host (audio playback rate, sandbox demos). */ - root: cn(menuHostShell, 'min-w-24 !overflow-auto'), + root: cn(menuHostShell, 'overflow-auto!'), /** Settings menu viewport host with nested submenu navigation. */ - settings: cn(menuHostShell, 'min-w-44 w-(--media-menu-width) h-(--media-menu-height)', '!overflow-hidden'), - group: 'flex flex-col gap-0.5', + settings: cn( + menuHostShell, + // Add height and width transitions. + '[--media-popup-transition:var(--media-popup-base-transition),height_var(--media-popup-transition-timing-function)_var(--menu-transition-duration),width_var(--media-popup-transition-timing-function)_var(--menu-transition-duration)]', + // Don't transition width and height on open/close. + 'data-starting-style:[--media-popup-transition:var(--media-popup-base-transition)] data-ending-style:[--media-popup-transition:var(--media-popup-base-transition)]', + 'min-w-48 w-(--media-menu-width) h-(--media-menu-height)', + '!overflow-hidden' + ), + group, item: cn( itemBase, 'group/menu-item justify-between tabular-nums text-inherit', 'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden', 'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50' ), - separator: 'my-1 -mx-1 border-b border-[oklch(0_0_0/0.075)] shadow-[0_1px_0_0_oklch(1_0_0/0.05)]', - tier: 'self-start -ml-1 pt-px text-[0.5625rem] font-semibold leading-none text-current/70', - badge: 'ml-auto', - indicator: '-mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', + separator: 'my-1 border-b border-[oklch(1_0_0/0.1)]', + tier: 'pl-0.5 pt-px text-[0.5625rem] font-semibold leading-none text-current/70', + indicator: 'ml-auto -mr-1 shrink-0 opacity-0 group-aria-checked/menu-item:opacity-100', + icon: 'shrink-0 text-current/50 drop-shadow-[0_1px_0_var(--media-current-shadow-color)]', /** Root settings view — slides out when a submenu is active. */ rootView, /** Submenu panel — slides in/out alongside the root view. */ submenuPanel, - back: cn(itemBase, 'mb-0.5 w-full font-medium'), - hint: 'ml-auto flex min-w-0 items-center gap-1 text-xs text-current/65', + back: cn(itemBase, 'mb-0.5 w-full'), + hint: 'ml-auto inline-flex min-w-0 items-center gap-1 pl-2 text-xs text-current/65', hintLabel: 'max-w-24 overflow-hidden text-ellipsis whitespace-nowrap', - chevron: 'size-3.5 first:-ml-1 last:-mr-1', + chevron: 'size-3.5', settingsGroup: 'group/settings', settingsTrigger: 'group hidden group-has-[[data-availability=available]]/settings:grid', settingsIcon: 'transition-transform duration-150 ease-in-out group-aria-expanded:rotate-90 motion-reduce:duration-0', diff --git a/packages/skins/src/minimal/tailwind/components/popup.ts b/packages/skins/src/minimal/tailwind/components/popup.ts index 784707bf..605562e3 100644 --- a/packages/skins/src/minimal/tailwind/components/popup.ts +++ b/packages/skins/src/minimal/tailwind/components/popup.ts @@ -2,16 +2,19 @@ import { cn } from '@videojs/utils/style'; const base = cn( // Reset default popover styles - 'm-0 border-0 text-inherit overflow-visible', + '[--media-popup-base-transition:opacity_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration),filter_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration),transform_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration),scale_var(--media-popup-transition-timing-function)_var(--media-popup-transition-duration)]', + '[--popup-translate-distance:0.5rem] m-0 border-0 text-inherit overflow-visible', // Animation - 'transition-[scale,opacity,filter_var(--popup-transition-property,)]', - 'duration-[var(--media-popup-transition-duration),var(--media-popup-transition-duration),var(--media-popup-transition-duration)_var(--popup-transition-duration,)]', - 'ease-(--media-popup-transition-timing-function)', - 'data-starting-style:opacity-0 data-starting-style:scale-50 data-starting-style:blur-sm', - 'data-ending-style:opacity-0 data-ending-style:scale-50 data-ending-style:blur-sm', - 'data-ending-style:duration-[max(0ms,calc(var(--media-popup-transition-duration)-50ms))]', + '[transition:var(--media-popup-transition,var(--media-popup-base-transition))]', + // We have to use transform here for translate as the translate property is used for positioning by core. + 'data-starting-style:opacity-0 data-starting-style:blur-xs data-starting-style:scale-95 data-starting-style:[transform:translate(var(--popup-translate-x-distance,0),var(--popup-translate-y-distance,0))]', + 'data-ending-style:opacity-0 data-ending-style:blur-xs data-ending-style:scale-95 data-ending-style:transform-none', + // Speed up the exit transition. + 'data-ending-style:[transition-duration:max(0ms,calc(var(--media-popup-transition-duration)-50ms))]', // Ensure we animate from the correct origin based on the side the popover is on 'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left', + 'data-[side=top]:[--popup-translate-y-distance:var(--popup-translate-distance)] data-[side=bottom]:[--popup-translate-y-distance:calc(var(--popup-translate-distance)*-1)]', + 'data-[side=left]:[--popup-translate-x-distance:var(--popup-translate-distance)] data-[side=right]:[--popup-translate-x-distance:calc(var(--popup-translate-distance)*-1)]', // Safe area between trigger and popup 'before:absolute before:pointer-events-[inherit]', 'data-[side=top]:before:left-0 data-[side=top]:before:right-0 data-[side=top]:before:top-full', @@ -30,9 +33,9 @@ export const popup = { tooltip: cn( base, 'px-2 py-1 rounded-lg text-[0.75rem] whitespace-nowrap', - 'data-[open]:flex data-[open]:items-center data-[open]:gap-1', + 'data-open:flex data-open:items-center data-open:gap-1', 'bg-(--media-tooltip-background-color) [backdrop-filter:var(--media-tooltip-backdrop-filter)]', - 'ring-1 ring-(color:--media-tooltip-border-color) shadow-md shadow-black/10', + 'ring-1 ring-(color:--media-tooltip-border-color) shadow-md shadow-black/20', 'text-(--media-tooltip-text-color)', 'data-[side=top]:before:h-(--media-tooltip-side-offset) data-[side=bottom]:before:h-(--media-tooltip-side-offset)', 'data-[side=left]:before:w-(--media-tooltip-side-offset) data-[side=right]:before:w-(--media-tooltip-side-offset)' diff --git a/packages/skins/src/minimal/tailwind/components/root.ts b/packages/skins/src/minimal/tailwind/components/root.ts index 3202f9c6..9e5a2249 100644 --- a/packages/skins/src/minimal/tailwind/components/root.ts +++ b/packages/skins/src/minimal/tailwind/components/root.ts @@ -12,6 +12,10 @@ export const root = cn( 'outline-2 outline-transparent -outline-offset-4', 'transition-[outline-offset,outline-color] duration-100 ease-out', 'focus-visible:outline-current focus-visible:outline-offset-2', + // Scrollbars + 'scrollbar-thin scrollbar-thumb-current/30', + '[@media_(prefers-reduced-transparency:reduce)_or_(prefers-contrast:more)]:scrollbar-auto', + '[@media_(prefers-reduced-transparency:reduce)_or_(prefers-contrast:more)]:scrollbar-thumb-current/80', // Shadow color variables (derived from currentColor lightness) '[--media-current-shadow-color:oklch(from_currentColor_0_0_0/clamp(0,calc((l-0.5)*0.5),0.15))]', '[--media-current-shadow-color-subtle:oklch(from_var(--media-current-shadow-color)_l_c_h/calc(alpha*0.4))]', diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts index c7e8e32a..f01b84a0 100644 --- a/packages/skins/src/minimal/tailwind/video.tailwind.ts +++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts @@ -36,17 +36,17 @@ export const root = (isShadowDOM: boolean) => '[--media-error-dialog-transition-timing-function:ease-out]', '[--media-popup-transition-duration:100ms]', '[--media-popup-transition-timing-function:ease-out]', - '[--media-tooltip-background-color:oklch(1_0_0/0.1)]', - '[--media-tooltip-border-color:transparent]', + '[--media-tooltip-background-color:oklch(0_0_0/0.5)]', + '[--media-tooltip-border-color:oklch(1_0_0/0.1)]', '[--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-background-color:oklch(1_0_0/0.1)]', - '[--media-popover-border-color:transparent]', - '[--media-popover-backdrop-filter:blur(16px)_saturate(1.5)]', + '[--media-popover-background-color:var(--media-tooltip-background-color)]', + '[--media-popover-border-color:var(--media-tooltip-border-color)]', + '[--media-popover-backdrop-filter:var(--media-tooltip-backdrop-filter)]', '[--media-popover-side-offset:1.5rem]', - '[--media-popover-boundary-offset:0.5rem]', + '[--media-popover-boundary-offset:var(--media-tooltip-boundary-offset)]', 'motion-reduce:[--media-error-dialog-transition-duration:50ms]', 'motion-reduce:[--media-error-dialog-transition-delay:0ms]', 'motion-reduce:[--media-popup-transition-duration:0ms]',