feat(core): add fullscreen button component (#459)

This commit is contained in:
rahim
2026-02-06 14:39:09 +11:00
committed by GitHub
parent 7b8bc11f9f
commit 3c4152fb58
10 changed files with 397 additions and 7 deletions
+11 -1
View File
@@ -46,11 +46,19 @@ interface PlayButtonProps {
### State Interface
```ts
// When keys match the feature state, use Pick (preserves JSDoc on IDE hover)
interface PlayButtonState extends Pick<PlaybackState, 'paused' | 'ended' | 'started'> {}
// When renaming keys, use Pick for matching keys and add JSDoc for renamed ones
interface FullscreenButtonState extends Pick<FullscreenState, 'fullscreen'> {
/** Whether fullscreen can be requested on this platform. */
availability: FullscreenState['fullscreenAvailability'];
}
```
- Primitives only — no methods
- Use `Pick<FeatureState, ...>` to select relevant fields
- Use `Pick<FeatureState, ...>` to select relevant fields — preserves JSDoc on IDE hover
- When a key is renamed for the button context, use `FeatureState['...']` for the type and add a JSDoc description
### Core Class
@@ -226,6 +234,8 @@ packages/
└── utils/ # renderElement, mergeProps
```
**No barrel exports for simple components** — Don't create `index.ts` files for simple UI components. Export directly from individual files. Reserve `index.ts` barrels for compound components with multiple related exports that form a cohesive API.
---
## Component Registration
+2
View File
@@ -1,5 +1,7 @@
export * from './element';
export * from './media/state';
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';
export * from './ui/mute-button/mute-button-data-attrs';
export * from './ui/play-button/play-button-core';
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest';
import type { FullscreenState } from '../../media/state';
import { FullscreenButtonCore } from './fullscreen-button-core';
function createMockFullscreen(overrides: Partial<FullscreenState> = {}): FullscreenState {
return {
fullscreen: false,
fullscreenAvailability: 'available',
requestFullscreen: vi.fn(),
exitFullscreen: vi.fn(),
...overrides,
};
}
describe('FullscreenButtonCore', () => {
describe('getLabel', () => {
it('returns custom label string when provided', () => {
const core = new FullscreenButtonCore({ label: 'Custom Label' });
const state = createMockFullscreen();
expect(core.getLabel(state)).toBe('Custom Label');
});
it('returns custom label from function when provided', () => {
const core = new FullscreenButtonCore({
label: (state) => (state.fullscreen ? 'Exit' : 'Enter'),
});
const state = createMockFullscreen({ fullscreen: true });
expect(core.getLabel(state)).toBe('Exit');
});
it('falls back to default label when function returns empty string', () => {
const core = new FullscreenButtonCore({ label: () => '' });
const state = createMockFullscreen({ fullscreen: false });
expect(core.getLabel(state)).toBe('Enter fullscreen');
});
it('returns "Exit fullscreen" when active', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
expect(core.getLabel(state)).toBe('Exit fullscreen');
});
it('returns "Enter fullscreen" when not active', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: false });
expect(core.getLabel(state)).toBe('Enter fullscreen');
});
});
describe('getAttrs', () => {
it('returns aria-label based on fullscreen state', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
const attrs = core.getAttrs(state);
expect(attrs['aria-label']).toBe('Exit fullscreen');
});
it('returns aria-disabled when disabled', () => {
const core = new FullscreenButtonCore({ disabled: true });
const state = createMockFullscreen();
const attrs = core.getAttrs(state);
expect(attrs['aria-disabled']).toBe('true');
});
it('returns undefined aria-disabled when not disabled', () => {
const core = new FullscreenButtonCore({ disabled: false });
const state = createMockFullscreen();
const attrs = core.getAttrs(state);
expect(attrs['aria-disabled']).toBeUndefined();
});
});
describe('getState', () => {
it('returns primitive values only (no methods)', () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
const buttonState = core.getState(state);
expect(buttonState).toEqual({
fullscreen: true,
availability: 'available',
});
const functionKeys = Object.entries(buttonState).filter(([, value]) => typeof value === 'function');
expect(functionKeys).toHaveLength(0);
});
it('picks fullscreen from state', () => {
const core = new FullscreenButtonCore();
expect(core.getState(createMockFullscreen({ fullscreen: true })).fullscreen).toBe(true);
expect(core.getState(createMockFullscreen({ fullscreen: false })).fullscreen).toBe(false);
});
it('picks availability from state', () => {
const core = new FullscreenButtonCore();
expect(core.getState(createMockFullscreen({ fullscreenAvailability: 'unsupported' })).availability).toBe(
'unsupported'
);
});
});
describe('toggle', () => {
it('calls requestFullscreen when not active', async () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: false });
await core.toggle(state);
expect(state.requestFullscreen).toHaveBeenCalledTimes(1);
expect(state.exitFullscreen).not.toHaveBeenCalled();
});
it('calls exitFullscreen when active', async () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreen: true });
await core.toggle(state);
expect(state.exitFullscreen).toHaveBeenCalledTimes(1);
expect(state.requestFullscreen).not.toHaveBeenCalled();
});
it('does nothing when disabled', async () => {
const core = new FullscreenButtonCore({ disabled: true });
const state = createMockFullscreen();
await core.toggle(state);
expect(state.requestFullscreen).not.toHaveBeenCalled();
expect(state.exitFullscreen).not.toHaveBeenCalled();
});
it('does nothing when availability is not available', async () => {
const core = new FullscreenButtonCore();
const state = createMockFullscreen({ fullscreenAvailability: 'unsupported' });
await core.toggle(state);
expect(state.requestFullscreen).not.toHaveBeenCalled();
expect(state.exitFullscreen).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,83 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { ElementProps } from '../../element';
import type { FullscreenState } from '../../media/state';
export interface FullscreenButtonProps {
/** Custom label for the button. */
label?: string | ((state: FullscreenButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
export interface FullscreenButtonState extends Pick<FullscreenState, 'fullscreen'> {
/** Whether fullscreen can be requested on this platform. */
availability: FullscreenState['fullscreenAvailability'];
}
export class FullscreenButtonCore {
static readonly defaultProps: NonNullableObject<FullscreenButtonProps> = {
label: '',
disabled: false,
};
#props = { ...FullscreenButtonCore.defaultProps };
constructor(props?: FullscreenButtonProps) {
if (props) this.setProps(props);
}
setProps(props: FullscreenButtonProps): void {
this.#props = defaults(props, FullscreenButtonCore.defaultProps);
}
getLabel(state: FullscreenState): string {
const buttonState = this.getState(state);
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(buttonState);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return buttonState.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
}
getAttrs(state: FullscreenState): ElementProps {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(state: FullscreenState): FullscreenButtonState {
return {
fullscreen: state.fullscreen,
availability: state.fullscreenAvailability,
};
}
async toggle(state: FullscreenState): Promise<void> {
if (this.#props.disabled) return;
if (state.fullscreenAvailability !== 'available') return;
try {
if (state.fullscreen) {
await state.exitFullscreen();
} else {
await state.requestFullscreen();
}
} catch {
// Fullscreen requests can fail (user gesture required, permissions, etc.)
}
}
}
export namespace FullscreenButtonCore {
export type Props = FullscreenButtonProps;
export type State = FullscreenButtonState;
}
@@ -0,0 +1,6 @@
export const FullscreenButtonDataAttributes = {
/** Present when fullscreen mode is active. */
fullscreen: 'data-fullscreen',
/** Indicates fullscreen availability (`available` or `unsupported`). */
availability: 'data-availability',
} as const;
@@ -0,0 +1,9 @@
import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element';
customElements.define(FullscreenButtonElement.tagName, FullscreenButtonElement);
declare global {
interface HTMLElementTagNameMap {
[FullscreenButtonElement.tagName]: FullscreenButtonElement;
}
}
+2 -3
View File
@@ -13,11 +13,10 @@ export * from './player/player-mixin';
export * from './store/container-mixin';
export * from './store/provider-mixin';
export * from './store/types';
// UI Components
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
// Primitives
export * from './ui/media-element';
// UI Components
export { MuteButtonElement } from './ui/mute-button/mute-button-element';
export { PlayButtonElement } from './ui/play-button/play-button-element';
export { TimeElement } from './ui/time/time-element';
@@ -0,0 +1,71 @@
import type { PropertyValues } from '@lit/reactive-element';
import { FullscreenButtonCore } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
createButton,
logMissingFeature,
selectFullscreen,
} from '@videojs/core/dom';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class FullscreenButtonElement extends MediaElement {
static readonly tagName = 'media-fullscreen-button';
static override properties = {
label: { type: String },
disabled: { type: Boolean },
};
label = '';
disabled = false;
readonly #core = new FullscreenButtonCore();
readonly #state = new PlayerController(this, playerContext, selectFullscreen);
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
const buttonProps = createButton({
onActivate: () => this.#core.toggle(this.#state.value!),
isDisabled: () => this.disabled || !this.#state.value,
});
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (!this.#state.value) {
logMissingFeature(FullscreenButtonElement.tagName, 'fullscreen');
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps(this);
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const state = this.#state.value;
if (!state) {
return;
}
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, this.#core.getState(state));
}
}
+2 -3
View File
@@ -29,10 +29,9 @@ export {
type ProviderProps,
} from './player/create-player';
// UI Hooks
// UI
export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button';
export { useButton } from './ui/hooks/use-button';
// UI Components
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
export { Time } from './ui/time';
@@ -0,0 +1,53 @@
'use client';
import { FullscreenButtonCore } from '@videojs/core';
import { logMissingFeature, selectFullscreen } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useButton } from '../hooks/use-button';
export interface FullscreenButtonProps
extends UIComponentProps<'button', FullscreenButtonCore.State>,
FullscreenButtonCore.Props {}
export const FullscreenButton = forwardRef(function FullscreenButton(
componentProps: FullscreenButtonProps,
forwardedRef: ForwardedRef<HTMLButtonElement>
) {
const { render, className, style, label, disabled = false, ...elementProps } = componentProps;
const fullscreen = usePlayer(selectFullscreen);
const [core] = useState(() => new FullscreenButtonCore());
core.setProps({ label, disabled });
const { getButtonProps, buttonRef } = useButton({
displayName: 'FullscreenButton',
onActivate: () => core.toggle(fullscreen!),
isDisabled: () => disabled || !fullscreen,
});
if (!fullscreen) {
logMissingFeature('FullscreenButton', 'fullscreen');
return null;
}
return renderElement(
'button',
{ render, className, style },
{
state: core.getState(fullscreen),
ref: [forwardedRef, buttonRef],
props: [core.getAttrs(fullscreen), elementProps, getButtonProps()],
}
);
});
export namespace FullscreenButton {
export type Props = FullscreenButtonProps;
export type State = FullscreenButtonCore.State;
}