bitmovin-player-ui
Version:
Bitmovin Player UI Framework
323 lines (282 loc) • 11.7 kB
text/typescript
import { Container, ContainerConfig } from '../components/Container';
import { Component, ComponentConfig } from '../components/Component';
import { getComponentInDirection } from './NavigationAlgorithm';
import { resolveAllComponents } from './helper/resolveAllComponents';
import { NodeEventSubscriber } from './NodeEventSubscriber';
import { isFocusable, isSettingsPanel } from './TypeGuards';
import { Action, ActionCallback, AnyComponent, Callback, Direction, Focusable, NavigationCallback } from './types';
import { FocusableContainer } from './FocusableContainer';
import { toHtmlElement } from './helper/toHtmlElement';
/**
* Used as part of spatial navigation. Groups together different components to which you can navigate to, in a single
* navigation group.
*
* Responsible for finding elements in direction on navigation and for tracking active element inside the group.
* Triggers blur and focus on element when active element is changed, as well as click on element on `Action.SELECT`.
* Will call `hideUi()` on passed in container if `Action.BACK` is called.
*
* To have more control over grouping related elements together, you can use `FocusableContainer`.
*
* Example 1:
* <code>
* new RootNavigationGroup(uiContainer, playbackToggleOverlay, seekBar, bottomControlBar, titleBar)
* </code>
*
* In this example all components which are passed to the `RootNavigationGroup` will be navigable on a 'flat' hierarchy.
* Elements form within the bottomControlBar and the titleBar will be resolved lazily and it's possible to navigate
* from/to every element.
*
* Example 2:
* <code>
* new RootNavigationGroup(
* uiContainer, playbackToggleOverlay, seekBar, new FocusableContainer(bottomControlBar, playbackToggleButton), new FocusableContainer(titleBar)
* )
* </code>
*
* In this example the bottomControlBar and the titleBar are considered as a group of elements. Their components
* will still be resolved lazily, but the navigation will target the whole container instead of the individual
* components. In addition, a primary component can be set for each `FocusableContainer`, which will be component that
* receives focus first when the container is focused.
*
* @category Components
*/
export class NavigationGroup {
protected activeComponent?: AnyComponent;
private activeComponentBeforeDisable?: AnyComponent;
private readonly _components: Focusable[];
private removeElementHoverEventListeners = () => {};
private readonly eventSubscriber: NodeEventSubscriber;
constructor(
public readonly container: Container<ContainerConfig>,
...components: Focusable[]
) {
this._components = components;
this.eventSubscriber = new NodeEventSubscriber();
}
// Dynamically resolve all components within this group respecting FocusableContainers.
protected getComponents(): Focusable[] {
const componentsToConsider: Focusable[] = [];
const focusableContainers = this._components
.filter(component => component instanceof FocusableContainer)
.map(component => component as FocusableContainer);
if (this.activeComponent) {
const activeFocusableContainer = this.getActiveFocusableContainer();
if (activeFocusableContainer) {
// If the active component is wihtin a focusable container, we want to include all components of that container.
componentsToConsider.push(...activeFocusableContainer.components);
} else {
// If the active component is not within a focusable container, we only want to include the container itself.
componentsToConsider.push(...focusableContainers);
}
}
// Add all non-focusable containers components and flat map other containers
const components = this._components.filter(component => !(component instanceof FocusableContainer));
components.forEach(component => {
if (component instanceof Container) {
componentsToConsider.push(...resolveAllComponents(component));
} else {
componentsToConsider.push(component);
}
});
return componentsToConsider.filter(component => isFocusable(component));
}
/**
* If overwritten, allows to implement custom navigation behavior. Per default, the internal handler will still be
* executed. To prevent execution of the default navigation handler, call `preventDefault()`;
*
* @param direction {Direction} The direction to move along
* @param target {HTMLElement} The target element for the event
* @param preventDefault {() => void} A function that, when called, will prevent the execution of the default handler
*/
public onNavigation?: NavigationCallback;
/**
* If overwritten, allows to implement custom action behavior. Per default, the internal handler will still be
* executed. To prevent execution of the default action handler, call `preventDefault()`;
*
* @param action {Action} The action that was called
* @param target {HTMLElement} The target element that action was called on
* @param preventDefault {() => void} A function that, when called, will prevent the execution of the default handler
*/
public onAction?: ActionCallback;
/**
* Returns the active HTMLElement.
*/
public getActiveComponent(): AnyComponent | undefined {
return this.activeComponent;
}
protected focusComponent(component: Focusable): void {
let targetComponent: AnyComponent;
if (component instanceof FocusableContainer) {
targetComponent = component.primaryComponent;
} else {
targetComponent = component;
}
this.blurActiveComponent();
this.activeComponent = targetComponent;
toHtmlElement(this.activeComponent).focus();
}
private blurActiveComponent(): void {
if (!this.activeComponent) {
return;
}
toHtmlElement(this.activeComponent).blur();
}
public focusFirstComponent(): void {
const component = this.getComponents()[0];
if (component) {
this.focusComponent(component);
}
}
protected defaultNavigationHandler(direction: Direction): void {
if (!this.activeComponent) {
return;
}
const containerContainingActiveComponent = this.getActiveFocusableContainer();
if (containerContainingActiveComponent) {
const targetComponent = getComponentInDirection(
this.activeComponent,
containerContainingActiveComponent.components,
direction,
);
if (targetComponent) {
this.focusComponent(targetComponent);
return;
}
}
// If no component was found within the container itself, check all components within the group
const targetComponent = getComponentInDirection(this.activeComponent, this.getComponents(), direction);
if (targetComponent) {
this.focusComponent(targetComponent);
}
}
protected defaultActionHandler(action: Action): void {
switch (action) {
case Action.SELECT:
if (this.activeComponent) {
toHtmlElement(this.activeComponent).click();
}
break;
case Action.BACK:
this.container.hide();
break;
}
}
private handleInput<T>(data: T, defaultHandler: (data: T) => void, userHandler?: Callback<T>): void {
let handleDefault = true;
const preventDefault = () => (handleDefault = false);
userHandler?.(data, this.activeComponent, preventDefault);
if (handleDefault) {
defaultHandler.call(this, data);
}
}
/**
* Handles a navigation event.
*
* @param direction The direction of the navigation event
* @returns true if navigation was successful, false otherwise
*/
public handleNavigation(direction: Direction): void {
if (!this.activeComponent) {
// If we do not have an active element, the active element has been disabled by a mouseleave
// event. We should continue the navigation at the exact place where we left off.
if (this.activeComponentBeforeDisable) {
this.focusComponent(this.activeComponentBeforeDisable);
} else {
this.focusFirstComponent();
}
return;
}
// eslint-disable-next-line @typescript-eslint/unbound-method
this.handleInput(direction, this.defaultNavigationHandler, this.onNavigation);
}
/**
* Handles an action event.
*
* @param action The action of the event
*/
public handleAction(action: Action): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.handleInput(action, this.defaultActionHandler, this.onAction);
}
/**
* Disable navigation group
*
* Call blur on active element, set as undefined, and track it as element before disable.
*/
public disable(): void {
if (this.activeComponent) {
this.activeComponentBeforeDisable = this.activeComponent;
this.blurActiveComponent();
this.activeComponent = undefined;
}
}
/**
* Enable navigation group
*
* Sets active element to either element that was active before disable, or first element of tracked elements.
* If it is settings panel, it will always focus first element in the list.
*/
public enable(): void {
if (this.activeComponentBeforeDisable && !isSettingsPanel(this.container)) {
this.focusComponent(this.activeComponentBeforeDisable);
this.activeComponentBeforeDisable = undefined;
} else {
this.focusFirstComponent();
}
this.trackElementHover();
}
/**
* Adds event listener for `mouseenter` on tracked elements to ensure tracking of active element will work together
* in combination of using mouse and key events.
*/
private trackElementHover(): void {
this.removeElementHoverEventListeners();
const componentsToConsider: Component<ComponentConfig>[] = [];
this.getComponents().forEach(component => {
let elementsToConsider: Component<ComponentConfig>[];
if (component instanceof Container) {
elementsToConsider = resolveAllComponents(component);
} else if (component instanceof FocusableContainer) {
elementsToConsider = resolveAllComponents(component.container);
} else {
elementsToConsider = [component];
}
elementsToConsider.forEach(component => {
componentsToConsider.push(component);
});
});
const removeEventListenerFunctions = componentsToConsider
.map(component => {
return { component, element: toHtmlElement(component) };
})
.map(({ element, component }) => {
const enterListener = this.focusComponent.bind(this, component);
const exitListener = () => this.disable();
this.eventSubscriber.on(element, 'mouseenter', enterListener);
this.eventSubscriber.on(element, 'mouseleave', exitListener);
return () => {
this.eventSubscriber.off(element, 'mouseenter', enterListener);
this.eventSubscriber.off(element, 'mouseleave', exitListener);
};
});
this.removeElementHoverEventListeners = () => removeEventListenerFunctions.forEach(fn => fn());
}
/**
* Dispose of navigation group
*/
public release(): void {
this.eventSubscriber.release();
this.activeComponent = undefined;
this._components.splice(0, this._components.length);
this.removeElementHoverEventListeners();
}
private getActiveFocusableContainer(): FocusableContainer | undefined {
if (!this.activeComponent) {
return undefined;
}
return this._components
.filter(component => component instanceof FocusableContainer)
.map(component => component as FocusableContainer)
.find(container => container.components.includes(this.activeComponent));
}
}