mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add fullscreen button component (#459)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user