From b2e2b88e19634fa67e064b898e37f954d6939e2a Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 7 Jan 2026 15:45:46 +1100 Subject: [PATCH] feat(store): skin store setup (#298) --- .claude/plans/store-bindings.md | 158 ++++-------------- packages/html/package.json | 9 + packages/html/src/define/vjs-frosted-skin.ts | 3 + packages/html/src/skins/frosted/index.ts | 12 ++ packages/html/src/skins/frosted/skin.ts | 57 +++++++ packages/html/src/skins/frosted/store.ts | 41 +++++ packages/html/tsdown.config.ts | 8 +- packages/react/package.json | 4 + packages/react/src/index.ts | 3 + packages/react/src/skins/frosted/index.ts | 13 ++ packages/react/src/skins/frosted/skin.tsx | 33 ++++ packages/react/src/skins/frosted/store.ts | 41 +++++ packages/react/tsdown.config.ts | 3 +- packages/store/src/lit/create-store.ts | 1 - packages/store/src/lit/mixins/attach-mixin.ts | 6 +- .../lit/mixins/tests/combined-mixin.test.ts | 26 +-- pnpm-lock.yaml | 11 +- 17 files changed, 273 insertions(+), 156 deletions(-) create mode 100644 packages/html/src/define/vjs-frosted-skin.ts create mode 100644 packages/html/src/skins/frosted/index.ts create mode 100644 packages/html/src/skins/frosted/skin.ts create mode 100644 packages/html/src/skins/frosted/store.ts create mode 100644 packages/react/src/skins/frosted/index.ts create mode 100644 packages/react/src/skins/frosted/skin.tsx create mode 100644 packages/react/src/skins/frosted/store.ts diff --git a/.claude/plans/store-bindings.md b/.claude/plans/store-bindings.md index bb41ade8..a8469ad2 100644 --- a/.claude/plans/store-bindings.md +++ b/.claude/plans/store-bindings.md @@ -36,7 +36,8 @@ Implement React and DOM bindings for Video.js 10's store, enabling: | Primitives context | `useStoreContext()` internal hook for primitive UI components | | displayName | For React DevTools component naming | | Component types | Namespace pattern: `Skin.Props` via `namespace Skin { export type Props }` | -| Element define | `FrostedSkinElement.define(tagName, { mixins })` for declarative setup | +| Element define | `FrostedSkinElement.define(tagName?, mixin?)` - pass mixin directly | +| Slot handling | Default slot (``) - no `slot="media"` attribute needed | | Config extension | `extendConfig()` uses `uniqBy` + `composeCallbacks` from utils | | Provider resolution | Isolated by default; `inherit` prop to use parent store from context | | Store instance | `create()` method for imperative store creation | @@ -232,108 +233,18 @@ export { extendConfig, Provider } from './store'; --- -## Phase 6: Frosted Skin (HTML) +## Phase 6: Frosted Skin [DONE] -### 6.1 Store config +> Refer to [PR #298](https://github.com/videojs/v10/pull/298) for implementation details. -**File:** `packages/html/src/skins/frosted/store.ts` +React and HTML frosted skin store setup: -```typescript -import type { AnySlice, StoreConfig } from '@videojs/store'; +- React: `Provider`, `Skin`, `extendConfig`, `createStore` in `@videojs/react/skins/frosted` +- HTML: `FrostedSkinElement`, `StoreMixin`, `extendConfig` in `@videojs/html/skins/frosted` +- Auto-define entry: `@videojs/html/define/vjs-frosted-skin` for CDN usage +- `StoreAttachMixin` uses default slot (no `slot="media"` attribute needed) -import { extendConfig as extendBaseConfig } from '@videojs/store'; -import { createStore } from '@videojs/store/lit'; - -import { media } from './slices'; // internal - re-exported from @videojs/html - -/** Base config for frosted skin. */ -const baseConfig = { - slices: [media.playback] as const, -}; - -/** - * Extends frosted skin config with additional slices/hooks. - * Composes lifecycle hooks (both called, base first). - */ -export function extendConfig(extension?: Partial>) { - return extendBaseConfig(baseConfig, extension); -} - -export const { StoreMixin, StoreProviderMixin, StoreAttachMixin, context } = createStore(extendConfig()); -``` - -### 6.2 Skin component - -**File:** `packages/html/src/skins/frosted/skin.ts` - -```typescript -import { StoreAttachMixin, StoreMixin, StoreProviderMixin } from './store'; - -type Mixin = >(Base: T) => T; - -export interface DefineOptions { - /** Mixins to apply. Defaults to [StoreMixin] (combined provider + attach). */ - mixins?: Mixin[]; -} - -/** - * Frosted skin element. Empty for now - controls will be added later. - * Uses shadow DOM with slot for video element. - */ -export class FrostedSkinElement extends HTMLElement { - /** Default tag name for this element. */ - static tagName = 'vjs-frosted-skin'; - - /** - * Define this element with the custom elements registry. - * - * @example - * // Default: combined provider + attach - * FrostedSkinElement.define('vjs-frosted-skin'); - * - * @example - * // Granular mixin control (e.g., attach only, inherit provider from parent) - * FrostedSkinElement.define('vjs-thumbnail', { mixins: [StoreAttachMixin] }); - * - * @example - * // Custom store with extended slices - * const { StoreMixin } = createStore(extendConfig({ slices: [chaptersSlice] })); - * FrostedSkinElement.define('my-extended-player', { mixins: [StoreMixin] }); - */ - static define(tagName: string, options: DefineOptions = {}) { - const { mixins = [StoreMixin] } = options; - - // Apply mixins in order (right to left composition) - const Mixed = mixins.reduceRight((Base, mixin) => mixin(Base), this as typeof FrostedSkinElement); - customElements.define(tagName, Mixed); - } - - connectedCallback() { - const shadow = this.attachShadow({ mode: 'open' }); - shadow.innerHTML = ``; - } -} -``` - -### 6.3 Define export - -**File:** `packages/html/src/define/vjs-frosted-skin.ts` - -```typescript -import { FrostedSkinElement } from '../skins/frosted/skin'; - -FrostedSkinElement.define('vjs-frosted-skin'); -``` - -### 6.4 Exports - -**File:** `packages/html/src/skins/frosted/index.ts` - -```typescript -export { FrostedSkinElement } from './skin'; -export type { DefineOptions } from './skin'; -export { context, extendConfig, StoreAttachMixin, StoreMixin, StoreProviderMixin } from './store'; -``` +**Note:** Don't export `context` from skins (causes unique symbol type issues). Users can import from `@videojs/store/lit` if needed. --- @@ -501,17 +412,20 @@ function ChaptersPanel() { Where `my-player.js` contains: ```typescript -import { createStore, media } from '@videojs/html'; +import { ReactiveElement } from '@lit/reactive-element'; +import { media } from '@videojs/core/dom'; +import { createStore } from '@videojs/store/lit'; const { StoreMixin } = createStore({ - slices: [media.playback], + slices: [...media.all], }); // Create custom element with store provider and auto-attach -class MyPlayer extends StoreMixin(HTMLElement) { - connectedCallback() { +class MyPlayer extends StoreMixin(ReactiveElement) { + constructor() { + super(); const shadow = this.attachShadow({ mode: 'open' }); - shadow.innerHTML = ``; + shadow.innerHTML = ''; } } @@ -539,7 +453,7 @@ import { chaptersSlice } from './slices/chapters.js'; // Extend frosted config with custom slice (merges with base slices) const { StoreMixin } = createStore(extendConfig({ slices: [chaptersSlice] })); -FrostedSkinElement.define('my-extended-skin', { mixins: [StoreMixin] }); +FrostedSkinElement.define('my-extended-skin', StoreMixin); ``` --- @@ -683,9 +597,9 @@ packages/html/src/ - Shared types: `OptimisticResult` discriminated union in `src/shared/types.ts` ✓ - Tests: 13 new tests for React hook ✓ -8. **Phase 6**: Skins - - React skin (Provider, Skin, extendConfig) - - HTML skin (FrostedSkinElement, extendConfig) +8. **Phase 6**: Skins **[DONE - PR #298]** + - React skin (Provider, Skin, extendConfig) ✓ + - HTML skin (FrostedSkinElement, extendConfig) ✓ Each phase includes tests. @@ -814,15 +728,15 @@ Use `types` + `default` format to match existing packages. ### Related Issues -| Issue | Title | Description | Status | -| ----- | ------------------- | --------------------------------------- | ------ | -| #218 | Store | Parent tracking issue | Open | -| #285 | Queue Task Refactor | Unified tasks map, status discriminator | Closed | -| #228 | Optimistic Updates | useMutation, useOptimistic | Open | -| #229 | React Bindings | createStore, hooks, context | Closed | -| #230 | Lit Bindings | Controllers, mixins, context | Closed | -| #239 | DOM Media Slices | media slices | Closed | -| #231 | Skin Stores | Skin store configuration | Open | +| Issue | Title | Description | Status | +| ----- | ------------------- | --------------------------------------- | ---------------- | +| #218 | Store | Parent tracking issue | Open | +| #285 | Queue Task Refactor | Unified tasks map, status discriminator | Closed | +| #228 | Optimistic Updates | useMutation, useOptimistic | Open | +| #229 | React Bindings | createStore, hooks, context | Closed | +| #230 | Lit Bindings | Controllers, mixins, context | Closed | +| #239 | DOM Media Slices | media slices | Closed | +| #231 | Skin Stores | Skin store configuration | Closed (PR #298) | ### PR Strategy @@ -875,9 +789,9 @@ PR #291: Optimistic Hooks/Controllers [DONE] ├── Tests: 13 new tests for React hook ✓ └── Closes #228 -PR F: Skins -├── React skin (Provider, Skin, extendConfig) -├── HTML skin (FrostedSkinElement, extendConfig) +PR #298: Skins [DONE] +├── React skin (Provider, Skin, extendConfig) ✓ +├── HTML skin (FrostedSkinElement, extendConfig) ✓ ├── References #218 └── Closes #231 ``` @@ -885,12 +799,12 @@ PR F: Skins ### Dependency Graph ``` -PR #283 ───> PR #287 ───> PR #288 ───> PR #290 ───> PR #291 ───> PR F (Skins) +PR #283 ───> PR #287 ───> PR #288 ───> PR #290 ───> PR #291 ───> PR #298 (Skins) └──> PR #289 (done) ────────────────────────┘ └──> PR #292 (done) ────────────────────────┘ ``` -PRs are sequential. PR #288, #289, #292 can technically parallel after PR #287, but we'll do them sequentially for easier review. +All PRs merged. Store bindings implementation complete. --- diff --git a/packages/html/package.json b/packages/html/package.json index 6124411b..29a320e2 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -16,6 +16,14 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./skins/frosted": { + "types": "./dist/skins/frosted.d.ts", + "default": "./dist/skins/frosted.js" + }, + "./define/vjs-frosted-skin": { + "types": "./dist/define/vjs-frosted-skin.d.ts", + "default": "./dist/define/vjs-frosted-skin.js" } }, "main": "dist/index.js", @@ -32,6 +40,7 @@ "clean": "rm -rf dist types" }, "dependencies": { + "@lit/reactive-element": "^2.1.2", "@videojs/core": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*" diff --git a/packages/html/src/define/vjs-frosted-skin.ts b/packages/html/src/define/vjs-frosted-skin.ts new file mode 100644 index 00000000..da7471d6 --- /dev/null +++ b/packages/html/src/define/vjs-frosted-skin.ts @@ -0,0 +1,3 @@ +import { FrostedSkinElement } from '../skins/frosted'; + +FrostedSkinElement.define(); diff --git a/packages/html/src/skins/frosted/index.ts b/packages/html/src/skins/frosted/index.ts new file mode 100644 index 00000000..da2336ef --- /dev/null +++ b/packages/html/src/skins/frosted/index.ts @@ -0,0 +1,12 @@ +export { FrostedSkinElement } from './skin'; + +export { + create as createStore, + extendConfig, + RequestController, + SelectorController, + StoreAttachMixin, + StoreMixin, + StoreProviderMixin, + TasksController, +} from './store'; diff --git a/packages/html/src/skins/frosted/skin.ts b/packages/html/src/skins/frosted/skin.ts new file mode 100644 index 00000000..d61a024d --- /dev/null +++ b/packages/html/src/skins/frosted/skin.ts @@ -0,0 +1,57 @@ +import type { Mixin } from '@videojs/utils/types'; + +import { ReactiveElement } from '@lit/reactive-element'; + +import { StoreMixin } from './store'; + +/** + * Frosted skin custom element. + * + * Uses shadow DOM with a slot for video elements. Controls will be added in future updates. + * + * @example Basic usage (after calling define) + * ```html + * + * + * + * ``` + * + * @example Define with default tag + * ```ts + * import { FrostedSkinElement } from '@videojs/html/skins/frosted'; + * FrostedSkinElement.define(); + * ``` + * + * @example Define with custom tag + * ```ts + * FrostedSkinElement.define('my-player'); + * ``` + * + * @example Define with extended store + * ```ts + * import { createStore } from '@videojs/store/lit'; + * import { extendConfig, FrostedSkinElement } from '@videojs/html/skins/frosted'; + * + * const { StoreMixin } = createStore(extendConfig({ slices: [chaptersSlice] })); + * FrostedSkinElement.define('my-player', StoreMixin); + * ``` + */ +export class FrostedSkinElement extends ReactiveElement { + static tagName = 'vjs-frosted-skin'; + + /** + * Registers this element with the custom elements registry. + * + * @param tagName - Custom element tag name (defaults to 'vjs-frosted-skin') + * @param mixin - Mixin to apply (defaults to StoreMixin) + */ + static define(tagName = this.tagName, mixin: Mixin = StoreMixin): void { + customElements.define(tagName, mixin(this)); + } + + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + } +} diff --git a/packages/html/src/skins/frosted/store.ts b/packages/html/src/skins/frosted/store.ts new file mode 100644 index 00000000..7735a23c --- /dev/null +++ b/packages/html/src/skins/frosted/store.ts @@ -0,0 +1,41 @@ +import type { AnySlice, StoreConfig } from '@videojs/store'; + +import { media } from '@videojs/core/dom'; +import { extendConfig as extendBaseConfig } from '@videojs/store'; +import { createStore } from '@videojs/store/lit'; + +const baseConfig = { + slices: [...media.all], +}; + +/** + * Extends frosted skin config. + * + * @example + * ```ts + * import { createStore } from '@videojs/store/lit'; + * import { extendConfig, FrostedSkinElement } from '@videojs/html/skins/frosted'; + * import { chaptersSlice } from './slices/chapters'; + * + * const { StoreMixin } = createStore( + * extendConfig({ slices: [chaptersSlice] }) + * ); + * + * FrostedSkinElement.define('my-player', El => StoreMixin(El)); + * ``` + */ +export function extendConfig[] = []>( + extension?: Partial>, +) { + return extendBaseConfig(baseConfig, extension); +} + +export const { + StoreMixin, + StoreProviderMixin, + StoreAttachMixin, + SelectorController, + RequestController, + TasksController, + create, +} = createStore(baseConfig); diff --git a/packages/html/tsdown.config.ts b/packages/html/tsdown.config.ts index c387a2ae..9c9b64c9 100644 --- a/packages/html/tsdown.config.ts +++ b/packages/html/tsdown.config.ts @@ -6,13 +6,9 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: { index: 'src/index.ts', - // 'skins/frosted': 'src/skins/frosted/index.ts', + 'skins/frosted': 'src/skins/frosted/index.ts', // 'skins/minimal': 'src/skins/minimal/index.ts', - // ...defineFiles.reduce((entries, file) => { - // const name = file.replace(/\.ts$/, ''); - // entries[`define/${name}`] = `src/define/${file}`; - // return entries; - // }, {} as Record), + 'define/vjs-frosted-skin': 'src/define/vjs-frosted-skin.ts', }, platform: 'browser', format: 'es', diff --git a/packages/react/package.json b/packages/react/package.json index aa4de8bb..932a6456 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -16,6 +16,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./skins/frosted": { + "types": "./dist/skins/frosted.d.ts", + "default": "./dist/skins/frosted.js" } }, "main": "dist/index.js", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b827556f..97428b63 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,5 +3,8 @@ // Media export { Video, type VideoProps } from './media/video'; +// Slices (re-export for convenience) +export { media } from '@videojs/core/dom'; + // Store export * from '@videojs/store/react'; diff --git a/packages/react/src/skins/frosted/index.ts b/packages/react/src/skins/frosted/index.ts new file mode 100644 index 00000000..fc347c14 --- /dev/null +++ b/packages/react/src/skins/frosted/index.ts @@ -0,0 +1,13 @@ +'use client'; + +export { Skin, type SkinProps } from './skin'; + +export { + create as createStore, + extendConfig, + Provider, + useRequest, + useSelector, + useStore, + useTasks, +} from './store'; diff --git a/packages/react/src/skins/frosted/skin.tsx b/packages/react/src/skins/frosted/skin.tsx new file mode 100644 index 00000000..534cf3b4 --- /dev/null +++ b/packages/react/src/skins/frosted/skin.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { ReactNode } from 'react'; + +export interface SkinProps { + children?: ReactNode; + className?: string; +} + +/** + * @example + * ```tsx + * import { Video } from '@videojs/react'; + * import { Provider, Skin } from '@videojs/react/skins/frosted'; + * + * function App() { + * return ( + * + * + * + * + * ); + * } + * ``` + */ +export function Skin({ children, className }: SkinProps): React.JSX.Element { + return
{children}
; +} + +export namespace Skin { + export type Props = SkinProps; +} diff --git a/packages/react/src/skins/frosted/store.ts b/packages/react/src/skins/frosted/store.ts new file mode 100644 index 00000000..c275573a --- /dev/null +++ b/packages/react/src/skins/frosted/store.ts @@ -0,0 +1,41 @@ +'use client'; + +import type { AnySlice, StoreConfig } from '@videojs/store'; + +import { media } from '@videojs/core/dom'; +import { extendConfig as extendBaseConfig } from '@videojs/store'; +import { createStore } from '@videojs/store/react'; + +const baseConfig = { + slices: [...media.all], + displayName: 'FrostedSkin', +}; + +/** + * Extends frosted skin config. + * + * @example + * ```ts + * import { createStore } from '@videojs/store/react'; + * import { extendConfig } from '@videojs/react/skins/frosted'; + * import { chaptersSlice } from './slices/chapters'; + * + * const { Provider, useSelector } = createStore( + * extendConfig({ slices: [chaptersSlice] }) + * ); + * ``` + */ +export function extendConfig[] = []>( + extension?: Partial>, +) { + return extendBaseConfig(baseConfig, extension); +} + +export const { + Provider, + create, + useStore, + useSelector, + useRequest, + useTasks, +} = createStore(baseConfig); diff --git a/packages/react/tsdown.config.ts b/packages/react/tsdown.config.ts index 32cb06ce..5d037b37 100644 --- a/packages/react/tsdown.config.ts +++ b/packages/react/tsdown.config.ts @@ -3,8 +3,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: { index: './src/index.ts', - // 'skins/frosted': './src/skins/frosted/index.ts', - // 'skins/minimal': './src/skins/minimal/index.ts', + 'skins/frosted': './src/skins/frosted/index.ts', }, platform: 'browser', format: 'es', diff --git a/packages/store/src/lit/create-store.ts b/packages/store/src/lit/create-store.ts index 6012136b..1eb393ce 100644 --- a/packages/store/src/lit/create-store.ts +++ b/packages/store/src/lit/create-store.ts @@ -208,7 +208,6 @@ export function createStore(config: CreateStoreConfig const StoreAttachMixin = createStoreAttachMixin(context); const StoreMixin = createStoreMixin(context, create); - // Bound controllers - context is pre-bound class SelectorController extends SelectorControllerBase { constructor(host: CreateStoreHost, selector: (state: State) => Value) { super(host, context, selector); diff --git a/packages/store/src/lit/mixins/attach-mixin.ts b/packages/store/src/lit/mixins/attach-mixin.ts index 4939ad44..4b63b5ce 100644 --- a/packages/store/src/lit/mixins/attach-mixin.ts +++ b/packages/store/src/lit/mixins/attach-mixin.ts @@ -14,7 +14,7 @@ import { isNull } from '@videojs/utils/predicate'; * Creates a mixin that consumes a store from context and auto-attaches media elements. * * - Requests store from context (must have a provider ancestor) - * - Observes slotted elements for `