diff --git a/.claude/skills/component/references/videojs.md b/.claude/skills/component/references/videojs.md index 4c9316e0..8cb17752 100644 --- a/.claude/skills/component/references/videojs.md +++ b/.claude/skills/component/references/videojs.md @@ -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 {} + +// When renaming keys, use Pick for matching keys and add JSDoc for renamed ones +interface FullscreenButtonState extends Pick { + /** Whether fullscreen can be requested on this platform. */ + availability: FullscreenState['fullscreenAvailability']; +} ``` - Primitives only — no methods -- Use `Pick` to select relevant fields +- Use `Pick` 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 diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 56e74cb6..ad0035ef 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.test.ts new file mode 100644 index 00000000..30ce115f --- /dev/null +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.test.ts @@ -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 { + 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(); + }); + }); +}); diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts new file mode 100644 index 00000000..dd7faccb --- /dev/null +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts @@ -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 { + /** Whether fullscreen can be requested on this platform. */ + availability: FullscreenState['fullscreenAvailability']; +} + +export class FullscreenButtonCore { + static readonly defaultProps: NonNullableObject = { + 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 { + 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; +} diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts new file mode 100644 index 00000000..d194d2f9 --- /dev/null +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-data-attrs.ts @@ -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; diff --git a/packages/html/src/define/ui/fullscreen-button.ts b/packages/html/src/define/ui/fullscreen-button.ts new file mode 100644 index 00000000..5f497a62 --- /dev/null +++ b/packages/html/src/define/ui/fullscreen-button.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 0499a46c..3843738b 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts new file mode 100644 index 00000000..654813ca --- /dev/null +++ b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts @@ -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)); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index cf3b8b97..5ba369b3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx new file mode 100644 index 00000000..473f0aea --- /dev/null +++ b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx @@ -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 +) { + 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; +}