Skip to content

Commit

Permalink
Merge pull request #55 from THEOplayer/slot-container
Browse files Browse the repository at this point in the history
Fix `topChrome` not auto-hiding in React custom UIs
  • Loading branch information
MattiasBuelens authored Mar 18, 2024
2 parents e3b03ea + c665af8 commit 21c8622
Show file tree
Hide file tree
Showing 15 changed files with 123 additions and 53 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
> - 🏠 Internal
> - 💅 Polish
## Unreleased

- 🚀 Added `<theoplayer-slot-container>`. ([#55](https://github.com/THEOplayer/web-ui/pull/55))

## v1.7.1 (2024-02-15)

- 💅 Export `version` in public API. ([#53](https://github.com/THEOplayer/web-ui/pull/53))
Expand Down
6 changes: 6 additions & 0 deletions react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
> - 🏠 Internal
> - 💅 Polish
## Unreleased

- 🐛 Fixed `topChrome`, `middleChrome` and `centeredChrome` slots not auto-hiding in `<UIContainer>`. ([#55](https://github.com/THEOplayer/web-ui/pull/55))
- 🐛 Fixed `no-auto-hide` attribute not working for React components. ([#55](https://github.com/THEOplayer/web-ui/pull/55))
- 🚀 Added `<SlotContainer>`. ([#55](https://github.com/THEOplayer/web-ui/pull/55))

## v1.7.1 (2024-02-15)

- 🐛 Fix "Warning: useLayoutEffect does nothing on the server" when using `@theoplayer/react-ui` in Node. ([#52](https://github.com/THEOplayer/web-ui/pull/52))
Expand Down
11 changes: 5 additions & 6 deletions react/src/DefaultUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import type { ChromelessPlayer } from 'theoplayer/chromeless';
import { createComponent, type WebComponentProps } from '@lit/react';
import { usePlayer } from './util';
import { PlayerContext } from './context';
import { Slotted, SlottedInPlace } from './slotted';
import type { Menu } from './components';
import { type Menu, SlotContainer } from './components';

const RawDefaultUI = createComponent({
tagName: 'theoplayer-default-ui',
Expand Down Expand Up @@ -93,10 +92,10 @@ export const DefaultUI = (props: DefaultUIProps) => {
return (
<RawDefaultUI {...otherProps} ref={setUi}>
<PlayerContext.Provider value={player}>
<Slotted slot="title">{title}</Slotted>
<Slotted slot="top-control-bar">{topControlBar}</Slotted>
<Slotted slot="bottom-control-bar">{bottomControlBar}</Slotted>
<SlottedInPlace slot="menu">{menu}</SlottedInPlace>
{title && <SlotContainer slot="title">{title}</SlotContainer>}
{topControlBar && <SlotContainer slot="top-control-bar">{topControlBar}</SlotContainer>}
{bottomControlBar && <SlotContainer slot="bottom-control-bar">{bottomControlBar}</SlotContainer>}
{menu && <SlotContainer slot="menu">{menu}</SlotContainer>}
</PlayerContext.Provider>
</RawDefaultUI>
);
Expand Down
15 changes: 7 additions & 8 deletions react/src/UIContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import type { ChromelessPlayer } from 'theoplayer/chromeless';
import { createComponent, type WebComponentProps } from '@lit/react';
import { usePlayer } from './util';
import { PlayerContext } from './context';
import { Slotted, SlottedInPlace } from './slotted';
import type { ChromecastButton, ErrorDisplay, Menu, PlayButton, TimeRange } from './components';
import { type ChromecastButton, type ErrorDisplay, type Menu, type PlayButton, SlotContainer, type TimeRange } from './components';

const RawUIContainer = createComponent({
tagName: 'theoplayer-ui',
Expand Down Expand Up @@ -134,13 +133,13 @@ export const UIContainer = (props: UIContainerProps) => {
return (
<RawUIContainer {...otherProps} ref={setUi}>
<PlayerContext.Provider value={player}>
<Slotted slot="top-chrome">{topChrome}</Slotted>
<Slotted slot="middle-chrome">{middleChrome}</Slotted>
<Slotted slot="centered-chrome">{centeredChrome}</Slotted>
<Slotted slot="centered-loading">{centeredLoading}</Slotted>
{topChrome && <SlotContainer slot="top-chrome">{topChrome}</SlotContainer>}
{middleChrome && <SlotContainer slot="middle-chrome">{middleChrome}</SlotContainer>}
{centeredChrome && <SlotContainer slot="centered-chrome">{centeredChrome}</SlotContainer>}
{centeredLoading && <SlotContainer slot="centered-loading">{centeredLoading}</SlotContainer>}
{bottomChrome}
<SlottedInPlace slot="menu">{menu}</SlottedInPlace>
<Slotted slot="error">{error}</Slotted>
{menu && <SlotContainer slot="menu">{menu}</SlotContainer>}
{error && <SlotContainer slot="error">{error}</SlotContainer>}
</PlayerContext.Provider>
</RawUIContainer>
);
Expand Down
16 changes: 16 additions & 0 deletions react/src/components/SlotContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createComponent } from '@lit/react';
import { SlotContainer as SlotContainerElement } from '@theoplayer/web-ui';
import * as React from 'react';

/**
* See {@link @theoplayer/web-ui!SlotContainer | SlotContainer in @theoplayer/web-ui}.
*
* @group Components
* @internal
*/
export const SlotContainer = createComponent({
tagName: 'theoplayer-slot-container',
displayName: 'SlotContainer',
elementClass: SlotContainerElement,
react: React
});
1 change: 1 addition & 0 deletions react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ export * from './GestureReceiver';
export * from './PreviewTimeDisplay';
export * from './PreviewThumbnail';
export * from './LiveButton';
export * from './SlotContainer';
export * from './ads/index';
29 changes: 1 addition & 28 deletions react/src/slotted.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import * as React from 'react';
import { Children, cloneElement, Fragment, isValidElement, type ReactNode } from 'react';
import { type ReactNode } from 'react';

export interface SlottedProps {
slot: string;
children?: ReactNode;
}

/**
* A component that puts its children inside a specific slot of a custom element.
*/
Expand All @@ -18,29 +17,3 @@ export const Slotted = ({ slot, children }: SlottedProps) => {
<div slot={slot} style={{ display: 'contents' }} children={children} />
);
};

/**
* A component that puts its children inside a specific slot of a custom element,
* by adding a `slot` property directly to each child.
*
* This should be used with caution! If a component is wrapped in another component that doesn't forward all props
* (such as a `<Context.Consumer>`), then the slot property might not end up at the desired child.
*/
export const SlottedInPlace = ({ slot, children }: SlottedProps): ReactNode => {
if (!children) {
return null;
}
return Children.map(children, (child) => cloneWithSlot(slot, child));
};

function cloneWithSlot<T extends ReactNode>(slot: string, child: T): ReactNode {
if (isValidElement(child)) {
if (child.type === Fragment) {
return cloneElement(child, undefined, <SlottedInPlace slot={slot} children={child.props.children} />);
} else {
return cloneElement(child, { slot });
}
} else {
return <Slotted slot={slot} children={child} />;
}
}
6 changes: 3 additions & 3 deletions react/test/ssr.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ describe('Server-side rendering (SSR)', () => {
);
const expected =
'<theoplayer-default-ui>' +
'<div slot="top-control-bar" style="display:contents"><theoplayer-play-button></theoplayer-play-button></div>' +
'<div slot="bottom-control-bar" style="display:contents"><theoplayer-time-range></theoplayer-time-range></div>' +
'<theoplayer-slot-container slot="top-control-bar"><theoplayer-play-button></theoplayer-play-button></theoplayer-slot-container>' +
'<theoplayer-slot-container slot="bottom-control-bar"><theoplayer-time-range></theoplayer-time-range></theoplayer-slot-container>' +
'</theoplayer-default-ui>';
assert.equal(actual, expected);
});
Expand All @@ -43,7 +43,7 @@ describe('Server-side rendering (SSR)', () => {
);
const expected =
'<theoplayer-ui>' +
'<div slot="top-chrome" style="display:contents"><theoplayer-play-button></theoplayer-play-button></div>' +
'<theoplayer-slot-container slot="top-chrome"><theoplayer-play-button></theoplayer-play-button></theoplayer-slot-container>' +
'<theoplayer-time-range></theoplayer-time-range>' +
'</theoplayer-ui>';
assert.equal(actual, expected);
Expand Down
3 changes: 2 additions & 1 deletion src/UIContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
arrayRemove,
containsComposedNode,
getFocusableChildren,
getSlottedElements,
getTvFocusChildren,
isElement,
isHTMLElement,
Expand Down Expand Up @@ -1075,7 +1076,7 @@ declare global {

function getVisibleRect(slot: HTMLSlotElement): Rectangle | undefined {
let result: Rectangle | undefined;
const children = slot.assignedNodes().filter(isHTMLElement);
const children = getSlottedElements(slot).filter(isHTMLElement);
for (const child of children) {
if (getComputedStyle(child).opacity !== '0') {
const childRect = Rectangle.fromRect(child.getBoundingClientRect());
Expand Down
16 changes: 11 additions & 5 deletions src/components/MenuGroup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import * as shadyCss from '@webcomponents/shadycss';
import menuGroupCss from './MenuGroup.css';
import { Attribute } from '../util/Attribute';
import { arrayFind, arrayFindIndex, fromArrayLike, isElement, isHTMLElement, noOp, upgradeCustomElementIfNeeded } from '../util/CommonUtils';
import {
arrayFind,
arrayFindIndex,
fromArrayLike,
getSlottedElements,
isElement,
isHTMLElement,
noOp,
upgradeCustomElementIfNeeded
} from '../util/CommonUtils';
import { CLOSE_MENU_EVENT, type CloseMenuEvent } from '../events/CloseMenuEvent';
import { TOGGLE_MENU_EVENT, type ToggleMenuEvent } from '../events/ToggleMenuEvent';
import { isBackKey } from '../util/KeyCode';
Expand Down Expand Up @@ -255,10 +264,7 @@ export class MenuGroup extends HTMLElement {
* this listener to each nested `<slot>`.
*/
private readonly _onMenuListChange = () => {
const children: Element[] = [
...fromArrayLike(this.shadowRoot!.children),
...(this._menuSlot ? this._menuSlot.assignedNodes({ flatten: true }).filter(isElement) : [])
];
const children: Element[] = [...fromArrayLike(this.shadowRoot!.children), ...(this._menuSlot ? getSlottedElements(this._menuSlot) : [])];
const upgradePromises: Array<Promise<unknown>> = [];
for (const child of children) {
if (!isMenuElement(child)) {
Expand Down
4 changes: 2 additions & 2 deletions src/components/RadioGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as shadyCss from '@webcomponents/shadycss';
import { isArrowKey, KeyCode } from '../util/KeyCode';
import { RadioButton } from './RadioButton';
import { createEvent } from '../util/EventUtils';
import { arrayFind, isElement, noOp, upgradeCustomElementIfNeeded } from '../util/CommonUtils';
import { arrayFind, getSlottedElements, isElement, noOp, upgradeCustomElementIfNeeded } from '../util/CommonUtils';
import { StateReceiverMixin } from './StateReceiverMixin';
import { Attribute } from '../util/Attribute';
import type { DeviceType } from '../util/DeviceType';
Expand Down Expand Up @@ -75,7 +75,7 @@ export class RadioGroup extends StateReceiverMixin(HTMLElement, ['deviceType'])
}

private readonly _onSlotChange = () => {
const children = this._slot.assignedNodes({ flatten: true }).filter(isElement);
const children = getSlottedElements(this._slot);
const upgradePromises: Array<Promise<unknown>> = [];
for (const child of children) {
if (!isRadioButton(child)) {
Expand Down
11 changes: 11 additions & 0 deletions src/components/SlotContainer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
:host {
display: contents;
}

slot,
::slotted(:not([no-auto-hide])) {
/*
* Inherit opacity from parent container, so auto-hide works.
*/
opacity: inherit;
}
39 changes: 39 additions & 0 deletions src/components/SlotContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createTemplate } from '../util/TemplateUtils';
import slotContainerCss from './SlotContainer.css';

const template = createTemplate('theoplayer-slot-container', `<style>${slotContainerCss}</style><slot></slot>`);

/**
* `<theoplayer-slot-container>` - A container that can be assigned to a slot,
* and behaves as if all its children are directly assigned to that slot.
*
* This behaves approximately like a regular `<div>` with style `display: contents`,
* but receives some special treatment from e.g. {@link MenuGroup | `<theoplayer-menu-group>`}
* which normally expects its {@link Menu | menu}s to be slotted in as direct children.
* Those menus can also be children of a `<theoplayer-slot-container>` instead.
*
* This is an internal component, used mainly by Open Video UI for React.
* You shouldn't need this under normal circumstances.
*
* @group Components
* @internal
*/
export class SlotContainer extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template().content.cloneNode(true));
}
}

customElements.define('theoplayer-slot-container', SlotContainer);

export function isSlotContainer(element: Element): element is SlotContainer {
return element.nodeName.toLowerCase() === 'theoplayer-slot-container';
}

declare global {
interface HTMLElementTagNameMap {
'theoplayer-slot-container': SlotContainer;
}
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ export * from './GestureReceiver';
export * from './PreviewTimeDisplay';
export * from './PreviewThumbnail';
export * from './LiveButton';
export { SlotContainer } from './SlotContainer';
export * from './ads/index';
export { type StateReceiverElement, type StateReceiverPropertyMap, StateReceiverMixin } from './StateReceiverMixin';
14 changes: 14 additions & 0 deletions src/util/CommonUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Attribute } from './Attribute';
import { isSlotContainer } from '../components/SlotContainer';

export type Constructor<T> = abstract new (...args: any[]) => T;

Expand Down Expand Up @@ -158,6 +159,19 @@ export function getChildren(element: Element): ArrayLike<Element> {
return [];
}

export function getSlottedElements(slot: HTMLSlotElement): Element[] {
const elements: Element[] = [];
for (const node of slot.assignedNodes({ flatten: true })) {
if (isElement(node)) {
elements.push(node);
if (isSlotContainer(node)) {
elements.push(...fromArrayLike(node.children));
}
}
}
return elements;
}

export function getTvFocusChildren(element: Element): HTMLElement[] | undefined {
if (!isHTMLElement(element)) {
return;
Expand Down

0 comments on commit 21c8622

Please sign in to comment.