feat(core): add controls component with activity tracking (#514)

This commit is contained in:
rahim
2026-02-13 01:14:16 +11:00
committed by GitHub
parent 33b21906cd
commit 90d881cec2
20 changed files with 787 additions and 0 deletions
+2
View File
@@ -1,4 +1,6 @@
export * from './media/state';
export * from './ui/controls/controls-core';
export * from './ui/controls/controls-data-attrs';
export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/mute-button/mute-button-core';
+7
View File
@@ -163,6 +163,13 @@ export interface MediaFullscreenState {
exitFullscreen(): Promise<void>;
}
export interface MediaControlsState {
/** Whether the user has recently interacted with the player. */
userActive: boolean;
/** Whether controls should be visible (userActive || paused). */
controlsVisible: boolean;
}
export interface MediaPictureInPictureState {
/**
* Whether picture-in-picture mode is currently active.
@@ -0,0 +1,19 @@
import type { MediaControlsState } from '../../media/state';
export interface ControlsState {
visible: boolean;
userActive: boolean;
}
export class ControlsCore {
getState(media: MediaControlsState): ControlsState {
return {
visible: media.controlsVisible,
userActive: media.userActive,
};
}
}
export namespace ControlsCore {
export type State = ControlsState;
}
@@ -0,0 +1,9 @@
import type { StateAttrMap } from '../types';
import type { ControlsState } from './controls-core';
export const ControlsDataAttrs = {
/** Present when controls are visible. */
visible: 'data-visible',
/** Present when the user has recently interacted. */
userActive: 'data-user-active',
} as const satisfies StateAttrMap<ControlsState>;
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import type { MediaControlsState } from '../../../media/state';
import { ControlsCore } from '../controls-core';
describe('ControlsCore', () => {
describe('getState', () => {
it('returns visible: true when controlsVisible is true', () => {
const core = new ControlsCore();
const media = createControlsState({ controlsVisible: true });
expect(core.getState(media)).toEqual({ visible: true, userActive: true });
});
it('returns visible: false when controlsVisible is false', () => {
const core = new ControlsCore();
const media = createControlsState({ controlsVisible: false });
expect(core.getState(media)).toEqual({ visible: false, userActive: true });
});
it('returns userActive: false when userActive is false', () => {
const core = new ControlsCore();
const media = createControlsState({ userActive: false });
expect(core.getState(media)).toEqual({ visible: true, userActive: false });
});
it('projects visible and userActive independently', () => {
const core = new ControlsCore();
// All four quadrants of the 2x2 boolean matrix
expect(core.getState(createControlsState({ controlsVisible: true, userActive: true }))).toEqual({
visible: true,
userActive: true,
});
expect(core.getState(createControlsState({ controlsVisible: true, userActive: false }))).toEqual({
visible: true,
userActive: false,
});
expect(core.getState(createControlsState({ controlsVisible: false, userActive: true }))).toEqual({
visible: false,
userActive: true,
});
expect(core.getState(createControlsState({ controlsVisible: false, userActive: false }))).toEqual({
visible: false,
userActive: false,
});
});
it('returns only primitive values', () => {
const core = new ControlsCore();
const state = core.getState(createControlsState());
const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function');
expect(functionKeys).toHaveLength(0);
});
});
});
function createControlsState(overrides: Partial<MediaControlsState> = {}): MediaControlsState {
return {
userActive: true,
controlsVisible: true,
...overrides,
};
}
+2
View File
@@ -2,6 +2,7 @@ import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store';
import type {
MediaBufferState,
MediaControlsState,
MediaFullscreenState,
MediaPictureInPictureState,
MediaPlaybackState,
@@ -41,6 +42,7 @@ export type VideoFeatures = [
PlayerFeature<MediaBufferState>,
PlayerFeature<MediaFullscreenState>,
PlayerFeature<MediaPictureInPictureState>,
PlayerFeature<MediaControlsState>,
];
export type AudioFeatures = [
@@ -0,0 +1,106 @@
import { listen } from '@videojs/utils/dom';
import { isNull } from '@videojs/utils/predicate';
import type { MediaControlsState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
const IDLE_DELAY = 2000;
const TAP_THRESHOLD = 250;
export const controlsFeature = definePlayerFeature({
state: (): MediaControlsState => ({
userActive: true,
controlsVisible: true,
}),
attach({ target, signal, get, set }) {
const { media, container } = target;
if (isNull(container)) {
if (__DEV__) {
console.warn('[vjs] controlsFeature requires a container element for activity tracking.');
}
return;
}
function computeVisible(userActive: boolean): boolean {
return userActive || media.paused;
}
// Idle timer
let idleTimer: ReturnType<typeof setTimeout> | undefined;
function clearIdle() {
clearTimeout(idleTimer);
idleTimer = undefined;
}
function scheduleIdle() {
clearIdle();
idleTimer = setTimeout(setInactive, IDLE_DELAY);
}
function setActive() {
if (!get().userActive) {
set({ userActive: true, controlsVisible: true });
}
scheduleIdle();
}
function setInactive() {
clearIdle();
set({ userActive: false, controlsVisible: computeVisible(false) });
}
// Touch tap-to-toggle
let pointerDownTime = 0;
function onPointerDown() {
pointerDownTime = Date.now();
}
function onPointerUp(event: PointerEvent) {
if (event.pointerType === 'touch' && Date.now() - pointerDownTime < TAP_THRESHOLD) {
if (get().controlsVisible) {
clearIdle();
set({ userActive: false, controlsVisible: computeVisible(false) });
} else {
setActive();
}
} else {
setActive();
}
}
// Recompute visibility when playback state changes.
function onPlaybackChange() {
const { userActive } = get();
set({ controlsVisible: computeVisible(userActive) });
// When playback starts, schedule idle if user is active.
if (!media.paused && userActive) {
scheduleIdle();
}
}
// Container event listeners
listen(container, 'pointermove', setActive, { signal });
listen(container, 'pointerdown', onPointerDown, { signal });
listen(container, 'pointerup', onPointerUp, { signal });
listen(container, 'keyup', setActive, { signal });
listen(container, 'focusin', setActive, { signal });
listen(container, 'pointerleave', setInactive, { signal });
// Media event listeners for playback state changes.
listen(media, 'play', onPlaybackChange, { signal });
listen(media, 'pause', onPlaybackChange, { signal });
listen(media, 'ended', onPlaybackChange, { signal });
// Clean up timer on signal abort.
signal.addEventListener('abort', clearIdle, { once: true });
// Always schedule idle initially. When paused, userActive will go false
// but controlsVisible stays true (because paused keeps controls visible).
scheduleIdle();
},
});
@@ -1,5 +1,6 @@
import type { AudioFeatures, BackgroundFeatures, VideoFeatures } from '../../media/types';
import { bufferFeature } from './buffer';
import { controlsFeature } from './controls';
import { fullscreenFeature } from './fullscreen';
import { pipFeature } from './pip';
import { playbackFeature } from './playback';
@@ -10,6 +11,7 @@ import { volumeFeature } from './volume';
// Short aliases
export {
bufferFeature as buffer,
controlsFeature as controls,
fullscreenFeature as fullscreen,
pipFeature as pip,
playbackFeature as playback,
@@ -26,6 +28,7 @@ export const video: VideoFeatures = [
bufferFeature,
fullscreenFeature,
pipFeature,
controlsFeature,
];
export const audio: AudioFeatures = [playbackFeature, volumeFeature, timeFeature, sourceFeature, bufferFeature];
@@ -1,4 +1,5 @@
export * from './buffer';
export * from './controls';
export * as features from './feature.parts';
export * from './fullscreen';
export * from './pip';
@@ -0,0 +1,413 @@
import { createStore, flush } from '@videojs/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../media/types';
import { controlsFeature } from '../controls';
const IDLE_DELAY = 2000;
describe('controlsFeature', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('initial state', () => {
it('starts with userActive: true and controlsVisible: true', () => {
const { store } = createPlayerStore();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
});
describe('idle timeout', () => {
it('sets inactive after idle delay when playing', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(false);
});
it('sets userActive false but keeps controlsVisible true when paused', () => {
const video = createMockVideo({ paused: true });
const { store } = createPlayerStore(video);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(true);
});
});
describe('activity detection', () => {
it('resets idle timer on pointermove', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
// Advance partway through idle delay
vi.advanceTimersByTime(IDLE_DELAY - 500);
container!.dispatchEvent(new Event('pointermove'));
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
// Advance past original delay — still active since timer was reset
vi.advanceTimersByTime(500);
flush();
expect(store.state.userActive).toBe(true);
// Now wait full delay from the pointermove
vi.advanceTimersByTime(IDLE_DELAY - 500);
flush();
expect(store.state.userActive).toBe(false);
});
it('sets active on keyup', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
container!.dispatchEvent(new Event('keyup'));
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
it('reactivates on pointermove after idle', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(false);
container!.dispatchEvent(new Event('pointermove'));
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
it('sets active on focusin', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
container!.dispatchEvent(new Event('focusin'));
flush();
expect(store.state.userActive).toBe(true);
});
it('sets inactive immediately on pointerleave', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
container!.dispatchEvent(new Event('pointerleave'));
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(false);
});
it('keeps controlsVisible true on pointerleave when paused', () => {
const video = createMockVideo({ paused: true });
const { store, container } = createPlayerStore(video);
container!.dispatchEvent(new Event('pointerleave'));
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(true);
});
});
describe('touch tap-to-toggle', () => {
it('hides controls on tap when visible and playing', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
container!.dispatchEvent(new Event('pointerdown'));
vi.advanceTimersByTime(100);
container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' }));
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(false);
});
it('keeps controls visible on tap when paused', () => {
const video = createMockVideo({ paused: true });
const { store, container } = createPlayerStore(video);
container!.dispatchEvent(new Event('pointerdown'));
vi.advanceTimersByTime(100);
container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' }));
flush();
// userActive goes false but controlsVisible stays true (paused)
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(true);
});
it('shows controls on tap when hidden', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
// First tap to hide
container!.dispatchEvent(new Event('pointerdown'));
vi.advanceTimersByTime(100);
container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' }));
flush();
expect(store.state.controlsVisible).toBe(false);
// Second tap to show
container!.dispatchEvent(new Event('pointerdown'));
vi.advanceTimersByTime(100);
container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' }));
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
it('does not toggle on long press', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
container!.dispatchEvent(new Event('pointerdown'));
vi.advanceTimersByTime(300);
container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' }));
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
it('does not toggle for mouse clicks', () => {
const video = createMockVideo({ paused: false });
const { store, container } = createPlayerStore(video);
container!.dispatchEvent(new Event('pointerdown'));
vi.advanceTimersByTime(100);
container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'mouse' }));
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
});
describe('playback state interaction', () => {
it('shows controls when media pauses', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.controlsVisible).toBe(false);
// Pause media
Object.defineProperty(video, 'paused', { value: true, configurable: true });
video.dispatchEvent(new Event('pause'));
flush();
expect(store.state.controlsVisible).toBe(true);
});
it('hides controls when media resumes and user is inactive', () => {
const video = createMockVideo({ paused: true });
const { store } = createPlayerStore(video);
// Let idle expire — user inactive, but visible because paused
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(true);
// Resume playback while user is still inactive
Object.defineProperty(video, 'paused', { value: false, configurable: true });
video.dispatchEvent(new Event('play'));
flush();
expect(store.state.controlsVisible).toBe(false);
});
it('schedules idle when playback resumes and user is active', () => {
const video = createMockVideo({ paused: true });
const { store, container } = createPlayerStore(video);
// Trigger activity to keep user active
container!.dispatchEvent(new Event('pointermove'));
flush();
// Resume playback
Object.defineProperty(video, 'paused', { value: false, configurable: true });
video.dispatchEvent(new Event('play'));
flush();
expect(store.state.controlsVisible).toBe(true);
// After idle delay, should hide
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.controlsVisible).toBe(false);
});
});
describe('null container', () => {
it('does not track activity without container', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video, null);
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
});
describe('cleanup', () => {
it('stops listening when store is destroyed', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video);
store.destroy();
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(true);
});
it('clears idle timer on detach', () => {
const video = createMockVideo({ paused: false });
const store = createStore<PlayerTarget>()(controlsFeature);
const container = createContainer();
const detach = store.attach({ media: video, container });
flush();
detach();
flush();
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
it('does not react to media events after detach', () => {
const video = createMockVideo({ paused: false });
const store = createStore<PlayerTarget>()(controlsFeature);
const container = createContainer();
const detach = store.attach({ media: video, container });
flush();
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.controlsVisible).toBe(false);
detach();
flush();
// Pause after detach — should not affect state
Object.defineProperty(video, 'paused', { value: true, configurable: true });
video.dispatchEvent(new Event('pause'));
flush();
// State was reset to initial on detach
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockVideo(
overrides: Partial<{
paused: boolean;
ended: boolean;
currentTime: number;
readyState: number;
tagName: string;
}> = {}
): HTMLVideoElement {
const video = document.createElement(overrides.tagName === 'AUDIO' ? 'audio' : 'video') as HTMLVideoElement;
if (overrides.paused !== undefined) {
Object.defineProperty(video, 'paused', { value: overrides.paused, configurable: true });
}
if (overrides.ended !== undefined) {
Object.defineProperty(video, 'ended', { value: overrides.ended, configurable: true });
}
if (overrides.currentTime !== undefined) {
video.currentTime = overrides.currentTime;
}
if (overrides.readyState !== undefined) {
Object.defineProperty(video, 'readyState', { value: overrides.readyState, configurable: true });
}
return video;
}
function createContainer(): HTMLElement {
return document.createElement('div');
}
function createPointerEvent(type: string, init?: { pointerType?: string }): Event {
const event = new Event(type, { bubbles: true });
(event as unknown as Record<string, unknown>).pointerType = init?.pointerType ?? '';
return event;
}
function createPlayerStore(video?: HTMLVideoElement, container?: HTMLElement | null) {
const store = createStore<PlayerTarget>()(controlsFeature);
const media = video ?? createMockVideo({ paused: true });
const cont = container === undefined ? createContainer() : container;
store.attach({ media, container: cont });
flush();
return { store, media, container: cont };
}
+2
View File
@@ -1,6 +1,7 @@
import { createSelector } from '@videojs/store';
import { bufferFeature } from './features/buffer';
import { controlsFeature } from './features/controls';
import { fullscreenFeature } from './features/fullscreen';
import { pipFeature } from './features/pip';
import { playbackFeature } from './features/playback';
@@ -9,6 +10,7 @@ import { timeFeature } from './features/time';
import { volumeFeature } from './features/volume';
export const selectBuffer = createSelector(bufferFeature);
export const selectControls = createSelector(controlsFeature);
export const selectFullscreen = createSelector(fullscreenFeature);
export const selectPiP = createSelector(pipFeature);
export const selectPlayback = createSelector(playbackFeature);
+12
View File
@@ -0,0 +1,12 @@
import { ControlsElement } from '../../ui/controls/controls-element';
import { ControlsGroupElement } from '../../ui/controls/controls-group-element';
customElements.define(ControlsElement.tagName, ControlsElement);
customElements.define(ControlsGroupElement.tagName, ControlsGroupElement);
declare global {
interface HTMLElementTagNameMap {
[ControlsElement.tagName]: ControlsElement;
[ControlsGroupElement.tagName]: ControlsGroupElement;
}
}
+2
View File
@@ -14,6 +14,8 @@ export * from './store/container-mixin';
export * from './store/provider-mixin';
export * from './store/types';
// UI Components
export { ControlsElement } from './ui/controls/controls-element';
export { ControlsGroupElement } from './ui/controls/controls-group-element';
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
// Primitives
export * from './ui/media-element';
@@ -0,0 +1,34 @@
import { ControlsCore, ControlsDataAttrs } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectControls } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class ControlsElement extends MediaElement {
static readonly tagName = 'media-controls';
readonly #core = new ControlsCore();
readonly #state = new PlayerController(this, playerContext, selectControls);
override connectedCallback(): void {
super.connectedCallback();
if (__DEV__ && !this.#state.value) {
logMissingFeature(ControlsElement.tagName, 'controls');
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const controls = this.#state.value;
if (!controls) {
return;
}
applyStateDataAttrs(this, this.#core.getState(controls), ControlsDataAttrs);
}
}
@@ -0,0 +1,13 @@
import { MediaElement } from '../media-element';
export class ControlsGroupElement extends MediaElement {
static readonly tagName = 'media-controls-group';
override connectedCallback(): void {
super.connectedCallback();
if (this.hasAttribute('aria-label') || this.hasAttribute('aria-labelledby')) {
this.setAttribute('role', 'group');
}
}
}
+3
View File
@@ -30,6 +30,9 @@ export {
} from './player/create-player';
// UI
export { Controls } from './ui/controls';
export type { ControlsGroupProps } from './ui/controls/controls-group';
export type { ControlsRootProps } from './ui/controls/controls-root';
export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button';
export { useButton } from './ui/hooks/use-button';
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
@@ -0,0 +1,38 @@
'use client';
import type { ForwardedRef, ReactNode } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
type GroupState = Record<string, never>;
export interface ControlsGroupProps extends UIComponentProps<'div', GroupState> {
children?: ReactNode | undefined;
}
export const ControlsGroup = forwardRef(function ControlsGroup(
componentProps: ControlsGroupProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, children, ...elementProps } = componentProps;
const role = elementProps['aria-label'] || elementProps['aria-labelledby'] ? 'group' : undefined;
const state: GroupState = {};
return renderElement(
'div',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [{ role, children }, elementProps],
}
);
});
export namespace ControlsGroup {
export type Props = ControlsGroupProps;
}
@@ -0,0 +1,48 @@
'use client';
import { ControlsCore, ControlsDataAttrs } from '@videojs/core';
import { logMissingFeature, selectControls } from '@videojs/core/dom';
import type { ForwardedRef, ReactNode } from 'react';
import { forwardRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
export interface ControlsRootProps extends UIComponentProps<'div', ControlsCore.State> {
children?: ReactNode | undefined;
}
export const ControlsRoot = forwardRef(function ControlsRoot(
componentProps: ControlsRootProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, children, ...elementProps } = componentProps;
const controls = usePlayer(selectControls);
const [core] = useState(() => new ControlsCore());
if (!controls) {
if (__DEV__) logMissingFeature('Controls.Root', 'controls');
return null;
}
const state = core.getState(controls);
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: ControlsDataAttrs,
ref: [forwardedRef],
props: [{ children }, elementProps],
}
);
});
export namespace ControlsRoot {
export type Props = ControlsRootProps;
export type State = ControlsCore.State;
}
@@ -0,0 +1,2 @@
export { ControlsGroup as Group, type ControlsGroupProps as GroupProps } from './controls-group';
export { ControlsRoot as Root, type ControlsRootProps as RootProps } from './controls-root';
+1
View File
@@ -0,0 +1 @@
export * as Controls from './index.parts';