Files
v10/.claude/skills/component/references/videojs.md
T

10 KiB

Video.js Component Architecture

Video.js components use a three-layer architecture separating framework-agnostic logic from platform implementations.

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│  @videojs/core          Framework-agnostic business logic   │
│  @videojs/core/dom      Shared DOM utilities                │
└─────────────────────────────────────────────────────────────┘
                              │
            ┌─────────────────┴─────────────────┐
            ▼                                   ▼
      @videojs/html                     @videojs/react
      Web Components                    React Components
Package Responsibility
@videojs/core Core classes with getState()/getAttrs()/actions
@videojs/core/dom DOM utilities, selectors, button behavior
@videojs/html Web Components consuming core via controllers
@videojs/react React components consuming core via hooks

Core Class Pattern

Every UI component has a *Core class in @videojs/core.

Props Interface

interface PlayButtonProps {
  /** Custom label for the button. */
  label?: string | undefined;
  /** Whether the button is disabled. */
  disabled?: boolean | undefined;
}
  • All props optional with | undefined (explicit optionality)
  • JSDoc for each prop

State Interface

// 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 — 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

class PlayButtonCore {
  static readonly defaultProps: NonNullableObject<Props>;
  
  setProps(props: Props): void;                    // Merge with defaults
  getState(media: MediaPlaybackState): State;      // Project media → UI state
  getLabel(state: PlayButtonState): string;        // Computed label
  getAttrs(state: PlayButtonState): { ... };       // ARIA only (inferred)
  toggle(media: MediaPlaybackState): Promise<void>; // Action
}

namespace PlayButtonCore {
  export type Props = PlayButtonProps;
  export type State = PlayButtonState;
}

Method signatures — queries vs commands:

Method Accepts Why
getState(media) Raw media state Projection boundary — only place that touches Media*State
getLabel(state) Projected UI state Pure query, needs only data fields
getAttrs(state) Projected UI state Pure query, return type inferred from object literal
toggle(media) Raw media state Command — needs action methods (play, pause, etc.)

Rules:

  • static readonly defaultProps with NonNullableObject<Props> type
  • getAttrs() returns ARIA attributes only (no data-*), return type inferred (no explicit interface)
  • getState() returns primitives only (no methods) — converted to data-* for CSS
  • toggle() accepts raw media state (commands need action methods)
  • getLabel() and getAttrs() accept projected UI state (queries need only data)
  • Namespace exports Props and State types

State vs Attrs Separation

Method Returns Purpose
getAttrs() ARIA attributes Accessibility (aria-label, aria-disabled)
getState() Primitives CSS styling via data-* attributes

Why separate:

  • CSS targets [data-paused], [data-ended] selectors
  • ARIA attrs remain semantically accurate
  • Can update independently

Data Attribute Maps

Each component has a *DataAttrs constant that maps state keys to data-* attribute names. The satisfies StateAttrMap<*State> constraint validates at compile-time that only keys from the component's state type are mapped — preventing accidental serialization of unmapped keys.

import type { StateAttrMap } from '../types';
import type { PlayButtonState } from './play-button-core';

export const PlayButtonDataAttrs = {
  /** Present when the media is paused. */
  paused: 'data-paused',
  /** Present when the media has ended. */
  ended: 'data-ended',
  /** Present when playback has started. */
  started: 'data-started',
} as const satisfies StateAttrMap<PlayButtonState>;

StateAttrMap<State> is defined in core/ui/types.ts:

export type StateAttrMap<State> = {
  [Key in keyof State]?: string;
};
  • JSDoc comments generate API documentation
  • Maps are partial — only mapped state keys become data-* attributes
  • getStateDataAttrs and applyStateDataAttrs skip unmapped keys when a map is provided

Web Component

class PlayButtonElement extends MediaElement {
  static readonly tagName = 'media-play-button';
  static override properties = { label: { type: String }, disabled: { type: Boolean } };

  readonly #core = new PlayButtonCore();
  readonly #state = new PlayerController(this, playerContext, selectPlayback);
  #disconnect: AbortController | null = null;
  
  // Lifecycle: see flow below
}

Lifecycle flow:

  1. connectedCallback — Create AbortController, apply button props, __DEV__ warning for missing feature
  2. disconnectedCallback — Abort controller for cleanup
  3. willUpdate — Sync component props to core via setProps(this)
  4. update — Silent null guard, then project state and apply attrs

connectedCallback + update() pattern:

override connectedCallback(): void {
  super.connectedCallback();

  this.#disconnect = new AbortController();
  const buttonProps = createButton({ ... });
  applyElementProps(this, buttonProps, this.#disconnect.signal);

  if (__DEV__ && !this.#state.value) {
    logMissingFeature(PlayButtonElement.tagName, 'playback');
  }
}

protected override update(changed: PropertyValues): void {
  super.update(changed);

  const media = this.#state.value;
  if (!media) return;

  const state = this.#core.getState(media);
  applyElementProps(this, this.#core.getAttrs(state));
  applyStateDataAttrs(this, state, PlayButtonDataAttrs);
}

Key utilities:

  • PlayerController(host, context, selector) — Store subscription
  • applyElementProps(el, props, signal?) — Apply attrs + events to DOM
  • applyStateDataAttrs(el, state, map) — State → data-* (map controls which keys are serialized)
  • logMissingFeature(name, feature) — Deduped __DEV__-only warning (called in connectedCallback)

React Component

const PlayButton = forwardRef(function PlayButton(props, ref) {
  const playback = usePlayer(selectPlayback);
  const [core] = useState(() => new PlayButtonCore());
  const { getButtonProps, buttonRef } = useButton({
    onActivate: () => core.toggle(playback!),
    isDisabled,
  });

  const state = core.getState(playback);

  return renderElement('button', { render, className, style }, {
    state,
    stateAttrMap: PlayButtonDataAttrs,
    ref: [ref, buttonRef],
    props: [core.getAttrs(state), elementProps, getButtonProps()],
  });
});

Flow:

  1. usePlayer(selector) — Subscribe to store slice
  2. useState(() => new Core()) — Lazy init core class
  3. useButton() — Get accessible button behavior
  4. renderElement() — Render with state→data-attrs, ref composition, props merge

Props type: UIComponentProps<Tag, State> allows className/style as functions of state.


Shared Utilities

@videojs/core/dom

Utility Purpose
createButton(options) Accessible button (Enter/Space, click, disabled)
applyElementProps(el, props, signal?) Apply attrs and events to DOM
applyStateDataAttrs(el, state, map) State → data-* (map controls serialized keys)
getStateDataAttrs(state, map) State → data-attrs object (React)
logMissingFeature(name, feature) Deduped console.warn
selectPlayback / selectVolume Store selectors

@videojs/react/utils

Utility Purpose
renderElement(tag, props, params) Render with state, refs, props merge
mergeProps(...propSets) Chain events, concat className, merge style
composeRefs(...refs) Compose refs (React 19 cleanup support)

File Organization

packages/
├── core/src/
│   ├── core/
│   │   ├── ui/types.ts                        # StateAttrMap type
│   │   └── ui/{component}/
│   │       ├── {component}-core.ts            # Core class
│   │       ├── {component}-data-attrs.ts      # Data attr map (satisfies StateAttrMap)
│   │       └── tests/
│   │           └── {component}-core.test.ts   # Core tests
│   └── dom/ui/                                # createButton, utils
├── html/src/
│   ├── ui/{component}/                        # Web Component
│   ├── define/ui/                             # Side-effect registration
│   └── player/player-controller.ts            # Store controller
└── react/src/
    ├── ui/{component}/                        # React component
    ├── ui/hooks/                              # Behavior hooks
    └── 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

// define/ui/play-button.ts
customElements.define(PlayButtonElement.tagName, PlayButtonElement);

declare global {
  interface HTMLElementTagNameMap {
    [PlayButtonElement.tagName]: PlayButtonElement;
  }
}
  • Tag name: static readonly tagName = 'media-{name}'
  • Registration in define/ui/ directory
  • Augment HTMLElementTagNameMap for TypeScript