Skip to content

Commit

Permalink
feat(player): new menuContainer default layout prop
Browse files Browse the repository at this point in the history
closes #1301
  • Loading branch information
mihar-22 committed Jun 19, 2024
1 parent 5d5ae75 commit 0efc998
Show file tree
Hide file tree
Showing 15 changed files with 136 additions and 67 deletions.
1 change: 0 additions & 1 deletion packages/react/src/components/layouts/default/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export const DefaultLayoutContext = React.createContext<DefaultLayoutContext>({}
DefaultLayoutContext.displayName = 'DefaultLayoutContext';

interface DefaultLayoutContext extends DefaultLayoutProps {
menuContainer?: React.RefObject<HTMLElement | null>;
isSmallLayout: boolean;
userPrefersAnnouncements: WriteSignal<boolean>;
userPrefersKeyboardAnimations: WriteSignal<boolean>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export interface DefaultLayoutProps<Slots = unknown> extends PrimitivePropsWithR
* Translation map from english to your desired language for words used throughout the layout.
*/
translations?: Partial<DefaultLayoutTranslations> | null;
/**
* A document query selector string or `HTMLElement` to mount menus inside.
*
* @defaultValue `document.body`
*/
menuContainer?: string | HTMLElement | null;
/**
* Specifies whether menu buttons should be placed in the top or bottom controls group. This
* only applies to the large video layout.
Expand Down Expand Up @@ -153,6 +159,7 @@ export function createDefaultMediaLayout({
icons,
colorScheme = 'system',
download = null,
menuContainer = null,
menuGroup = 'bottom',
noAudioGain = false,
audioGains = { min: 0, max: 300, step: 25 },
Expand Down Expand Up @@ -216,6 +223,7 @@ export function createDefaultMediaLayout({
colorScheme,
download,
isSmallLayout,
menuContainer,
menuGroup,
noAudioGain,
audioGains,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function DefaultChaptersMenu({ tooltip, placement, portalClass = '' }: DefaultMe
isSmallLayout,
icons: Icons,
menuGroup,
menuContainer,
colorScheme,
} = useDefaultLayoutContext(),
chaptersText = useDefaultLayoutWord('Chapters'),
Expand Down Expand Up @@ -111,6 +112,7 @@ function DefaultChaptersMenu({ tooltip, placement, portalClass = '' }: DefaultMe
Content
) : (
<Menu.Portal
container={menuContainer}
className={portalClass + (colorSchemeClass ? ` ${colorSchemeClass}` : '')}
disabled="fullscreen"
data-sm={isSmallLayout ? '' : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function DefaultSettingsMenu({
showMenuDelay,
icons: Icons,
isSmallLayout,
menuContainer,
menuGroup,
noModal,
colorScheme,
Expand Down Expand Up @@ -93,6 +94,7 @@ function DefaultSettingsMenu({
) : (
<Menu.Portal
className={portalClass + (colorSchemeClass ? ` ${colorSchemeClass}` : '')}
container={menuContainer}
disabled="fullscreen"
data-sm={isSmallLayout ? '' : null}
data-lg={!isSmallLayout ? '' : null}
Expand Down
18 changes: 14 additions & 4 deletions packages/react/src/components/ui/menu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';

import { composeRefs, createReactComponent, type ReactElementProps } from 'maverick.js/react';
import { isString } from 'maverick.js/std';
import { createPortal } from 'react-dom';

import { IS_SERVER } from '../../env';
Expand Down Expand Up @@ -109,14 +110,14 @@ Button.displayName = 'MenuButton';
* Portal
* -----------------------------------------------------------------------------------------------*/

export interface PortalProps extends Omit<ReactElementProps<MenuPortalInstance>, 'container'> {
export interface PortalProps extends ReactElementProps<MenuPortalInstance> {
asChild?: boolean;
children?: React.ReactNode;
ref?: React.Ref<HTMLElement>;
}

/**
* Portals menu items into the document body.
* Portals menu items into the given container.
*
* @docs {@link https://www.vidstack.io/docs/player/components/menu#portal}
* @example
Expand All @@ -130,9 +131,18 @@ export interface PortalProps extends Omit<ReactElementProps<MenuPortalInstance>,
* ```
*/
const Portal = React.forwardRef<HTMLElement, PortalProps>(
({ disabled = false, children, ...props }, forwardRef) => {
({ container = null, disabled = false, children, ...props }, forwardRef) => {
let fullscreen = useMediaState('fullscreen'),
shouldPortal = disabled === 'fullscreen' ? !fullscreen : !disabled;

const target = React.useMemo(() => {
if (IS_SERVER) return null;

const node = isString(container) ? document.querySelector(container) : container;

return node ?? document.body;
}, [container]);

return IS_SERVER || !shouldPortal
? children
: createPortal(
Expand All @@ -143,7 +153,7 @@ const Portal = React.forwardRef<HTMLElement, PortalProps>(
>
{children}
</Primitive.div>,
document.body,
target,
);
},
);
Expand Down
4 changes: 3 additions & 1 deletion packages/vidstack/mangle.json
Original file line number Diff line number Diff line change
Expand Up @@ -782,5 +782,7 @@
"_when": "cn",
"_calcJumpValue": "Cn",
"_calcNewKeyValue": "Dn",
"_repeatedKeys": "Bn"
"_repeatedKeys": "Bn",
"_setupMenuContainer": "En",
"_setupWatchScrubbing": "Fn"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createContext, useContext, type ReadSignalRecord, type WriteSignal } fr
import type { DefaultLayoutProps } from './props';

export interface DefaultLayoutContext extends ReadSignalRecord<DefaultLayoutProps> {
menuContainer: HTMLElement | null;
menuPortal: WriteSignal<HTMLElement | null>;
userPrefersAnnouncements: WriteSignal<boolean>;
userPrefersKeyboardAnimations: WriteSignal<boolean>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ export class DefaultLayout extends Component<DefaultLayoutProps> {
return this._matches(when);
});

@prop
menuContainer: HTMLElement | null = null;

@prop
get isMatch() {
return this._when();
Expand Down Expand Up @@ -53,9 +50,7 @@ export class DefaultLayout extends Component<DefaultLayoutProps> {
smallWhen: this._smallWhen,
userPrefersAnnouncements: signal(true),
userPrefersKeyboardAnimations: signal(true),
get menuContainer() {
return self.menuContainer;
},
menuPortal: signal<HTMLElement | null>(null),
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/vidstack/src/components/layouts/default/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const defaultLayoutProps: DefaultLayoutProps = {
download: null,
customIcons: false,
disableTimeSlider: false,
menuContainer: null,
menuGroup: 'bottom',
noAudioGain: false,
noGestures: false,
Expand Down Expand Up @@ -60,6 +61,11 @@ export interface DefaultLayoutProps {
* Translation map from english to your desired language for words used throughout the layout.
*/
translations: Partial<DefaultLayoutTranslations> | null;
/**
* A document query selector string or `HTMLElement` to mount the menu container inside. Defaults
* to `document.body` when set to `null`.
*/
menuContainer: string | HTMLElement | null;
/**
* Specifies whether menu buttons should be placed in the top or bottom controls group. This
* only applies to the large video layout.
Expand Down
4 changes: 2 additions & 2 deletions packages/vidstack/src/components/ui/menu/menu-portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ export class MenuPortal extends Component<MenuPortalProps> {
export interface MenuPortalProps {
/**
* Specifies a DOM element or query selector for the container that the menu should be portalled
* inside.
* inside. Defaults to `document.body` when set to `null`.
*/
container: HTMLElement | string | null;
container: string | HTMLElement | null;
/**
* Whether the portal should be disabled. The value can be the string "fullscreen" to disable
* portals while media is fullscreen. This is to ensure the menu remains visible.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { html } from 'lit-html';
import { effect, onDispose, signal } from 'maverick.js';
import { effect, signal } from 'maverick.js';
import { Host, type Attributes } from 'maverick.js/element';
import { listenEvent } from 'maverick.js/std';

import { DefaultAudioLayout } from '../../../../components/layouts/default/audio-layout';
import { useDefaultLayoutContext } from '../../../../components/layouts/default/context';
import type { DefaultLayoutProps } from '../../../../components/layouts/default/props';
import type { MediaContext } from '../../../../core';
import { useMediaContext } from '../../../../core/api/media-context';
Expand Down Expand Up @@ -50,28 +50,13 @@ export class MediaAudioLayoutElement
this._media = useMediaContext();

this.classList.add('vds-audio-layout');
this.menuContainer = createMenuContainer('vds-audio-layout', () => this.isSmallLayout);

const { pointer } = this._media.$state;
effect(() => {
if (pointer() !== 'coarse') return;
effect(this._watchScrubbing.bind(this));
});

onDispose(() => this.menuContainer?.remove());
this._setupWatchScrubbing();
}

protected onConnect() {
setLayoutName('audio', () => this.isMatch);

effect(() => {
const roots = this.menuContainer ? [this, this.menuContainer] : [this];
if (this.$props.customIcons()) {
new SlotManager(roots).connect();
} else {
new DefaultLayoutIconsLoader(roots).connect();
}
});
this._setupMenuContainer();
}

render() {
Expand All @@ -82,6 +67,42 @@ export class MediaAudioLayoutElement
return this.isMatch ? Layout() : null;
}

private _setupMenuContainer() {
const { menuPortal } = useDefaultLayoutContext();

effect(() => {
if (!this.isMatch) return;

const container = createMenuContainer(
this.menuContainer,
'vds-audio-layout',
() => this.isSmallLayout,
),
roots = container ? [this, container] : [this];

const iconsManager = this.$props.customIcons()
? new SlotManager(roots)
: new DefaultLayoutIconsLoader(roots);

iconsManager.connect();

menuPortal.set(container);

return () => {
container.remove();
menuPortal.set(null);
};
});
}

private _setupWatchScrubbing() {
const { pointer } = this._media.$state;
effect(() => {
if (pointer() !== 'coarse') return;
effect(this._watchScrubbing.bind(this));
});
}

private _watchScrubbing() {
if (!this._scrubbing()) {
listenEvent(this, 'pointerdown', this._onStartScrubbing.bind(this), { capture: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function DefaultChaptersMenu({
{
translations,
thumbnails,
menuContainer,
menuPortal,
noModal,
menuGroup,
smallWhen: smWhen,
Expand Down Expand Up @@ -110,7 +110,7 @@ export function DefaultChaptersMenu({
${$i18n(translations, 'Chapters')}
</media-tooltip-content>
</media-tooltip>
${portal ? MenuPortal(menuContainer, items) : items}
${portal ? MenuPortal(menuPortal, items) : items}
</media-menu>
`;
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,49 @@
import { html, type TemplateResult } from 'lit-html';
import { effect, type ReadSignal } from 'maverick.js';
import { setAttribute } from 'maverick.js/std';
import { isString, setAttribute } from 'maverick.js/std';

import { useDefaultLayoutContext } from '../../../../../../components/layouts/default/context';
import { useMediaState } from '../../../../../../core/api/media-context';
import { watchColorScheme } from '../../../../../../utils/dom';
import { $signal } from '../../../../../lit/directives/signal';

export function MenuPortal(container: HTMLElement | null, template: TemplateResult) {
export function MenuPortal(
container: ReadSignal<string | HTMLElement | null>,
template: TemplateResult,
) {
return html`
<media-menu-portal .container=${container} disabled="fullscreen">
<media-menu-portal .container=${$signal(container)} disabled="fullscreen">
${template}
</media-menu-portal>
`;
}

export function createMenuContainer(className: string, isSmallLayout: ReadSignal<boolean>) {
let container = document.querySelector<HTMLElement>(`body > .${className}`);
export function createMenuContainer(
rootSelector: string | HTMLElement | null,
className: string,
isSmallLayout: ReadSignal<boolean>,
) {
let root = isString(rootSelector) ? document.querySelector(rootSelector) : rootSelector;
if (!root) root = document.body;

if (!container) {
container = document.createElement('div');
container.style.display = 'contents';
container.classList.add(className);
document.body.append(container);
}

const { viewType } = useMediaState(),
{ colorScheme } = useDefaultLayoutContext();
const container = document.createElement('div');
container.style.display = 'contents';
container.classList.add(className);
root.append(container);

effect(() => {
if (!container) return;

const isSmall = isSmallLayout();
const { viewType } = useMediaState(),
isSmall = isSmallLayout();

setAttribute(container, 'data-view-type', viewType());
setAttribute(container, 'data-sm', isSmall);
setAttribute(container, 'data-lg', !isSmall);
setAttribute(container, 'data-size', isSmall ? 'sm' : 'lg');
});

const { colorScheme } = useDefaultLayoutContext();
watchColorScheme(container, colorScheme);

return container;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function DefaultSettingsMenu({
const { viewType } = useMediaState(),
{
translations,
menuContainer,
menuPortal,
noModal,
menuGroup,
smallWhen: smWhen,
Expand Down Expand Up @@ -91,7 +91,7 @@ export function DefaultSettingsMenu({
${$i18n(translations, 'Settings')}
</media-tooltip-content>
</media-tooltip>
${portal ? MenuPortal(menuContainer, items) : items}
${portal ? MenuPortal(menuPortal, items) : items}
</media-menu>
`;
});
Expand Down
Loading

0 comments on commit 0efc998

Please sign in to comment.