From 9cfab264d85ea5b8e20fc2d020171ba5ef53b0f4 Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 4 Feb 2026 00:33:14 +1100 Subject: [PATCH] feat(core): add play button component (#383) --- packages/core/src/core/element.ts | 8 + packages/core/src/core/index.ts | 3 + packages/core/src/core/media/state.ts | 36 ++ .../ui/play-button/play-button-core.test.ts | 170 +++++ .../core/ui/play-button/play-button-core.ts | 67 ++ .../ui/play-button/play-button-data-attrs.ts | 8 + packages/core/src/dom/feature.ts | 2 +- packages/core/src/dom/index.ts | 4 +- packages/core/src/dom/{ => media}/types.ts | 0 .../core/src/dom/store/features/playback.ts | 12 +- .../dom/store/features/tests/buffer.test.ts | 2 +- .../dom/store/features/tests/playback.test.ts | 2 +- .../dom/store/features/tests/source.test.ts | 2 +- .../src/dom/store/features/tests/time.test.ts | 2 +- .../dom/store/features/tests/volume.test.ts | 2 +- .../core/src/dom/store/features/volume.ts | 2 +- packages/core/src/dom/ui/button.ts | 66 ++ packages/core/src/dom/ui/event.ts | 9 + packages/core/src/dom/utils/element-props.ts | 28 + packages/core/src/dom/utils/index.ts | 3 + packages/core/src/dom/utils/log.ts | 9 + .../core/src/dom/utils/state-data-attrs.ts | 58 ++ .../dom/utils/tests/state-data-attrs.test.ts | 137 ++++ packages/html/src/define/ui/play-button.ts | 10 +- packages/html/src/index.ts | 3 + packages/html/src/player/player-controller.ts | 14 +- .../src/ui/play-button/play-button-element.ts | 68 ++ packages/react/src/index.ts | 11 + packages/react/src/ui/hooks/use-button.ts | 63 ++ .../react/src/ui/play-button/play-button.tsx | 67 ++ packages/react/src/utils/merge-props.ts | 113 ++++ .../react/src/utils/tests/merge-props.test.ts | 256 ++++++++ .../react/src/utils/tests/use-render.test.tsx | 597 ++++++++++++++++++ packages/react/src/utils/types.ts | 35 + packages/react/src/utils/use-composed-refs.ts | 19 +- packages/react/src/utils/use-render.tsx | 124 ++++ packages/store/src/core/selector.ts | 6 +- packages/utils/src/object/defaults.ts | 26 + packages/utils/src/object/index.ts | 1 + .../utils/src/object/tests/defaults.test.ts | 72 +++ packages/utils/src/types/types.ts | 4 + 41 files changed, 2083 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/core/element.ts create mode 100644 packages/core/src/core/media/state.ts create mode 100644 packages/core/src/core/ui/play-button/play-button-core.test.ts create mode 100644 packages/core/src/core/ui/play-button/play-button-core.ts create mode 100644 packages/core/src/core/ui/play-button/play-button-data-attrs.ts rename packages/core/src/dom/{ => media}/types.ts (100%) create mode 100644 packages/core/src/dom/ui/button.ts create mode 100644 packages/core/src/dom/ui/event.ts create mode 100644 packages/core/src/dom/utils/element-props.ts create mode 100644 packages/core/src/dom/utils/index.ts create mode 100644 packages/core/src/dom/utils/log.ts create mode 100644 packages/core/src/dom/utils/state-data-attrs.ts create mode 100644 packages/core/src/dom/utils/tests/state-data-attrs.test.ts create mode 100644 packages/html/src/ui/play-button/play-button-element.ts create mode 100644 packages/react/src/ui/hooks/use-button.ts create mode 100644 packages/react/src/ui/play-button/play-button.tsx create mode 100644 packages/react/src/utils/merge-props.ts create mode 100644 packages/react/src/utils/tests/merge-props.test.ts create mode 100644 packages/react/src/utils/tests/use-render.test.tsx create mode 100644 packages/react/src/utils/types.ts create mode 100644 packages/react/src/utils/use-render.tsx create mode 100644 packages/utils/src/object/defaults.ts create mode 100644 packages/utils/src/object/tests/defaults.test.ts diff --git a/packages/core/src/core/element.ts b/packages/core/src/core/element.ts new file mode 100644 index 00000000..41806da1 --- /dev/null +++ b/packages/core/src/core/element.ts @@ -0,0 +1,8 @@ +export interface ElementProps { + 'aria-label'?: string | undefined; + 'aria-disabled'?: string | undefined; + 'data-paused'?: boolean | undefined; + 'data-ended'?: boolean | undefined; + 'data-waiting'?: boolean | undefined; + 'data-unsupported'?: boolean | undefined; +} diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index e69de29b..770c76dc 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -0,0 +1,3 @@ +export * from './element'; +export * from './media/state'; +export * from './ui/play-button/play-button-core'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts new file mode 100644 index 00000000..a4ad1b6c --- /dev/null +++ b/packages/core/src/core/media/state.ts @@ -0,0 +1,36 @@ +export interface PlaybackState { + /** + * Whether playback is paused. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/paused + */ + paused: boolean; + /** + * Whether playback has reached the end. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended + */ + ended: boolean; + /** + * Whether playback has started (played or seeked). + */ + started: boolean; + /** + * Whether playback is stalled waiting for data. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/waiting_event + */ + waiting: boolean; + /** + * Start playback. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play + */ + play(): Promise; + /** + * Pause playback. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause + */ + pause(): void; +} diff --git a/packages/core/src/core/ui/play-button/play-button-core.test.ts b/packages/core/src/core/ui/play-button/play-button-core.test.ts new file mode 100644 index 00000000..1bb4885a --- /dev/null +++ b/packages/core/src/core/ui/play-button/play-button-core.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PlaybackState } from '../../media/state'; +import { PlayButtonCore } from './play-button-core'; + +function createMockPlayback(overrides: Partial = {}): PlaybackState { + return { + paused: true, + ended: false, + started: false, + waiting: false, + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn(), + ...overrides, + }; +} + +describe('PlayButtonCore', () => { + describe('getLabel', () => { + it('returns custom label when provided', () => { + const core = new PlayButtonCore({ label: 'Custom Label' }); + const playback = createMockPlayback(); + + expect(core.getLabel(playback)).toBe('Custom Label'); + }); + + it('returns "Replay" when ended', () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ ended: true }); + + expect(core.getLabel(playback)).toBe('Replay'); + }); + + it('returns "Play" when paused', () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: true }); + + expect(core.getLabel(playback)).toBe('Play'); + }); + + it('returns "Pause" when playing', () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: false }); + + expect(core.getLabel(playback)).toBe('Pause'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label based on playback state', () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: true }); + + const attrs = core.getAttrs(playback); + + expect(attrs['aria-label']).toBe('Play'); + }); + + it('returns aria-disabled when disabled', () => { + const core = new PlayButtonCore({ disabled: true }); + const playback = createMockPlayback(); + + const attrs = core.getAttrs(playback); + + expect(attrs['aria-disabled']).toBe('true'); + }); + + it('returns undefined aria-disabled when not disabled', () => { + const core = new PlayButtonCore({ disabled: false }); + const playback = createMockPlayback(); + + const attrs = core.getAttrs(playback); + + expect(attrs['aria-disabled']).toBeUndefined(); + }); + + it('does NOT return data-* attributes', () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: true, ended: false, waiting: true }); + + const attrs = core.getAttrs(playback); + + // Verify no data-* keys exist + const dataKeys = Object.keys(attrs).filter((key) => key.startsWith('data-')); + expect(dataKeys).toHaveLength(0); + }); + }); + + describe('getState', () => { + it('returns primitive values only (no methods)', () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: true, ended: false, started: true }); + + const state = core.getState(playback); + + expect(state).toEqual({ + paused: true, + ended: false, + started: true, + }); + + // Verify no functions in state + const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function'); + expect(functionKeys).toHaveLength(0); + }); + + it('includes paused from playback', () => { + const core = new PlayButtonCore(); + + expect(core.getState(createMockPlayback({ paused: true })).paused).toBe(true); + expect(core.getState(createMockPlayback({ paused: false })).paused).toBe(false); + }); + + it('includes ended from playback', () => { + const core = new PlayButtonCore(); + + expect(core.getState(createMockPlayback({ ended: true })).ended).toBe(true); + expect(core.getState(createMockPlayback({ ended: false })).ended).toBe(false); + }); + + it('includes started from playback', () => { + const core = new PlayButtonCore(); + + expect(core.getState(createMockPlayback({ started: true })).started).toBe(true); + expect(core.getState(createMockPlayback({ started: false })).started).toBe(false); + }); + }); + + describe('toggle', () => { + it('calls play() when paused', async () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: true }); + + await core.toggle(playback); + + expect(playback.play).toHaveBeenCalledTimes(1); + expect(playback.pause).not.toHaveBeenCalled(); + }); + + it('calls play() when ended', async () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: false, ended: true }); + + await core.toggle(playback); + + expect(playback.play).toHaveBeenCalledTimes(1); + expect(playback.pause).not.toHaveBeenCalled(); + }); + + it('calls pause() when playing', async () => { + const core = new PlayButtonCore(); + const playback = createMockPlayback({ paused: false, ended: false }); + + await core.toggle(playback); + + expect(playback.pause).toHaveBeenCalledTimes(1); + expect(playback.play).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled', async () => { + const core = new PlayButtonCore({ disabled: true }); + const playback = createMockPlayback({ paused: true }); + + await core.toggle(playback); + + expect(playback.play).not.toHaveBeenCalled(); + expect(playback.pause).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts new file mode 100644 index 00000000..6bca9cb2 --- /dev/null +++ b/packages/core/src/core/ui/play-button/play-button-core.ts @@ -0,0 +1,67 @@ +import { defaults } from '@videojs/utils/object'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { ElementProps } from '../../element'; +import type { PlaybackState } from '../../media/state'; + +export interface PlayButtonProps { + /** Custom label for the button. */ + label?: string | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export interface PlayButtonState extends Pick {} + +export class PlayButtonCore { + static readonly defaultProps: NonNullableObject = { + label: '', + disabled: false, + }; + + #props = { ...PlayButtonCore.defaultProps }; + + constructor(props?: PlayButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: PlayButtonProps): void { + this.#props = defaults(props, PlayButtonCore.defaultProps); + } + + getLabel(playback: PlaybackState): string { + if (this.#props.label) return this.#props.label; + if (playback.ended) return 'Replay'; + return playback.paused ? 'Play' : 'Pause'; + } + + getAttrs(playback: PlaybackState): ElementProps { + return { + 'aria-label': this.getLabel(playback), + 'aria-disabled': this.#props.disabled ? 'true' : undefined, + }; + } + + getState(playback: PlaybackState): PlayButtonState { + return { + paused: playback.paused, + ended: playback.ended, + started: playback.started, + }; + } + + async toggle(playback: PlaybackState): Promise { + if (this.#props.disabled) return; + + if (playback.paused || playback.ended) { + return playback.play(); + } + + playback.pause(); + } +} + +export namespace PlayButtonCore { + export type Props = PlayButtonProps; + export type State = PlayButtonState; +} diff --git a/packages/core/src/core/ui/play-button/play-button-data-attrs.ts b/packages/core/src/core/ui/play-button/play-button-data-attrs.ts new file mode 100644 index 00000000..136dd2d5 --- /dev/null +++ b/packages/core/src/core/ui/play-button/play-button-data-attrs.ts @@ -0,0 +1,8 @@ +export enum 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', +} diff --git a/packages/core/src/dom/feature.ts b/packages/core/src/dom/feature.ts index b976b59f..07a1a460 100644 --- a/packages/core/src/dom/feature.ts +++ b/packages/core/src/dom/feature.ts @@ -1,5 +1,5 @@ import { defineSlice } from '@videojs/store'; -import type { PlayerTarget } from './types'; +import type { PlayerTarget } from './media/types'; export const definePlayerFeature = defineSlice(); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 32e0316a..c6c1f792 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -1,4 +1,6 @@ export * from './feature'; +export * from './media/types'; export * from './store/features'; export * from './store/selectors'; -export * from './types'; +export * from './ui/button'; +export * from './utils'; diff --git a/packages/core/src/dom/types.ts b/packages/core/src/dom/media/types.ts similarity index 100% rename from packages/core/src/dom/types.ts rename to packages/core/src/dom/media/types.ts diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index 5ac1b161..b0696365 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -1,23 +1,17 @@ -import type { InferSliceState } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; +import type { PlaybackState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; export const playbackFeature = definePlayerFeature({ - state: ({ target }) => ({ - /** Whether playback is paused. */ + state: ({ target }): PlaybackState => ({ paused: true, - /** Whether playback has reached the end. */ ended: false, - /** Whether playback has started (played or seeked). */ started: false, - /** Whether playback is stalled waiting for data. */ waiting: false, - /** Start playback. */ play() { return target().media.play(); }, - /** Pause playback immediately. */ pause() { target().media.pause(); }, @@ -43,5 +37,3 @@ export const playbackFeature = definePlayerFeature({ listen(media, 'waiting', sync, { signal }); }, }); - -export type PlaybackState = InferSliceState; diff --git a/packages/core/src/dom/store/features/tests/buffer.test.ts b/packages/core/src/dom/store/features/tests/buffer.test.ts index c1f4d270..157d7659 100644 --- a/packages/core/src/dom/store/features/tests/buffer.test.ts +++ b/packages/core/src/dom/store/features/tests/buffer.test.ts @@ -1,7 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; -import type { PlayerTarget } from '../../../types'; +import type { PlayerTarget } from '../../../media/types'; import { bufferFeature } from '../buffer'; describe('bufferFeature', () => { diff --git a/packages/core/src/dom/store/features/tests/playback.test.ts b/packages/core/src/dom/store/features/tests/playback.test.ts index 406311f9..287f2a57 100644 --- a/packages/core/src/dom/store/features/tests/playback.test.ts +++ b/packages/core/src/dom/store/features/tests/playback.test.ts @@ -1,7 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; -import type { PlayerTarget } from '../../../types'; +import type { PlayerTarget } from '../../../media/types'; import { playbackFeature } from '../playback'; describe('playbackFeature', () => { diff --git a/packages/core/src/dom/store/features/tests/source.test.ts b/packages/core/src/dom/store/features/tests/source.test.ts index 0fd6c9bb..cd409635 100644 --- a/packages/core/src/dom/store/features/tests/source.test.ts +++ b/packages/core/src/dom/store/features/tests/source.test.ts @@ -1,7 +1,7 @@ import { combine, createStore } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; -import type { PlayerTarget } from '../../../types'; +import type { PlayerTarget } from '../../../media/types'; import { sourceFeature } from '../source'; import { timeFeature } from '../time'; diff --git a/packages/core/src/dom/store/features/tests/time.test.ts b/packages/core/src/dom/store/features/tests/time.test.ts index da9fd419..606ba265 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -1,7 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; -import type { PlayerTarget } from '../../../types'; +import type { PlayerTarget } from '../../../media/types'; import { timeFeature } from '../time'; describe('timeFeature', () => { diff --git a/packages/core/src/dom/store/features/tests/volume.test.ts b/packages/core/src/dom/store/features/tests/volume.test.ts index 5d53254f..bbf1ad01 100644 --- a/packages/core/src/dom/store/features/tests/volume.test.ts +++ b/packages/core/src/dom/store/features/tests/volume.test.ts @@ -1,7 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; -import type { PlayerTarget } from '../../../types'; +import type { PlayerTarget } from '../../../media/types'; import { volumeFeature } from '../volume'; describe('volumeFeature', () => { diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts index 3ac43dec..450234cd 100644 --- a/packages/core/src/dom/store/features/volume.ts +++ b/packages/core/src/dom/store/features/volume.ts @@ -2,7 +2,7 @@ import type { InferSliceState } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; import { definePlayerFeature } from '../../feature'; -import type { FeatureAvailability } from '../../types'; +import type { FeatureAvailability } from '../../media/types'; export const volumeFeature = definePlayerFeature({ state: ({ target }) => ({ diff --git a/packages/core/src/dom/ui/button.ts b/packages/core/src/dom/ui/button.ts new file mode 100644 index 00000000..cb5a759a --- /dev/null +++ b/packages/core/src/dom/ui/button.ts @@ -0,0 +1,66 @@ +import type { UIKeyboardEvent } from './event'; + +export interface ButtonOptions { + onActivate: () => void; + isDisabled: () => boolean; +} + +export interface ButtonProps { + role: 'button'; + tabIndex: 0; + onClick: (event: UIEvent) => void; + onPointerDown: (event: UIEvent) => void; + onMouseDown: (event: UIEvent) => void; + onKeyDown: (event: UIKeyboardEvent) => void; + onKeyUp: (event: UIKeyboardEvent) => void; +} + +export function createButton(options: ButtonOptions): ButtonProps { + const { onActivate, isDisabled } = options; + + return { + role: 'button', + tabIndex: 0, + + onClick(event) { + if (isDisabled()) { + event.preventDefault(); + return; + } + onActivate(); + }, + + onPointerDown(event) { + if (isDisabled()) event.preventDefault(); + }, + + onMouseDown(event) { + if (isDisabled()) event.preventDefault(); + }, + + onKeyDown(event) { + if (event.target !== event.currentTarget) return; + + if (isDisabled()) { + if (event.key !== 'Tab') event.preventDefault(); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + onActivate(); + } else if (event.key === ' ') { + event.preventDefault(); + } + }, + + onKeyUp(event) { + if (event.target !== event.currentTarget) return; + if (isDisabled()) return; + + if (event.key === ' ') { + onActivate(); + } + }, + }; +} diff --git a/packages/core/src/dom/ui/event.ts b/packages/core/src/dom/ui/event.ts new file mode 100644 index 00000000..96bc4e36 --- /dev/null +++ b/packages/core/src/dom/ui/event.ts @@ -0,0 +1,9 @@ +export interface UIEvent { + preventDefault(): void; +} + +export interface UIKeyboardEvent extends UIEvent { + key: string; + target: Node; + currentTarget: Node; +} diff --git a/packages/core/src/dom/utils/element-props.ts b/packages/core/src/dom/utils/element-props.ts new file mode 100644 index 00000000..222349d8 --- /dev/null +++ b/packages/core/src/dom/utils/element-props.ts @@ -0,0 +1,28 @@ +import { listen } from '@videojs/utils/dom'; +import { isFunction, isUndefined } from '@videojs/utils/predicate'; + +import type { ElementProps } from '../../core/element'; + +/** + * Apply props to a DOM element. + * + * Handles both attributes and event listeners: + * - Event props (onClick, onKeyDown, etc.) are attached as listeners + * - Boolean props: `true` sets empty attribute, `false` removes + * - `undefined` removes the attribute + * - Other props are set as string attributes + */ +export function applyElementProps(element: HTMLElement, props: ElementProps | object, signal?: AbortSignal): void { + for (const [key, value] of Object.entries(props)) { + if (isFunction(value) && key.startsWith('on')) { + const event = key.slice(2).toLowerCase(); + listen(element, event, value as EventListener, signal ? { signal } : undefined); + } else if (isUndefined(value) || value === false) { + element.removeAttribute(key); + } else if (value === true) { + element.setAttribute(key, ''); + } else { + element.setAttribute(key, String(value)); + } + } +} diff --git a/packages/core/src/dom/utils/index.ts b/packages/core/src/dom/utils/index.ts new file mode 100644 index 00000000..b019a85e --- /dev/null +++ b/packages/core/src/dom/utils/index.ts @@ -0,0 +1,3 @@ +export { applyElementProps } from './element-props'; +export { logMissingFeature } from './log'; +export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs'; diff --git a/packages/core/src/dom/utils/log.ts b/packages/core/src/dom/utils/log.ts new file mode 100644 index 00000000..36577f86 --- /dev/null +++ b/packages/core/src/dom/utils/log.ts @@ -0,0 +1,9 @@ +const warned = new Set(); + +export function logMissingFeature(displayName: string, featureName: string): void { + const key = `${displayName}:${featureName}`; + if (warned.has(key)) return; + + warned.add(key); + console.warn(`${displayName} requires ${featureName} feature`); +} diff --git a/packages/core/src/dom/utils/state-data-attrs.ts b/packages/core/src/dom/utils/state-data-attrs.ts new file mode 100644 index 00000000..ad7aa94b --- /dev/null +++ b/packages/core/src/dom/utils/state-data-attrs.ts @@ -0,0 +1,58 @@ +/** + * Convert state object to data attributes. + * + * - `true` → `data-keyname=""` + * - truthy string/number → `data-keyname="value"` + * - falsy → no attribute + * + * @example + * ```ts + * const state = { paused: true, ended: false, volume: 0.5 }; + * getStateDataAttrs(state); + * // { 'data-paused': '', 'data-volume': '0.5' } + * ``` + */ +export function getStateDataAttrs(state: State): Record { + const attrs: Record = {}; + + for (const key in state) { + const value = state[key]; + + if (value === true) { + attrs[`data-${key.toLowerCase()}`] = ''; + } else if (value) { + attrs[`data-${key.toLowerCase()}`] = String(value); + } + } + + return attrs; +} + +/** + * Apply state as data attributes to an element. + * + * - `true` → sets `data-keyname=""` + * - truthy string/number → sets `data-keyname="value"` + * - falsy → removes the attribute + * + * @example + * ```ts + * const state = { paused: true, ended: false }; + * applyStateDataAttrs(element, state); + * // element has data-paused="", data-ended is removed + * ``` + */ +export function applyStateDataAttrs(element: HTMLElement, state: State): void { + for (const key in state) { + const value = state[key]; + const attrName = `data-${key.toLowerCase()}`; + + if (value === true) { + element.setAttribute(attrName, ''); + } else if (value) { + element.setAttribute(attrName, String(value)); + } else { + element.removeAttribute(attrName); + } + } +} diff --git a/packages/core/src/dom/utils/tests/state-data-attrs.test.ts b/packages/core/src/dom/utils/tests/state-data-attrs.test.ts new file mode 100644 index 00000000..191ee2e0 --- /dev/null +++ b/packages/core/src/dom/utils/tests/state-data-attrs.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { applyStateDataAttrs, getStateDataAttrs } from '../state-data-attrs'; + +describe('getStateDataAttrs', () => { + it('converts true to empty string attribute', () => { + const state = { paused: true }; + expect(getStateDataAttrs(state)).toEqual({ 'data-paused': '' }); + }); + + it('ignores false values', () => { + const state = { paused: false }; + expect(getStateDataAttrs(state)).toEqual({}); + }); + + it('ignores undefined values', () => { + const state = { paused: undefined }; + expect(getStateDataAttrs(state)).toEqual({}); + }); + + it('ignores null values', () => { + const state = { paused: null }; + expect(getStateDataAttrs(state)).toEqual({}); + }); + + it('converts string values to attribute', () => { + const state = { status: 'loading' }; + expect(getStateDataAttrs(state)).toEqual({ 'data-status': 'loading' }); + }); + + it('converts number values to string attribute', () => { + const state = { volume: 0.5 }; + expect(getStateDataAttrs(state)).toEqual({ 'data-volume': '0.5' }); + }); + + it('ignores zero values', () => { + const state = { volume: 0 }; + expect(getStateDataAttrs(state)).toEqual({}); + }); + + it('converts keys to lowercase', () => { + const state = { isPaused: true, currentTime: 10 }; + expect(getStateDataAttrs(state)).toEqual({ + 'data-ispaused': '', + 'data-currenttime': '10', + }); + }); + + it('handles multiple state properties', () => { + const state = { paused: true, ended: false, waiting: true, volume: 0.8 }; + expect(getStateDataAttrs(state)).toEqual({ + 'data-paused': '', + 'data-waiting': '', + 'data-volume': '0.8', + }); + }); + + it('returns empty object for empty state', () => { + const state = {}; + expect(getStateDataAttrs(state)).toEqual({}); + }); + + it('ignores empty string values', () => { + const state = { label: '' }; + expect(getStateDataAttrs(state)).toEqual({}); + }); +}); + +describe('applyStateDataAttrs', () => { + it('applies data attributes to element', () => { + const element = document.createElement('div'); + const state = { paused: true, volume: 0.5 }; + + applyStateDataAttrs(element, state); + + expect(element.getAttribute('data-paused')).toBe(''); + expect(element.getAttribute('data-volume')).toBe('0.5'); + }); + + it('does not apply falsy values', () => { + const element = document.createElement('div'); + const state = { paused: false, ended: null, waiting: undefined }; + + applyStateDataAttrs(element, state); + + expect(element.hasAttribute('data-paused')).toBe(false); + expect(element.hasAttribute('data-ended')).toBe(false); + expect(element.hasAttribute('data-waiting')).toBe(false); + }); + + it('applies multiple attributes', () => { + const element = document.createElement('div'); + const state = { paused: true, ended: true, started: true }; + + applyStateDataAttrs(element, state); + + expect(element.getAttribute('data-paused')).toBe(''); + expect(element.getAttribute('data-ended')).toBe(''); + expect(element.getAttribute('data-started')).toBe(''); + }); + + it('removes attributes when state becomes false', () => { + const element = document.createElement('div'); + + applyStateDataAttrs(element, { paused: true }); + expect(element.hasAttribute('data-paused')).toBe(true); + + applyStateDataAttrs(element, { paused: false }); + expect(element.hasAttribute('data-paused')).toBe(false); + }); + + it('removes attributes when state becomes null or undefined', () => { + const element = document.createElement('div'); + + applyStateDataAttrs(element, { status: 'loading' }); + expect(element.getAttribute('data-status')).toBe('loading'); + + applyStateDataAttrs(element, { status: null }); + expect(element.hasAttribute('data-status')).toBe(false); + }); + + it('handles state transitions correctly', () => { + const element = document.createElement('div'); + + // Initial state + applyStateDataAttrs(element, { paused: true, ended: false, volume: 0.5 }); + expect(element.hasAttribute('data-paused')).toBe(true); + expect(element.hasAttribute('data-ended')).toBe(false); + expect(element.getAttribute('data-volume')).toBe('0.5'); + + // Transition: paused false, ended true, volume 0 + applyStateDataAttrs(element, { paused: false, ended: true, volume: 0 }); + expect(element.hasAttribute('data-paused')).toBe(false); + expect(element.hasAttribute('data-ended')).toBe(true); + expect(element.hasAttribute('data-volume')).toBe(false); + }); +}); diff --git a/packages/html/src/define/ui/play-button.ts b/packages/html/src/define/ui/play-button.ts index d685caf4..2600dea5 100644 --- a/packages/html/src/define/ui/play-button.ts +++ b/packages/html/src/define/ui/play-button.ts @@ -1 +1,9 @@ -// TODO: Implement PlayButtonElement then register it here +import { PlayButtonElement } from '../../ui/play-button/play-button-element'; + +customElements.define(PlayButtonElement.tagName, PlayButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [PlayButtonElement.tagName]: PlayButtonElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 57d4fda9..5d620549 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -16,3 +16,6 @@ export * from './store/types'; // Primitives export * from './ui/media-element'; + +// UI Components +export { PlayButtonElement } from './ui/play-button/play-button-element'; diff --git a/packages/html/src/player/player-controller.ts b/packages/html/src/player/player-controller.ts index 1f75f376..7d9c3116 100644 --- a/packages/html/src/player/player-controller.ts +++ b/packages/html/src/player/player-controller.ts @@ -36,7 +36,7 @@ export class PlayerController impleme readonly #selector: Selector, Result> | undefined; #consumer: ContextConsumer, PlayerControllerHost>; - #storeCtrl: StoreController | null = null; + #store: StoreController | null = null; constructor(host: PlayerControllerHost, context: PlayerContext); constructor( @@ -61,15 +61,15 @@ export class PlayerController impleme host.addController(this); } - get value(): Result { + get value(): Result | undefined { const ctx = this.#consumer.value; - if (!ctx) throw new Error('Player context not available'); + if (!ctx) return undefined; // Without selector: return store directly if (!this.#selector) return ctx.store as unknown as Result; // With selector: use StoreController - return this.#storeCtrl!.value; + return this.#store?.value; } hostConnected(): void { @@ -78,15 +78,15 @@ export class PlayerController impleme } hostDisconnected(): void { - this.#storeCtrl = null; + this.#store = null; } #connect(ctx: PlayerContextValue | undefined): void { if (!ctx) return; // Create StoreController with the store directly - if (!this.#storeCtrl && this.#selector) { - this.#storeCtrl = new StoreController(this.#host, ctx.store, this.#selector); + if (!this.#store && this.#selector) { + this.#store = new StoreController(this.#host, ctx.store, this.#selector); } } } diff --git a/packages/html/src/ui/play-button/play-button-element.ts b/packages/html/src/ui/play-button/play-button-element.ts new file mode 100644 index 00000000..ba48b8a2 --- /dev/null +++ b/packages/html/src/ui/play-button/play-button-element.ts @@ -0,0 +1,68 @@ +import type { PropertyValues } from '@lit/reactive-element'; +import { PlayButtonCore } from '@videojs/core'; +import { + applyElementProps, + applyStateDataAttrs, + createButton, + logMissingFeature, + selectPlayback, +} from '@videojs/core/dom'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; + +export class PlayButtonElement extends MediaElement { + static readonly tagName = 'media-play-button'; + + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + }; + + label = ''; + disabled = false; + + readonly #core = new PlayButtonCore(); + readonly #state = new PlayerController(this, playerContext, selectPlayback); + + #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); + } + + 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) { + logMissingFeature(PlayButtonElement.tagName, 'playback'); + 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 e45650a9..4f954b87 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -28,3 +28,14 @@ export { createPlayer, type ProviderProps, } from './player/create-player'; + +// UI Hooks +export { useButton } from './ui/hooks/use-button'; + +// UI Components +export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; + +// Utilities +export { mergeProps } from './utils/merge-props'; +export type { HTMLProps, RenderFunction, RenderProp, UIComponentProps } from './utils/types'; +export { renderElement } from './utils/use-render'; diff --git a/packages/react/src/ui/hooks/use-button.ts b/packages/react/src/ui/hooks/use-button.ts new file mode 100644 index 00000000..f9329717 --- /dev/null +++ b/packages/react/src/ui/hooks/use-button.ts @@ -0,0 +1,63 @@ +'use client'; + +import { createButton } from '@videojs/core/dom'; +import type { ComponentPropsWithRef, Ref } from 'react'; +import { useCallback } from 'react'; +import { mergeProps } from '../../utils/merge-props'; + +export interface UseButtonParameters { + displayName: string; + onActivate: () => void; + isDisabled: () => boolean; +} + +export interface UseButtonReturnValue { + getButtonProps: (externalProps?: ComponentPropsWithRef<'button'>) => ComponentPropsWithRef<'button'>; + buttonRef: Ref; +} + +/** + * Hook for button behavior with keyboard and pointer interaction. + * + * @example + * ```tsx + * const { getButtonProps, buttonRef } = useButton({ + * displayName: 'PlayButton', + * onActivate: () => togglePlayback(), + * isDisabled: () => disabled, + * }); + * + * return useRender('button', componentProps, { + * state, + * ref: [forwardedRef, buttonRef], + * props: [elementProps, getButtonProps], + * }); + * ``` + */ +export function useButton(params: UseButtonParameters): UseButtonReturnValue { + const { displayName, onActivate, isDisabled } = params; + + const buttonRef = useCallback( + (element: HTMLElement | null) => { + if (element && element.tagName !== 'BUTTON') { + console.warn(`${displayName} should render a + * )} + * /> + * ``` + */ +export const PlayButton = forwardRef(function PlayButton( + componentProps: PlayButtonProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, label, disabled = false, ...elementProps } = componentProps; + + const playback = usePlayer(selectPlayback); + + const [core] = useState(() => new PlayButtonCore()); + core.setProps({ label, disabled }); + + const { getButtonProps, buttonRef } = useButton({ + displayName: 'PlayButton', + onActivate: () => core.toggle(playback!), + isDisabled: () => disabled || !playback, + }); + + if (!playback) { + logMissingFeature('PlayButton', 'playback'); + return null; + } + + return renderElement( + 'button', + { render, className, style }, + { + state: core.getState(playback), + ref: [forwardedRef, buttonRef], + props: [core.getAttrs(playback), elementProps, getButtonProps()], + } + ); +}); + +export namespace PlayButton { + export type Props = PlayButtonProps; + export type State = PlayButtonCore.State; +} diff --git a/packages/react/src/utils/merge-props.ts b/packages/react/src/utils/merge-props.ts new file mode 100644 index 00000000..a9b7f22b --- /dev/null +++ b/packages/react/src/utils/merge-props.ts @@ -0,0 +1,113 @@ +'use client'; + +import type { ComponentPropsWithRef, CSSProperties, ElementType, SyntheticEvent } from 'react'; + +type Props = ComponentPropsWithRef; + +/** + * Check if a key is an event handler key (on* with capital letter). + */ +function isEventHandlerKey(key: string): boolean { + return ( + key.charCodeAt(0) === 111 /* o */ && + key.charCodeAt(1) === 110 /* n */ && + key.charCodeAt(2) >= 65 /* A */ && + key.charCodeAt(2) <= 90 /* Z */ + ); +} + +/** + * Check if a key/value pair is an event handler (includes undefined values). + */ +function isEventHandler(key: string, value: unknown): boolean { + return isEventHandlerKey(key) && (typeof value === 'function' || typeof value === 'undefined'); +} + +/** + * Merge two event handlers - external runs first, ours runs second. + */ +function mergeEventHandlers( + ours: ((event: SyntheticEvent) => void) | undefined, + theirs: ((event: SyntheticEvent) => void) | undefined +): ((event: SyntheticEvent) => void) | undefined { + if (!theirs) return ours; + if (!ours) return theirs; + + return (event: SyntheticEvent) => { + theirs(event); + ours(event); + }; +} + +/** + * Merge two className values - concatenate strings. + */ +function mergeClassNames(ours: string | undefined, theirs: string | undefined): string | undefined { + if (theirs && ours) return `${theirs} ${ours}`; + return theirs || ours; +} + +/** + * Merge two style objects - theirs overwrites conflicts. + */ +function mergeStyles(ours: CSSProperties | undefined, theirs: CSSProperties | undefined): CSSProperties | undefined { + if (!theirs) return ours; + if (!ours) return theirs; + return { ...ours, ...theirs }; +} + +/** + * Merge a single props object into accumulated result. + */ +function mergeOne( + merged: Record, + props: Props | undefined +): Record { + if (!props) return merged; + + for (const key in props) { + const value = props[key as keyof typeof props]; + + if (key === 'className') { + merged.className = mergeClassNames(merged.className as string | undefined, value as string); + } else if (key === 'style') { + merged.style = mergeStyles(merged.style as CSSProperties | undefined, value as CSSProperties); + } else if (isEventHandler(key, value)) { + merged[key] = mergeEventHandlers( + merged[key] as ((event: SyntheticEvent) => void) | undefined, + value as (event: SyntheticEvent) => void + ); + } else { + merged[key] = value; + } + } + + return merged; +} + +/** + * Merge multiple props objects. + * + * - Event handlers (on*): chained - external first, ours second + * - className: concatenated + * - style: merged objects (external wins conflicts) + * - other: last one wins + * + * @example + * ```ts + * const merged = mergeProps( + * { onClick: ourHandler, className: 'base' }, + * { onClick: theirHandler, className: 'custom' } + * ); + * // { onClick: chainedHandler, className: 'custom base' } + * ``` + */ +export function mergeProps(...propSets: (Props | undefined)[]): Props { + let merged: Record = {}; + + for (const props of propSets) { + merged = mergeOne(merged, props); + } + + return merged as Props; +} diff --git a/packages/react/src/utils/tests/merge-props.test.ts b/packages/react/src/utils/tests/merge-props.test.ts new file mode 100644 index 00000000..ebb7940d --- /dev/null +++ b/packages/react/src/utils/tests/merge-props.test.ts @@ -0,0 +1,256 @@ +import type { MouseEvent } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { mergeProps } from '../merge-props'; + +// Create a minimal mock event for testing +function createMockEvent(): MouseEvent { + return { type: 'click' } as MouseEvent; +} + +describe('mergeProps', () => { + describe('event handlers', () => { + it('merges two event handlers', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + const merged = mergeProps<'button'>({ onClick: handler1 }, { onClick: handler2 }); + + merged.onClick?.(createMockEvent()); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + + it('calls handlers in right-to-left order (rightmost first)', () => { + const log: string[] = []; + + const merged = mergeProps<'button'>( + { onClick: () => log.push('1') }, + { onClick: () => log.push('2') }, + { onClick: () => log.push('3') } + ); + + merged.onClick?.(createMockEvent()); + + expect(log).toEqual(['3', '2', '1']); + }); + + it('chains multiple event handlers', () => { + const handlers = [vi.fn(), vi.fn(), vi.fn(), vi.fn()]; + + const merged = mergeProps<'button'>( + { onClick: handlers[0] }, + { onClick: handlers[1] }, + { onClick: handlers[2] }, + { onClick: handlers[3] } + ); + + merged.onClick?.(createMockEvent()); + + for (const handler of handlers) { + expect(handler).toHaveBeenCalledTimes(1); + } + }); + + it('skips undefined handlers', () => { + const log: string[] = []; + + const merged = mergeProps<'button'>( + { onClick: () => log.push('1') }, + { onClick: undefined }, + { onClick: () => log.push('3') } + ); + + merged.onClick?.(createMockEvent()); + + expect(log).toEqual(['3', '1']); + }); + + it('handles onKeyDown event handlers', () => { + const log: string[] = []; + + const merged = mergeProps<'button'>({ onKeyDown: () => log.push('1') }, { onKeyDown: () => log.push('2') }); + + merged.onKeyDown?.({} as React.KeyboardEvent); + + expect(log).toEqual(['2', '1']); + }); + + it('returns single handler if only one defined', () => { + const handler = vi.fn(); + + const merged = mergeProps<'button'>({ onClick: handler }, { title: 'test' }); + + merged.onClick?.(createMockEvent()); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('passes event to all handlers', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const event = createMockEvent(); + + const merged = mergeProps<'button'>({ onClick: handler1 }, { onClick: handler2 }); + + merged.onClick?.(event); + + expect(handler1).toHaveBeenCalledWith(event); + expect(handler2).toHaveBeenCalledWith(event); + }); + }); + + describe('className', () => { + it('concatenates classNames with rightmost first', () => { + const merged = mergeProps<'div'>({ className: 'base' }, { className: 'custom' }); + + expect(merged.className).toBe('custom base'); + }); + + it('concatenates multiple classNames', () => { + const merged = mergeProps<'div'>({ className: 'a' }, { className: 'b' }, { className: 'c' }); + + expect(merged.className).toBe('c b a'); + }); + + it('returns single className if only one defined', () => { + const merged = mergeProps<'div'>({ className: 'only' }, { id: 'test' }); + + expect(merged.className).toBe('only'); + }); + + it('returns undefined if no classNames defined', () => { + const merged = mergeProps<'div'>({ id: 'test' }, { title: 'hello' }); + + expect(merged.className).toBeUndefined(); + }); + + it('handles undefined className in middle', () => { + const merged = mergeProps<'div'>({ className: 'a' }, { className: undefined }, { className: 'c' }); + + expect(merged.className).toBe('c a'); + }); + }); + + describe('style', () => { + it('merges style objects with rightmost winning conflicts', () => { + const merged = mergeProps<'div'>( + { style: { color: 'blue', backgroundColor: 'blue' } }, + { style: { color: 'red' } } + ); + + expect(merged.style).toEqual({ + color: 'red', + backgroundColor: 'blue', + }); + }); + + it('merges multiple style objects', () => { + const merged = mergeProps<'div'>( + { style: { color: 'blue' } }, + { style: { backgroundColor: 'green' } }, + { style: { color: 'red', border: '1px solid' } } + ); + + expect(merged.style).toEqual({ + color: 'red', + backgroundColor: 'green', + border: '1px solid', + }); + }); + + it('returns single style if only one defined', () => { + const style = { color: 'red' }; + const merged = mergeProps<'div'>({ style }, { id: 'test' }); + + expect(merged.style).toEqual(style); + }); + + it('returns undefined if no styles defined', () => { + const merged = mergeProps<'div'>({ id: 'test' }, { title: 'hello' }); + + expect(merged.style).toBeUndefined(); + }); + + it('handles undefined style in middle', () => { + const merged = mergeProps<'div'>( + { style: { color: 'blue' } }, + { style: undefined }, + { style: { backgroundColor: 'red' } } + ); + + expect(merged.style).toEqual({ + color: 'blue', + backgroundColor: 'red', + }); + }); + }); + + describe('regular props', () => { + it('overwrites with rightmost value (last wins)', () => { + const merged = mergeProps<'button'>({ title: 'first' }, { title: 'second' }, { title: 'third' }); + + expect(merged.title).toBe('third'); + }); + + it('preserves non-conflicting props from all sources', () => { + const merged = mergeProps<'button'>({ id: 'my-id' }, { role: 'button' }, { 'aria-label': 'Click me' }); + + expect(merged.id).toBe('my-id'); + expect(merged.role).toBe('button'); + expect(merged['aria-label']).toBe('Click me'); + }); + + it('handles boolean props', () => { + const merged = mergeProps<'button'>({ disabled: true }, { disabled: false }); + + expect(merged.disabled).toBe(false); + }); + + it('handles aria attributes', () => { + const merged = mergeProps<'button'>({ 'aria-pressed': true }, { 'aria-disabled': false }); + + expect(merged['aria-pressed']).toBe(true); + expect(merged['aria-disabled']).toBe(false); + }); + }); + + describe('edge cases', () => { + it('handles undefined prop sets', () => { + const handler = vi.fn(); + const merged = mergeProps<'button'>({ onClick: handler }, undefined, { title: 'test' }); + + expect(merged.onClick).toBe(handler); + expect(merged.title).toBe('test'); + }); + + it('handles empty prop sets', () => { + const handler = vi.fn(); + const merged = mergeProps<'button'>({}, { onClick: handler }, {}); + + expect(merged.onClick).toBe(handler); + }); + + it('returns empty object for no arguments', () => { + const merged = mergeProps<'button'>(); + + expect(merged).toEqual({}); + }); + + it('returns empty object for all undefined arguments', () => { + const merged = mergeProps<'button'>(undefined, undefined); + + expect(merged).toEqual({}); + }); + + it('does not merge ref (just overwrites)', () => { + const ref1 = { current: null }; + const ref2 = { current: null }; + + const merged = mergeProps<'button'>({ ref: ref1 }, { ref: ref2 }); + + expect(merged.ref).toBe(ref2); + }); + }); +}); diff --git a/packages/react/src/utils/tests/use-render.test.tsx b/packages/react/src/utils/tests/use-render.test.tsx new file mode 100644 index 00000000..0013be84 --- /dev/null +++ b/packages/react/src/utils/tests/use-render.test.tsx @@ -0,0 +1,597 @@ +import { cleanup, render } from '@testing-library/react'; +import type { ForwardedRef, Ref } from 'react'; +import { createRef, forwardRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderElement } from '../use-render'; + +afterEach(cleanup); + +interface TestState { + active?: boolean; +} + +interface TestComponentProps extends renderElement.ComponentProps { + active?: boolean; + id?: string; + title?: string; + onClick?: () => void; + 'data-testid'?: string; +} + +const TestComponent = forwardRef(function TestComponent(props: TestComponentProps, ref: ForwardedRef) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [{ 'data-component': 'test' }, elementProps], + } + ); +}); + +describe('renderElement', () => { + describe('default tag rendering', () => { + it('renders the specified element tag', () => { + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.tagName).toBe('DIV'); + }); + + it('spreads props onto the element', () => { + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('id')).toBe('my-id'); + expect(element?.getAttribute('title')).toBe('my-title'); + }); + + it('includes internal props', () => { + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-component')).toBe('test'); + }); + }); + + describe('className', () => { + it('accepts className as string', () => { + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.className).toContain('my-class'); + }); + + it('accepts className as function of state', () => { + const { container } = render( + (state.active ? 'active' : 'inactive')} /> + ); + const element = container.firstElementChild; + + expect(element?.className).toContain('active'); + expect(element?.className).not.toContain('inactive'); + }); + + it('handles className function returning undefined', () => { + const { container } = render( (state.active ? 'active' : undefined)} />); + const element = container.firstElementChild; + + expect(element?.className).not.toContain('active'); + }); + + it('merges className with props className', () => { + // Create a component that has internal className in props + const ComponentWithInternalClass = forwardRef(function ComponentWithInternalClass( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [{ className: 'internal-class' }, elementProps], + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.className).toContain('internal-class'); + expect(element?.className).toContain('external-class'); + }); + }); + + describe('style', () => { + it('accepts style as object', () => { + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + expect(element?.style.color).toBe('red'); + }); + + it('accepts style as function of state', () => { + const { container } = render( + ({ color: state.active ? 'green' : 'red' })} /> + ); + const element = container.firstElementChild as HTMLElement; + + expect(element?.style.color).toBe('green'); + }); + + it('handles style function returning undefined', () => { + const { container } = render( + (state.active ? { color: 'green' } : undefined)} /> + ); + const element = container.firstElementChild as HTMLElement; + + expect(element?.style.color).toBe(''); + }); + + it('merges style with props style', () => { + const ComponentWithInternalStyle = forwardRef(function ComponentWithInternalStyle( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [{ style: { padding: '10px' } }, elementProps], + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + expect(element?.style.padding).toBe('10px'); + expect(element?.style.color).toBe('red'); + }); + }); + + describe('render prop as function', () => { + it('calls render function with merged props and state', () => { + const renderFn = vi.fn((props, state) => ); + + const { container } = render(); + + expect(renderFn).toHaveBeenCalled(); + + const [receivedProps, receivedState] = renderFn.mock.calls[0]!; + expect(receivedProps['data-testid']).toBe('custom'); + expect(receivedProps['data-component']).toBe('test'); + expect(receivedState).toEqual({ active: true }); + + const element = container.firstElementChild; + expect(element?.tagName).toBe('SPAN'); + expect(element?.getAttribute('data-active')).toBe('true'); + }); + + it('passes ref to render function props', () => { + const componentRef = createRef(); + + render(} render={(props) => } />); + + expect(componentRef.current).toBeInstanceOf(HTMLSpanElement); + }); + + it('merges className and style into props', () => { + const renderFn = vi.fn((props) => ); + + render(); + + const [receivedProps] = renderFn.mock.calls[0]!; + expect(receivedProps.className).toContain('my-class'); + expect(receivedProps.style).toEqual({ color: 'red' }); + }); + }); + + describe('render prop as element', () => { + it('clones element with merged props', () => { + const { container } = render(} data-testid="merged" />); + + const element = container.firstElementChild; + expect(element?.tagName).toBe('SPAN'); + expect(element?.getAttribute('data-testid')).toBe('merged'); + expect(element?.getAttribute('data-component')).toBe('test'); + }); + + it('merges className from render element and component', () => { + const { container } = render( + } /> + ); + + const element = container.firstElementChild; + expect(element?.className).toContain('component-class'); + expect(element?.className).toContain('render-class'); + }); + + it('merges style from render element and component', () => { + const { container } = render( + } /> + ); + + const element = container.firstElementChild as HTMLElement; + expect(element?.style.color).toBe('red'); + expect(element?.style.fontSize).toBe('16px'); + }); + + it('preserves render element ref', () => { + const CustomElement = forwardRef>( + function CustomElement(props, ref) { + return ; + } + ); + + const renderRef = createRef(); + const componentRef = createRef(); + + render(} />); + + expect(renderRef.current).toBeInstanceOf(HTMLSpanElement); + expect(componentRef.current).toBeInstanceOf(HTMLSpanElement); + expect(renderRef.current).toBe(componentRef.current); + }); + }); + + describe('ref composition', () => { + it('forwards single ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('forwards array of refs', () => { + const ref1 = createRef(); + const ref2 = createRef(); + + // Component that accepts array of refs + const MultiRefComponent = forwardRef(function MultiRefComponent( + props: TestComponentProps, + _ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref: [ref1, ref2], + props: [elementProps], + } + ); + }); + + render(); + + expect(ref1.current).toBeInstanceOf(HTMLDivElement); + expect(ref2.current).toBeInstanceOf(HTMLDivElement); + expect(ref1.current).toBe(ref2.current); + }); + + it('handles undefined refs in array', () => { + const ref1 = createRef(); + + const MultiRefComponent = forwardRef(function MultiRefComponent( + props: TestComponentProps, + _ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref: [ref1, undefined] as Ref[], + props: [elementProps], + } + ); + }); + + render(); + + expect(ref1.current).toBeInstanceOf(HTMLDivElement); + }); + + it('composes ref from render element with forwarded ref', () => { + const CustomElement = forwardRef>( + function CustomElement(props, ref) { + return ; + } + ); + + const elementRef = createRef(); + const componentRef = createRef(); + + render(} />); + + expect(elementRef.current).toBeInstanceOf(HTMLSpanElement); + expect(componentRef.current).toBeInstanceOf(HTMLSpanElement); + expect(elementRef.current).toBe(componentRef.current); + }); + }); + + describe('props merging', () => { + it('merges array of props objects', () => { + const ComponentWithMultipleProps = forwardRef(function ComponentWithMultipleProps( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [{ 'data-first': 'first' }, { 'data-second': 'second' }, elementProps], + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-first')).toBe('first'); + expect(element?.getAttribute('data-second')).toBe('second'); + expect(element?.getAttribute('data-third')).toBe('third'); + }); + + it('chains event handlers from props array', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + const ComponentWithHandlers = forwardRef(function ComponentWithHandlers( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, onClick, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [{ onClick: handler1 }, { onClick }, elementProps], + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + element.click(); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + + it('last prop wins for non-special props', () => { + const ComponentWithConflictingProps = forwardRef(function ComponentWithConflictingProps( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [{ title: 'first' }, { title: 'second' }, elementProps], + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('title')).toBe('third'); + }); + }); + + describe('state data attributes', () => { + it('generates data-* attributes from state boolean true values', () => { + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-active')).toBe(''); + }); + + it('does not generate data-* attributes from state boolean false values', () => { + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.hasAttribute('data-active')).toBe(false); + }); + + it('generates data-* attributes from state with multiple properties', () => { + interface MultiState { + paused: boolean; + ended: boolean; + volume: number; + } + + const MultiStateComponent = forwardRef(function MultiStateComponent( + props: { paused?: boolean; ended?: boolean; volume?: number } & renderElement.ComponentProps, + ref: ForwardedRef + ) { + const { + className, + style, + render: renderProp, + paused = false, + ended = false, + volume = 1, + ...elementProps + } = props; + const state: MultiState = { paused, ended, volume }; + + return renderElement('div', { className, style, render: renderProp }, { state, ref, props: [elementProps] }); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-paused')).toBe(''); + expect(element?.hasAttribute('data-ended')).toBe(false); + expect(element?.getAttribute('data-volume')).toBe('0.5'); + }); + + it('converts state keys to lowercase for data attributes', () => { + interface CamelCaseState { + isPaused: boolean; + } + + const CamelCaseComponent = forwardRef(function CamelCaseComponent( + props: { isPaused?: boolean } & renderElement.ComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, isPaused = false, ...elementProps } = props; + const state: CamelCaseState = { isPaused }; + + return renderElement('div', { className, style, render: renderProp }, { state, ref, props: [elementProps] }); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-ispaused')).toBe(''); + }); + + it('state data-* attributes can be overridden by explicit props', () => { + const ComponentWithExplicitDataAttr = forwardRef(function ComponentWithExplicitDataAttr( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + // Explicit prop comes after state in merge order, so it wins + props: [elementProps], + } + ); + }); + + // State would generate data-active="", but explicit prop overrides + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-active')).toBe('custom'); + }); + }); + + describe('edge cases', () => { + it('handles empty props array', () => { + const ComponentWithEmptyProps = forwardRef(function ComponentWithEmptyProps( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: [], + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.tagName).toBe('DIV'); + expect(element?.className).toContain('test'); + }); + + it('handles undefined props', () => { + const ComponentWithUndefinedProps = forwardRef(function ComponentWithUndefinedProps( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: undefined, + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.tagName).toBe('DIV'); + expect(element?.className).toContain('test'); + }); + + it('handles single props object (not array)', () => { + const ComponentWithSingleProps = forwardRef(function ComponentWithSingleProps( + props: TestComponentProps, + ref: ForwardedRef + ) { + const { className, style, render: renderProp, active = false, ...elementProps } = props; + const state: TestState = { active }; + + return renderElement( + 'div', + { className, style, render: renderProp }, + { + state, + ref, + props: { 'data-single': 'single', ...elementProps }, + } + ); + }); + + const { container } = render(); + const element = container.firstElementChild; + + expect(element?.getAttribute('data-single')).toBe('single'); + expect(element?.getAttribute('data-testid')).toBe('test'); + }); + }); +}); diff --git a/packages/react/src/utils/types.ts b/packages/react/src/utils/types.ts new file mode 100644 index 00000000..ed04567d --- /dev/null +++ b/packages/react/src/utils/types.ts @@ -0,0 +1,35 @@ +import type { ComponentPropsWithRef, CSSProperties, ElementType, ReactElement } from 'react'; + +/** Props that can be spread on any HTML element. */ +export type HTMLProps = React.HTMLAttributes & { + ref?: React.Ref | undefined; +}; + +/** Render function signature - receives props and state, returns element. */ +export type RenderFunction = (props: Props, state: State) => ReactElement; + +/** Render prop - either a React element or a render function. */ +export type RenderProp = ReactElement | RenderFunction; + +/** + * Standard props for UI components. + * + * Provides consistent API across all UI components: + * - `className` as string or function of state + * - `style` as object or function of state + * - `render` prop for element customization + */ +export type UIComponentProps = Omit< + React.JSX.IntrinsicElements[TagName], + 'className' | 'style' +> & { + /** Class name or function returning class name from state. */ + className?: string | ((state: State) => string | undefined) | undefined; + /** Style or function returning style from state. */ + style?: CSSProperties | ((state: State) => CSSProperties | undefined) | undefined; + /** Render prop for custom element. */ + render?: RenderProp | undefined; +}; + +/** Extract props type from an element type. */ +export type PropsOf = ComponentPropsWithRef; diff --git a/packages/react/src/utils/use-composed-refs.ts b/packages/react/src/utils/use-composed-refs.ts index 2003db5e..d74cfd06 100644 --- a/packages/react/src/utils/use-composed-refs.ts +++ b/packages/react/src/utils/use-composed-refs.ts @@ -1,10 +1,11 @@ 'use client'; +import { isFunction } from '@videojs/utils/predicate'; import type { Ref, RefCallback } from 'react'; import { useCallback } from 'react'; -type PossibleRef = Ref | undefined; +type OptionalRef = Ref | undefined; /** * Set a given ref to a given value. @@ -13,8 +14,8 @@ type PossibleRef = Ref | undefined; * * @returns Cleanup function if the ref callback returned one (React 19+) */ -function setRef(ref: PossibleRef, value: T): (() => void) | void | undefined { - if (typeof ref === 'function') { +function setRef(ref: OptionalRef, value: T): (() => void) | void | undefined { + if (isFunction(ref)) { return ref(value); } else if (ref !== null && ref !== undefined) { ref.current = value; @@ -30,17 +31,19 @@ function setRef(ref: PossibleRef, value: T): (() => void) | void | undefin * return
; * ``` */ -export function composeRefs(...refs: PossibleRef[]): RefCallback { +export function composeRefs(...refs: (OptionalRef | OptionalRef[])[]): RefCallback { + const flatRefs = refs.flat(); + return (node): (() => void) | void => { - const cleanups = refs.map((ref) => setRef(ref, node)); + const cleanups = flatRefs.map((ref) => setRef(ref, node)); return () => { for (let i = 0; i < cleanups.length; i++) { const cleanup = cleanups[i]; - if (typeof cleanup === 'function') { + if (isFunction(cleanup)) { cleanup(); } else { - setRef(refs[i], null); + setRef(flatRefs[i], null); } } }; @@ -58,6 +61,6 @@ export function composeRefs(...refs: PossibleRef[]): RefCallback { * return
; * ``` */ -export function useComposedRefs(...refs: PossibleRef[]): RefCallback { +export function useComposedRefs(...refs: OptionalRef[]): RefCallback { return useCallback(composeRefs(...refs), [...refs]); } diff --git a/packages/react/src/utils/use-render.tsx b/packages/react/src/utils/use-render.tsx new file mode 100644 index 00000000..c5a014fb --- /dev/null +++ b/packages/react/src/utils/use-render.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { getStateDataAttrs } from '@videojs/core/dom'; +import { isFunction } from '@videojs/utils/predicate'; +import type { CSSProperties, ReactElement, Ref } from 'react'; +import { cloneElement, createElement, isValidElement } from 'react'; +import { mergeProps } from './merge-props'; +import type { HTMLProps, RenderProp } from './types'; +import { composeRefs } from './use-composed-refs'; + +type IntrinsicTagName = keyof React.JSX.IntrinsicElements; + +export interface UseRenderComponentProps { + className?: string | ((state: State) => string | undefined) | undefined; + style?: CSSProperties | ((state: State) => CSSProperties | undefined) | undefined; + render?: RenderProp | undefined; +} + +export interface UseRenderParameters { + state: State; + ref?: Ref | Ref[] | undefined; + props?: object | object[] | undefined; +} + +function resolveClassName( + className: string | ((state: State) => string | undefined) | undefined, + state: State +): string | undefined { + return isFunction(className) ? className(state) : className; +} + +function resolveStyle( + style: CSSProperties | ((state: State) => CSSProperties | undefined) | undefined, + state: State +): CSSProperties | undefined { + return isFunction(style) ? style(state) : style; +} + +function getElementRef(element: ReactElement): Ref | undefined { + // React 19+ uses element.props.ref, older versions use element.ref + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elementAny = element as any; + return elementAny.ref ?? elementAny.props?.ref; +} + +/** + * Render a UI component element. + * + * Handles: + * - Default tag rendering + * - Render prop (element or function) + * - Props merging (event handlers chained, className concatenated, style merged) + * - Ref composition + * - className/style as functions of state + * + * @example + * ```tsx + * return renderElement('button', componentProps, { + * state, + * ref: [forwardedRef, buttonRef], + * props: [{ type: 'button' }, elementProps, getButtonProps], + * }); + * ``` + */ +export function renderElement< + State extends object, + RenderedElementType extends Element, + TagName extends IntrinsicTagName, +>( + element: TagName, + componentProps: UseRenderComponentProps, + params: UseRenderParameters +): ReactElement { + const { className: classNameProp, style: styleProp, render } = componentProps; + const { state, ref, props } = params; + + // Resolve className and style if they're functions + const className = resolveClassName(classNameProp, state); + const style = resolveStyle(styleProp, state); + + // Generate data attributes from state + const stateDataAttrs = getStateDataAttrs(state); + + // Merge: state data attrs first, then props (so props can override) + const propsArray = Array.isArray(props) ? props : props ? [props] : []; + const mergedProps = mergeProps(stateDataAttrs, ...(propsArray as Record[])); + + if (className !== undefined) { + // Add resolved className and style + mergedProps.className = mergedProps.className ? `${mergedProps.className} ${className}` : className; + } + + if (style !== undefined) { + mergedProps.style = mergedProps.style ? { ...(mergedProps.style as CSSProperties), ...style } : style; + } + + if (isFunction(render)) { + // Render function: call with props and state + const mergedRef = composeRefs(ref, mergedProps.ref); + return render({ ...mergedProps, ref: mergedRef } as HTMLProps, state); + } + + if (isValidElement(render)) { + const elementRef = getElementRef(render); + + const mergedRef = composeRefs(ref, mergedProps.ref, elementRef); + + const elementProps = mergeProps(mergedProps, render.props as Record); + elementProps.ref = mergedRef; + + return cloneElement(render, elementProps); + } + + // Default tag + const mergedRef = composeRefs(ref, mergedProps.ref); + mergedProps.ref = mergedRef; + + return createElement(element, mergedProps); +} + +export namespace renderElement { + export type ComponentProps = UseRenderComponentProps; + export type Parameters = UseRenderParameters; +} diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts index 161ed621..95ec66db 100644 --- a/packages/store/src/core/selector.ts +++ b/packages/store/src/core/selector.ts @@ -20,9 +20,7 @@ const stateContext: StateContext = { * selectPlayback(store.state); // { paused, play, pause, ... } | undefined * ``` */ -export function createSelector( - slice: S -): (state: Record) => InferSliceState | undefined { +export function createSelector(slice: S): (state: object) => InferSliceState | undefined { const initialState = slice.state(stateContext); const keys = Object.keys(initialState as object); @@ -32,6 +30,6 @@ export function createSelector( return (state) => { // WARN: Could be the source of a bug if two slices have overlapping state keys if (!(firstKey in state)) return undefined; - return pick(state, keys) as InferSliceState; + return pick(state as Record, keys) as InferSliceState; }; } diff --git a/packages/utils/src/object/defaults.ts b/packages/utils/src/object/defaults.ts new file mode 100644 index 00000000..71b0d633 --- /dev/null +++ b/packages/utils/src/object/defaults.ts @@ -0,0 +1,26 @@ +import { isUndefined } from '../predicate'; + +/** Makes all properties optional and explicitly allows undefined values. */ +type PartialWithUndefined = { [K in keyof T]?: T[K] | undefined }; + +/** + * Creates a new object with default values filled in for undefined properties. + * + * @example + * ```ts + * const props = { label: undefined, disabled: true }; + * const defaultProps = { label: '', disabled: false }; + * defaults(props, defaultProps); // { label: '', disabled: true } + * ``` + */ +export function defaults(object: PartialWithUndefined, defaultValues: T): T { + const result = { ...defaultValues }; + + for (const key in object) { + if (!isUndefined(object[key])) { + result[key as keyof T] = object[key] as T[keyof T]; + } + } + + return result; +} diff --git a/packages/utils/src/object/index.ts b/packages/utils/src/object/index.ts index 81945939..594335fc 100644 --- a/packages/utils/src/object/index.ts +++ b/packages/utils/src/object/index.ts @@ -1 +1,2 @@ +export { defaults } from './defaults'; export { pick } from './pick'; diff --git a/packages/utils/src/object/tests/defaults.test.ts b/packages/utils/src/object/tests/defaults.test.ts new file mode 100644 index 00000000..3b4564d8 --- /dev/null +++ b/packages/utils/src/object/tests/defaults.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { defaults } from '../defaults'; + +describe('defaults', () => { + it('fills undefined values with defaults', () => { + const result = defaults({ a: undefined, b: 2 }, { a: 1, b: 0 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('preserves defined values', () => { + const result = defaults({ a: 'custom', b: 'also custom' }, { a: 'default', b: 'default' }); + expect(result).toEqual({ a: 'custom', b: 'also custom' }); + }); + + it('returns all defaults for empty object', () => { + const result = defaults({}, { a: 1, b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('preserves falsy values (false)', () => { + const result = defaults({ enabled: false }, { enabled: true }); + expect(result).toEqual({ enabled: false }); + }); + + it('preserves falsy values (0)', () => { + const result = defaults({ count: 0 }, { count: 10 }); + expect(result).toEqual({ count: 0 }); + }); + + it('preserves falsy values (empty string)', () => { + const result = defaults({ label: '' }, { label: 'default' }); + expect(result).toEqual({ label: '' }); + }); + + it('preserves null values', () => { + const result = defaults<{ value: string | null }>({ value: null }, { value: 'default' }); + expect(result).toEqual({ value: null }); + }); + + it('handles mixed defined and undefined values', () => { + const result = defaults({ label: 'custom', disabled: undefined }, { label: 'default', disabled: false }); + expect(result).toEqual({ label: 'custom', disabled: false }); + }); + + it('does not mutate the input object', () => { + const input = { a: undefined }; + const defaultValues = { a: 1, b: 2 }; + + defaults(input, defaultValues); + + expect(input).toEqual({ a: undefined }); + expect(defaultValues).toEqual({ a: 1, b: 2 }); + }); + + it('does not mutate the default values object', () => { + const input = { a: 'custom' }; + const defaultValues = { a: 'default', b: 'default' }; + + const result = defaults(input, defaultValues); + + expect(defaultValues).toEqual({ a: 'default', b: 'default' }); + expect(result).not.toBe(defaultValues); + }); + + it('handles nested objects (shallow)', () => { + const nested = { x: 1 }; + const result = defaults({ config: undefined }, { config: nested }); + + expect(result.config).toBe(nested); + }); +}); diff --git a/packages/utils/src/types/types.ts b/packages/utils/src/types/types.ts index a1d15798..2ce0c8f1 100644 --- a/packages/utils/src/types/types.ts +++ b/packages/utils/src/types/types.ts @@ -15,3 +15,7 @@ export type Falsy = T | false | null | undefined; export type EnsureFunction = T extends (...args: any[]) => any ? T : never; export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; + +export type NonNullableObject = { + [P in keyof T]-?: Exclude; +};