From 33b21906cd3b55ac059633f3cfebbb070ffa9a11 Mon Sep 17 00:00:00 2001 From: rahim Date: Fri, 13 Feb 2026 00:41:49 +1100 Subject: [PATCH] feat(element): add lightweight reactive element base (#513) --- .claude/plans/player-api-implementation.md | 8 +- .claude/plans/store/bindings.md | 2 +- .claude/plans/store/store-v2.md | 2 +- .claude/skills/README.md | 2 +- .claude/skills/component/SKILL.md | 8 +- .../component/references/{lit.md => html.md} | 21 +- .../component/references/lit-fundamentals.md | 324 --------- .../component/references/videojs-element.md | 259 +++++++ .../skills/component/references/videojs.md | 4 +- .../component/review/checklists/videojs.md | 2 +- .claude/skills/component/review/workflow.md | 6 +- .../create-skill/references/patterns.md | 4 +- .../create-skill/references/principles.md | 2 +- .../skills/design/templates/feature-multi.md | 6 +- .claude/skills/docs/SKILL.md | 6 +- .../skills/docs/references/multi-framework.md | 2 +- .../skills/docs/templates/api-reference.md | 16 +- .../skills/docs/templates/component-page.md | 10 +- .claude/skills/docs/templates/readme.md | 2 +- .size-limit.json | 16 +- CLAUDE.md | 4 +- biome.json | 10 + commitlint.config.js | 1 + packages/element/README.md | 114 +++ packages/element/package.json | 51 ++ packages/element/src/context.ts | 1 + packages/element/src/index.ts | 8 + packages/element/src/reactive-element.ts | 407 +++++++++++ .../src/tests/reactive-element.test.ts | 649 ++++++++++++++++++ packages/element/src/types.ts | 104 +++ packages/element/tsconfig.json | 10 + packages/element/tsdown.config.ts | 26 + packages/element/vitest.config.ts | 8 + packages/html/package.json | 3 +- packages/html/src/player/context.ts | 2 +- packages/html/src/player/player-controller.ts | 6 +- packages/html/src/store/container-mixin.ts | 8 +- packages/html/src/store/provider-mixin.ts | 6 +- .../fullscreen-button-element.ts | 4 +- packages/html/src/ui/media-element.ts | 8 +- .../src/ui/mute-button/mute-button-element.ts | 4 +- .../src/ui/play-button/play-button-element.ts | 4 +- packages/html/src/ui/poster/poster-element.ts | 2 +- packages/html/src/ui/time/time-element.ts | 4 +- packages/store/package.json | 19 +- .../src/{lit => html}/controllers/index.ts | 0 .../controllers/store-controller.ts | 2 +- .../controllers/subscription-controller.ts | 2 +- .../tests/store-controller.test.ts | 6 +- packages/store/src/{lit => html}/globals.d.ts | 0 packages/store/src/{lit => html}/index.ts | 0 .../store/src/{lit => html}/store-accessor.ts | 6 +- .../tests/store-accessor.test.ts | 0 .../src/{lit => html}/tests/test-utils.ts | 2 +- .../store/src/{lit => html}/tsconfig.json | 4 +- packages/store/tsdown.config.ts | 2 +- packages/store/vitest.config.ts | 4 +- pnpm-lock.yaml | 49 +- rfc/player-api/architecture.md | 4 +- tsconfig.json | 4 +- 60 files changed, 1793 insertions(+), 457 deletions(-) rename .claude/skills/component/references/{lit.md => html.md} (83%) delete mode 100644 .claude/skills/component/references/lit-fundamentals.md create mode 100644 .claude/skills/component/references/videojs-element.md create mode 100644 packages/element/README.md create mode 100644 packages/element/package.json create mode 100644 packages/element/src/context.ts create mode 100644 packages/element/src/index.ts create mode 100644 packages/element/src/reactive-element.ts create mode 100644 packages/element/src/tests/reactive-element.test.ts create mode 100644 packages/element/src/types.ts create mode 100644 packages/element/tsconfig.json create mode 100644 packages/element/tsdown.config.ts create mode 100644 packages/element/vitest.config.ts rename packages/store/src/{lit => html}/controllers/index.ts (100%) rename packages/store/src/{lit => html}/controllers/store-controller.ts (99%) rename packages/store/src/{lit => html}/controllers/subscription-controller.ts (99%) rename packages/store/src/{lit => html}/controllers/tests/store-controller.test.ts (93%) rename packages/store/src/{lit => html}/globals.d.ts (100%) rename packages/store/src/{lit => html}/index.ts (100%) rename packages/store/src/{lit => html}/store-accessor.ts (94%) rename packages/store/src/{lit => html}/tests/store-accessor.test.ts (100%) rename packages/store/src/{lit => html}/tests/test-utils.ts (98%) rename packages/store/src/{lit => html}/tsconfig.json (60%) diff --git a/.claude/plans/player-api-implementation.md b/.claude/plans/player-api-implementation.md index d24e5129..522bf4e7 100644 --- a/.claude/plans/player-api-implementation.md +++ b/.claude/plans/player-api-implementation.md @@ -12,7 +12,7 @@ Parent issue: [#320](https://github.com/videojs/v10/issues/320) | 2 | [#366](https://github.com/videojs/v10/issues/366) | PlayerTarget & Features | 3 | βœ… | | 3 | [#367](https://github.com/videojs/v10/issues/367) | React Player API | 3 | βœ… | | 4 | [#368](https://github.com/videojs/v10/issues/368) | HTML Player API | 5 | βœ… | -| 5 | [#369](https://github.com/videojs/v10/issues/369) | Cleanup & Documentation | 2 | πŸ”„ Partial (store/lit cleanup done in #368) | +| 5 | [#369](https://github.com/videojs/v10/issues/369) | Cleanup & Documentation | 2 | πŸ”„ Partial (store/html cleanup done in #368) | **Total: 18 SP** @@ -91,7 +91,7 @@ PlayerContextValue = { | `useStore` | Hook to subscribe to store state | | `useSelector` (new) | Selector-based subscription with shallowEqual | -### Removed from `@videojs/store/lit` +### Removed from `@videojs/store/html` | Export | Reason | |--------|--------| @@ -106,7 +106,7 @@ PlayerContextValue = { | `StoreConsumer` | Player-specific | | `StoreProvider` | Player-specific | -### Kept in `@videojs/store/lit` +### Kept in `@videojs/store/html` | Export | Purpose | |--------|---------| @@ -1013,7 +1013,7 @@ export function ContainerMixin>(BaseCl import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; import type { AnyStore, InferStoreState } from '@videojs/store'; import { shallowEqual } from '@videojs/store'; -import { StoreAccessor, type StoreSource } from '@videojs/store/lit'; +import { StoreAccessor, type StoreSource } from '@videojs/store/html'; export class PlayerController> implements ReactiveController diff --git a/.claude/plans/store/bindings.md b/.claude/plans/store/bindings.md index c0f279e6..420a6f54 100644 --- a/.claude/plans/store/bindings.md +++ b/.claude/plans/store/bindings.md @@ -20,7 +20,7 @@ Implemented React and Lit bindings for `@videojs/store`: `createStore()` factory ## Key Decisions -- Package structure: `store/react` and `store/lit` +- Package structure: `store/react` and `store/html` - Provider resolution: Isolated by default; `inherit` prop for parent context - Lit mixins: `StoreMixin` (combined), `StoreProviderMixin`, `StoreAttachMixin` diff --git a/.claude/plans/store/store-v2.md b/.claude/plans/store/store-v2.md index 856ee3cf..5ed20986 100644 --- a/.claude/plans/store/store-v2.md +++ b/.claude/plans/store/store-v2.md @@ -1299,7 +1299,7 @@ function createStore(config) { **Theme:** Update docs and AI guidance - `packages/store/README.md` β€” Update API docs -- `.claude/skills/component/references/lit.md` β€” Update mixin names +- `.claude/skills/component/references/html.md` β€” Update mixin names - `.claude/skills/docs/templates/readme.md` β€” Update mixin references - `CLAUDE.md` β€” Update store patterns/conventions - Document new v2 API patterns and examples diff --git a/.claude/skills/README.md b/.claude/skills/README.md index aaff80bd..4c7ef226 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -7,7 +7,7 @@ Specialized knowledge for AI agents working on Video.js 10. | Workflow | Load | | -------------------------- | ---------------------------------- | | Building store/features | `api` | -| Building Lit components | `component` + `aria` | +| Building HTML components | `component` + `aria` | | Building React components | `component` + `aria` | | Writing documentation | `docs` | | Writing Design Docs / RFCs | `design` or `rfc` | diff --git a/.claude/skills/component/SKILL.md b/.claude/skills/component/SKILL.md index 829ae8a3..4b080d77 100644 --- a/.claude/skills/component/SKILL.md +++ b/.claude/skills/component/SKILL.md @@ -4,7 +4,7 @@ description: >- Build accessible, headless UI components with modern architecture patterns. Use when creating component libraries, design systems, or reusable UI primitives. Handles compound components, state management, accessibility, styling hooks, and API design. - Includes Lit (controllers, ReactiveElement) and React (hooks, context) patterns. + Includes HTML (controllers, ReactiveElement) and React (hooks, context) patterns. Triggers: "create component", "component pattern", "compound component", "headless component". --- @@ -18,7 +18,7 @@ Build accessible, headless UI components using proven patterns from Base UI, Rad - [Ark UI](https://ark-ui.com/) β€” Cross-framework implementation - [Zag.js](https://zagjs.com/) β€” State machines for UI components -**Framework-specific:** [react.md](references/react.md) | [lit.md](references/lit.md) +**Framework-specific:** [react.md](references/react.md) | [html.md](references/html.md) --- @@ -173,8 +173,8 @@ See [props.md](references/props.md) for naming conventions. | File | Contents | | ----------------------------------------------- | ------------------------------------ | -| [lit.md](references/lit.md) | Lit controllers, mixins, context | -| [lit-fundamentals.md](references/lit-fundamentals.md) | ReactiveElement lifecycle, properties, styling | +| [html.md](references/html.md) | HTML controllers, mixins, context | +| [videojs-element.md](references/videojs-element.md) | ReactiveElement lifecycle, properties, controllers | | [react.md](references/react.md) | React hooks, context, refs | | [props.md](references/props.md) | Prop naming, conventions, defaults | | [styling.md](references/styling.md) | Data attributes, CSS variables | diff --git a/.claude/skills/component/references/lit.md b/.claude/skills/component/references/html.md similarity index 83% rename from .claude/skills/component/references/lit.md rename to .claude/skills/component/references/html.md index a5093156..544e1ab5 100644 --- a/.claude/skills/component/references/lit.md +++ b/.claude/skills/component/references/html.md @@ -1,20 +1,21 @@ -# Lit Component Patterns +# Component Patterns -Lit-specific patterns for Video.js web components. For ReactiveElement fundamentals (lifecycle, properties, styling), see [lit-fundamentals.md](lit-fundamentals.md). +Patterns for Video.js web components. For ReactiveElement fundamentals (lifecycle, properties, controllers), see [videojs-element.md](videojs-element.md). ## Package Requirements ```ts -import { ContextConsumer, ContextProvider } from '@lit/context'; -import { ReactiveElement } from '@lit/reactive-element'; +import { ReactiveElement } from '@videojs/element'; +import { ContextConsumer, ContextProvider } from '@videojs/element/context'; ``` -- Use `@lit/reactive-element` and `@lit/context` -- **Never** import from `lit` package +- Use `@videojs/element` for the base class and types +- Use `@videojs/element/context` for context (re-exports `@lit/context`) +- **Never** import from `lit` or `@lit/reactive-element` ## Platform Bindings -- Package-specific bindings live in `{package}/lit/` (e.g., `@videojs/store/lit`) +- Store HTML bindings: `@videojs/store/html` - Main web component library: `@videojs/html` --- @@ -42,7 +43,7 @@ import { ReactiveElement } from '@lit/reactive-element'; Controllers are the primary composability mechanism. Use controllers, not hooks or behavior mixins. -All store-related controllers live in `@videojs/store/lit`. See that package for available controllers and their APIs. +All store-related controllers live in `@videojs/store/html`. See that package for available controllers and their APIs. ### Controller Pattern @@ -129,7 +130,7 @@ class MyControls extends ContainerMixin(ReactiveElement) { ``` - Creates store on first access (lazy) -- Provides via Lit Context Protocol +- Provides via Context Protocol (`@lit/context`) - Destroys store on disconnect (if owned) --- @@ -168,6 +169,6 @@ customElements.define('vjs-player', StoreMixin(PlayerElement)); ## See Also -- `@videojs/store/lit` β€” Controller and mixin implementations +- `@videojs/store/html` β€” Controller and mixin implementations - `@videojs/html` β€” Web component library - [react.md](react.md) β€” React-specific patterns (parallel reference) diff --git a/.claude/skills/component/references/lit-fundamentals.md b/.claude/skills/component/references/lit-fundamentals.md deleted file mode 100644 index bdf82249..00000000 --- a/.claude/skills/component/references/lit-fundamentals.md +++ /dev/null @@ -1,324 +0,0 @@ -# Lit ReactiveElement Fundamentals - -Core Lit concepts for building web components. For Video.js-specific patterns, see [lit.md](lit.md). - -## ReactiveElement Basics - -`ReactiveElement` is the base class providing the reactive update cycle and property system. It extends `HTMLElement` directly: - -```ts -class MyElement extends ReactiveElement { - static properties = { - name: { type: String } - }; - - constructor() { - super(); - this.name = 'World'; - } -} -customElements.define('my-element', MyElement); -``` - -**Reactive update cycle:** property change β†’ `hasChanged()` check β†’ `requestUpdate()` β†’ microtask batch β†’ `shouldUpdate()` β†’ `willUpdate()` β†’ `update()` β†’ `firstUpdated()` (first time only) β†’ `updated()` β†’ `updateComplete` resolves. - -**Shadow DOM provides:** - -- DOM scoping (selectors won't leak) -- Style scoping (CSS encapsulated) -- Composition via slots - -Override `createRenderRoot()` to customize shadow root creation or disable it. - ---- - -## Reactive Properties - -Properties declared via static `properties` field: - -| Option | Purpose | -|--------|---------| -| `type` | `String \| Number \| Boolean \| Array \| Object` for attribute conversion | -| `attribute` | Custom attribute name or `false` to disable | -| `reflect` | `true` to sync property β†’ attribute | -| `converter` | Custom `fromAttribute`/`toAttribute` functions | -| `hasChanged` | Custom change detection function | -| `noAccessor` | Skip Lit's accessor generation | -| `useDefault` | Reset to default when attribute removed | -| `state` | `true` for internal reactive state (no attribute) | - -```ts -static properties = { - // Public property with attribute - name: { type: String }, - - // Custom attribute name - count: { type: Number, attribute: 'item-count' }, - - // Reflected to attribute - active: { type: Boolean, reflect: true }, - - // Internal state (no attribute) - _data: { state: true }, - - // Custom converter - date: { - converter: { - fromAttribute: (value) => new Date(value), - toAttribute: (value) => value.toISOString() - } - }, - - // Custom change detection - items: { - hasChanged: (newVal, oldVal) => newVal !== oldVal - } -}; -``` - ---- - -## Lifecycle Methods - -**In execution order:** - -1. **`constructor()`** β€” Initialize properties, call `super()`. No DOM access. - -2. **`connectedCallback()`** β€” Element added to DOM. Always call `super.connectedCallback()`. Start external subscriptions here. - -3. **`attributeChangedCallback(name, old, new)`** β€” Attribute changed. Handled automatically for declared properties. - -4. **`willUpdate(changedProperties)`** β€” Before update, compute derived state. Runs during SSR. - -5. **`update(changedProperties)`** β€” Performs the update. Call `super.update(changedProperties)`. - -6. **`firstUpdated(changedProperties)`** β€” After first update only. Safe for one-time DOM setup. - -7. **`updated(changedProperties)`** β€” After every update. Safe for DOM-dependent operations. - -8. **`disconnectedCallback()`** β€” Element removed. Always call `super.disconnectedCallback()`. Clean up subscriptions. - -**Update control methods:** - -- `requestUpdate(name?, oldValue?)` β€” Manually trigger update cycle -- `shouldUpdate(changedProperties)` β€” Return `false` to skip update -- `getUpdateComplete()` β€” Override to await child updates -- `updateComplete` β€” Promise resolving after update completes -- `performUpdate()` β€” Override for custom scheduling (use carefully) - -```ts -class MyElement extends ReactiveElement { - static properties = { - items: { type: Array }, - _computedValue: { state: true } - }; - - willUpdate(changedProperties) { - if (changedProperties.has('items')) { - this._computedValue = this.items.reduce((a, b) => a + b, 0); - } - } - - shouldUpdate(changedProperties) { - return this.items.length > 0; - } - - async getUpdateComplete() { - await super.getUpdateComplete(); - await this._childElement?.updateComplete; - } -} -``` - ---- - -## Shadow DOM and Render Root - -Override `createRenderRoot()` to customize: - -```ts -// Default: open shadow root -createRenderRoot() { - return this.attachShadow({ mode: 'open' }); -} - -// Render to light DOM instead -createRenderRoot() { - return this; -} - -// Custom shadow root options -createRenderRoot() { - return this.attachShadow({ - mode: 'open', - delegatesFocus: true - }); -} -``` - ---- - -## Styling - -Use static `styles` property for optimal performance (evaluated once per class, uses Constructable Stylesheets): - -```ts -import { css } from 'lit'; - -class MyElement extends ReactiveElement { - static styles = css` - :host { display: block; } - :host([hidden]) { display: none; } - :host(.active) { border: 1px solid blue; } - ::slotted(p) { color: blue; } - `; -} -``` - -**Key selectors:** - -- `:host` β€” Target host element -- `:host([attr])` β€” Host with attribute -- `:host(.class)` β€” Host with class -- `::slotted(selector)` β€” Direct slotted children only - -**CSS custom properties** inherit through shadow boundaries β€” primary theming mechanism. - -**Share styles across components:** - -```ts -// shared-styles.ts -export const sharedStyles = css` - :host { box-sizing: border-box; } -`; - -// my-element.ts -static styles = [sharedStyles, css` - :host { display: flex; } -`]; -``` - ---- - -## Reactive Controllers - -Encapsulate reusable behavior with lifecycle hooks: - -```ts -interface ReactiveController { - hostConnected?(): void; - hostDisconnected?(): void; - hostUpdate?(): void; - hostUpdated?(): void; -} - -class ClockController implements ReactiveController { - host: ReactiveControllerHost; - value = new Date(); - #timerID?: number; - - constructor(host: ReactiveControllerHost) { - this.host = host; - host.addController(this); - } - - hostConnected() { - this.#timerID = window.setInterval(() => { - this.value = new Date(); - this.host.requestUpdate(); - }, 1000); - } - - hostDisconnected() { - clearInterval(this.#timerID); - } -} - -// Usage -class MyElement extends ReactiveElement { - #clock = new ClockController(this); -} -``` - ---- - -## Context API (@lit/context) - -Context enables data sharing without prop drilling: - -```ts -import { createContext, ContextProvider, ContextConsumer } from '@lit/context'; - -// Define context -const userContext = createContext('user-context'); - -// Provider -class MyApp extends ReactiveElement { - #provider = new ContextProvider(this, { - context: userContext, - initialValue: { name: 'Guest' } - }); - - setUser(user: User) { - this.#provider.setValue(user); - } -} - -// Consumer -class UserDisplay extends ReactiveElement { - #consumer = new ContextConsumer(this, { - context: userContext, - subscribe: true, - callback: (value) => this.requestUpdate() - }); - - get user() { - return this.#consumer.value; - } -} -``` - ---- - -## TypeScript Configuration - -Required setting to prevent class fields from shadowing reactive accessors: - -```json -{ - "compilerOptions": { - "useDefineForClassFields": false - } -} -``` - ---- - -## Best Practices - -### Do - -- **Always call `super` in lifecycle methods** β€” Maintains Lit functionality -- **Use `willUpdate()` for derived state** β€” Computed before render -- **Clean up in `disconnectedCallback()`** β€” Prevents memory leaks -- **Use immutable data patterns** β€” `this.arr = [...this.arr, item]` not `push()` -- **Wait for `updateComplete`** before asserting DOM state in tests -- **Use `state: true`** for internal reactive state not exposed as attributes -- **Override `createRenderRoot()`** when shadow DOM isn't appropriate - -### Don't - -- **Don't mutate arrays/objects** without reassigning β€” Won't trigger updates -- **Don't access DOM in constructor** β€” Shadow root doesn't exist yet -- **Don't use external stylesheets via ``** β€” FOUC issues, ShadyCSS incompatibility -- **Don't reflect Object/Array properties** β€” Serialization overhead -- **Don't forget `super` calls** in lifecycle methods -- **Don't perform side effects in `shouldUpdate`** β€” Use `willUpdate` instead - -### Common Mistakes - -1. **Forgetting super calls**: `connectedCallback() { super.connectedCallback(); ... }` -2. **Boolean defaults**: Must default to `false` for attribute configuration to work -3. **Memory leaks**: Add listeners in `connectedCallback`, remove in `disconnectedCallback` -4. **DOM access too early**: Wait for `firstUpdated()` or `updateComplete` -5. **Mutating instead of replacing**: `this.items = [...this.items, item]` not `push()` diff --git a/.claude/skills/component/references/videojs-element.md b/.claude/skills/component/references/videojs-element.md new file mode 100644 index 00000000..0ea5d016 --- /dev/null +++ b/.claude/skills/component/references/videojs-element.md @@ -0,0 +1,259 @@ +# @videojs/element + +Core concepts for building web components with `@videojs/element`. For Video.js-specific component patterns, see [html.md](html.md). + +## ReactiveElement Basics + +`ReactiveElement` is our lightweight custom element base class (`@videojs/element`). It provides reactive properties, attribute reflection, batched updates, and reactive controllers β€” without Shadow DOM, `static styles`, or decorators. + +```ts +import { ReactiveElement } from '@videojs/element'; +import type { PropertyValues } from '@videojs/element'; + +class MyElement extends ReactiveElement { + static override properties = { + name: { type: String }, + }; + + name = 'World'; + + protected override update(changed: PropertyValues): void { + super.update(changed); + this.textContent = `Hello, ${this.name}!`; + } +} +customElements.define('my-element', MyElement); +``` + +**Reactive update cycle:** property change β†’ `Object.is()` check β†’ `requestUpdate()` β†’ microtask batch β†’ `scheduleUpdate()` β†’ `performUpdate()`: + +`willUpdate()` β†’ `hostUpdate()` (controllers) β†’ `update()` β†’ `hostUpdated()` (controllers) β†’ `hasUpdated = true` β†’ `firstUpdated()` (first time only) β†’ `updated()` β†’ `updateComplete` resolves. + +**Light DOM only** β€” elements render directly to `this` (e.g. `this.textContent`, `this.appendChild()`). No Shadow DOM, no `createRenderRoot()`. + +--- + +## Reactive Properties + +Properties declared via the static `properties` field: + +| Option | Purpose | +|--------|---------| +| `type` | `String`, `Boolean`, or `Number` β€” used for attribute β†’ property coercion | +| `attribute` | Custom attribute name (defaults to the property name) | + +```ts +static override properties = { + // String property, attribute name matches property name + label: { type: String }, + + // Number property + count: { type: Number }, + + // Boolean property (attribute presence = true, absence = false) + disabled: { type: Boolean }, + + // Custom attribute name + negativeSign: { type: String, attribute: 'negative-sign' }, +}; +``` + +**How attribute coercion works:** + +- `String` β€” attribute value passed through as-is +- `Boolean` β€” `true` if attribute is present, `false` if absent +- `Number` β€” attribute value parsed via `Number()` + +**Change detection** uses `Object.is()` β€” setting a property to the same value does not trigger an update. This correctly handles `NaN` and `-0`. + +**Property storage** uses Symbols internally. Prototype accessors are installed automatically when the class is registered via `customElements.define()`. + +### TypeScript Configuration + +Required to prevent class fields from shadowing reactive accessors: + +```json +{ + "compilerOptions": { + "useDefineForClassFields": false + } +} +``` + +Without this, `label = 'default'` uses `[[Define]]` semantics which creates an own data property, bypassing the prototype getter/setter. + +--- + +## Lifecycle Methods + +**In execution order:** + +1. **`constructor()`** β€” Call `super()`. Initialize field defaults. No DOM access. + +2. **`connectedCallback()`** β€” Element added to DOM. Always call `super.connectedCallback()`. Start subscriptions here. The first update is scheduled automatically. + +3. **`attributeChangedCallback(name, old, new)`** β€” Attribute changed. Handled automatically for declared properties β€” you rarely need to override this. + +4. **`willUpdate(changed: PropertyValues)`** β€” Called before `update()`. Use for computing derived state from changed properties. + +5. **`update(changed: PropertyValues)`** β€” Performs the DOM update. Always call `super.update(changed)`. This is where you write to the DOM. + +6. **`firstUpdated(changed: PropertyValues)`** β€” Called once after the very first update. Use for one-time setup that requires the DOM (e.g., measuring layout, adding event listeners that depend on rendered content). `hasUpdated` is `true` during this call. + +7. **`updated(changed: PropertyValues)`** β€” Called after every `update()` (including the first). `hasUpdated` is `true` during this call. Use for post-update side effects like focus management or external library syncing. + +8. **`disconnectedCallback()`** β€” Element removed. Always call `super.disconnectedCallback()`. Clean up subscriptions. + +**Update control:** + +- `requestUpdate(name?, oldValue?)` β€” Manually trigger an update cycle +- `isUpdatePending` β€” `boolean`, `true` while an update is queued or in progress +- `hasUpdated` β€” `boolean`, `false` until the first update cycle completes +- `performUpdate()` β€” Synchronously flush a pending update (no-op if none pending) +- `scheduleUpdate()` β€” Override to change update timing (e.g., use `requestAnimationFrame`). Default calls `performUpdate()`. +- `updateComplete` β€” Promise that resolves after the current update completes + +```ts +class MyElement extends ReactiveElement { + static override properties = { + items: { type: String }, + }; + + items = ''; + + #computedCount = 0; + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + if (changed.has('items')) { + this.#computedCount = this.items.split(',').length; + } + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + this.textContent = `${this.#computedCount} items`; + } +} +``` + +### What we DON'T have (vs Lit) + +These Lit features are **not available** in our ReactiveElement: + +- `shouldUpdate()` β€” no skipping updates +- `getUpdateComplete()` β€” no async update chaining + +--- + +## Reactive Controllers + +Encapsulate reusable behavior with lifecycle hooks: + +```ts +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; + +class ClockController implements ReactiveController { + #host: ReactiveControllerHost; + #timerID?: number; + value = new Date(); + + constructor(host: ReactiveControllerHost) { + this.#host = host; + host.addController(this); + } + + hostConnected(): void { + this.#timerID = window.setInterval(() => { + this.value = new Date(); + this.#host.requestUpdate(); + }, 1000); + } + + hostDisconnected(): void { + clearInterval(this.#timerID); + } +} + +// Usage +class MyElement extends ReactiveElement { + #clock = new ClockController(this); + + protected override update(): void { + this.textContent = this.#clock.value.toLocaleTimeString(); + } +} +``` + +**Controller interface (aligned with [Lit's ReactiveController](https://github.com/lit/lit/blob/main/packages/reactive-element/src/reactive-controller.ts)):** + +- `hostConnected()` β€” Called when the host element connects to the DOM +- `hostDisconnected()` β€” Called when the host element disconnects +- `hostUpdate()` β€” Called before the host's `willUpdate`/`update` +- `hostUpdated()` β€” Called after the host's update, before `updated()` + +--- + +## Context API + +Context enables data sharing without prop drilling. We re-export `@lit/context` from `@videojs/element/context`: + +```ts +import { createContext, ContextProvider, ContextConsumer } from '@videojs/element/context'; + +// Define context +const userContext = createContext('user-context'); + +// Provider +class MyApp extends ReactiveElement { + #provider = new ContextProvider(this, { + context: userContext, + initialValue: { name: 'Guest' }, + }); + + setUser(user: User): void { + this.#provider.setValue(user); + } +} + +// Consumer +class UserDisplay extends ReactiveElement { + #consumer = new ContextConsumer(this, { + context: userContext, + subscribe: true, + callback: () => this.requestUpdate(), + }); + + protected override update(): void { + const user = this.#consumer.value; + this.textContent = user?.name ?? 'Unknown'; + } +} +``` + +--- + +## Best Practices + +### Do + +- **Always call `super` in lifecycle methods** β€” maintains the reactive update cycle +- **Use `willUpdate()` for derived state** β€” computed before DOM update +- **Clean up in `disconnectedCallback()`** β€” prevents memory leaks +- **Use immutable data patterns** β€” `this.arr = [...this.arr, item]` not `push()` +- **Wait for `updateComplete`** before asserting DOM state in tests +- **Use `useDefineForClassFields: false`** in tsconfig for packages with reactive elements + +### Don't + +- **Don't mutate arrays/objects** without reassigning β€” won't trigger updates +- **Don't access DOM in constructor** β€” element isn't connected yet +- **Don't forget `super` calls** in lifecycle methods + +### Common Mistakes + +1. **Forgetting super calls**: `connectedCallback() { super.connectedCallback(); ... }` +2. **Boolean defaults**: Must default to `false` for attribute coercion to work correctly +3. **Memory leaks**: Add listeners in `connectedCallback`, remove in `disconnectedCallback` +4. **Mutating instead of replacing**: `this.items = [...this.items, item]` not `push()` +5. **Missing tsconfig setting**: `useDefineForClassFields: false` is required diff --git a/.claude/skills/component/references/videojs.md b/.claude/skills/component/references/videojs.md index 95364a57..cf841f33 100644 --- a/.claude/skills/component/references/videojs.md +++ b/.claude/skills/component/references/videojs.md @@ -13,7 +13,7 @@ Video.js components use a three-layer architecture separating framework-agnostic β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό @videojs/html @videojs/react - Web Components (Lit) React Components + Web Components React Components ``` | Package | Responsibility | @@ -148,7 +148,7 @@ export type StateAttrMap = { --- -## Web Component (Lit) +## Web Component ```ts class PlayButtonElement extends MediaElement { diff --git a/.claude/skills/component/review/checklists/videojs.md b/.claude/skills/component/review/checklists/videojs.md index 61624224..bbb3f9b7 100644 --- a/.claude/skills/component/review/checklists/videojs.md +++ b/.claude/skills/component/review/checklists/videojs.md @@ -20,7 +20,7 @@ Video.js-specific patterns. See [videojs.md](../../references/videojs.md) for ar - [ ] `getLabel()`/`getAttrs()` accept projected UI state, `toggle()` accepts raw media state - [ ] Data attribute map with `as const satisfies StateAttrMap<*State>` constraint -## Web Component (Lit) +## Web Component - [ ] Extends `MediaElement` - [ ] Uses `PlayerController` with selector for store subscription diff --git a/.claude/skills/component/review/workflow.md b/.claude/skills/component/review/workflow.md index 65bbd735..5928c148 100644 --- a/.claude/skills/component/review/workflow.md +++ b/.claude/skills/component/review/workflow.md @@ -13,7 +13,7 @@ Review UI components for architecture, API design, and patterns. β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Load References β”‚ -β”‚ Based on framework (Lit, React) and component type β”‚ +β”‚ Based on framework (HTML, React) and component type β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό @@ -44,7 +44,7 @@ Based on component type and framework: | Component Type | Load | | ---------------- | ---------------------------------------------- | | Any component | `references/props.md`, `references/styling.md` | -| Lit components | `references/lit.md` | +| HTML components | `references/html.md` | | React components | `references/react.md` | | Popups/modals | `references/collection.md` | | Animated | `references/animation.md` | @@ -100,7 +100,7 @@ Component reviews should always include accessibility checks. The `aria` skill p | [templates.md](templates.md) | Issue format, report template | | [../references/props.md](../references/props.md) | Prop naming conventions | | [../references/styling.md](../references/styling.md) | Data attributes, CSS variables | -| [../references/lit.md](../references/lit.md) | Lit controllers, mixins | +| [../references/html.md](../references/html.md) | HTML controllers, mixins | | [../references/react.md](../references/react.md) | React hooks, context | | [../references/polymorphism.md](../references/polymorphism.md) | render vs asChild | | [../references/collection.md](../references/collection.md) | Collections, portals | diff --git a/.claude/skills/create-skill/references/patterns.md b/.claude/skills/create-skill/references/patterns.md index e776ff7e..d326874e 100644 --- a/.claude/skills/create-skill/references/patterns.md +++ b/.claude/skills/create-skill/references/patterns.md @@ -13,7 +13,7 @@ description: >- Build accessible, headless UI components with modern architecture patterns. Use when creating component libraries, design systems, or reusable UI primitives. Handles compound components, state management, accessibility, styling hooks. - Includes Lit (controllers, ReactiveElement) and React (hooks, context) patterns. + Includes HTML (controllers, ReactiveElement) and React (hooks, context) patterns. --- # Component Architecture Patterns @@ -46,7 +46,7 @@ Build accessible, headless UI components using proven patterns. | File | Contents | |------|----------| -| [lit.md](references/lit.md) | Lit controllers, mixins | +| [html.md](references/html.md) | HTML controllers, mixins | | [react.md](references/react.md) | React hooks, context | | [props.md](references/props.md) | Prop naming conventions | diff --git a/.claude/skills/create-skill/references/principles.md b/.claude/skills/create-skill/references/principles.md index 3afefdc0..34617209 100644 --- a/.claude/skills/create-skill/references/principles.md +++ b/.claude/skills/create-skill/references/principles.md @@ -94,7 +94,7 @@ skill/ β”œβ”€β”€ SKILL.md (overview + navigation) └── references/ β”œβ”€β”€ react.md # React-specific patterns - β”œβ”€β”€ lit.md # Lit-specific patterns + β”œβ”€β”€ html.md # HTML/Web Component patterns └── vanilla.md # Vanilla JS patterns ``` diff --git a/.claude/skills/design/templates/feature-multi.md b/.claude/skills/design/templates/feature-multi.md index 3ddeaa64..efca8af8 100644 --- a/.claude/skills/design/templates/feature-multi.md +++ b/.claude/skills/design/templates/feature-multi.md @@ -45,7 +45,7 @@ What happens if we don't solve this? // Minimal working example \`\`\` -### HTML/Lit +### HTML \`\`\`ts // Same concept, different platform @@ -217,9 +217,9 @@ Usage examples for Feature Name. // React-specific patterns \`\`\` -### HTML/Lit +### HTML \`\`\`ts -// Lit-specific patterns +// HTML/Web Component patterns \`\`\` ``` diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md index fd59e714..2ba5839a 100644 --- a/.claude/skills/docs/SKILL.md +++ b/.claude/skills/docs/SKILL.md @@ -20,7 +20,7 @@ Load these files based on task: | README documentation | `templates/readme.md` | | Component docs | `references/component-libraries.md` | | Multi-framework docs | `references/multi-framework.md` (TODO) | -| Lit/Web Components | `references/component-libraries.md` | +| Web Components | `references/component-libraries.md` | | Studying exemplary docs | `references/gold-standard.md` | | State/config/tooling docs | `references/state-tooling.md` | | Error/troubleshooting docs | `patterns/error-docs.md` | @@ -240,9 +240,9 @@ When documenting, understand the package hierarchy: | Package | Subpaths | Purpose | Doc Focus | | ----------------------- | ------------------------------- | --------------------------------------- | ---------------------------------- | | `@videojs/utils` | `/dom`, `/predicate`, `/events` | Shared utilities | Utility reference | -| `@videojs/store` | `/lit`, `/react` | State management | Features, requests, error handling | +| `@videojs/store` | `/html`, `/react` | State management | Features, requests, error handling | | `@videojs/core` | `/dom` | Runtime-agnostic logic + media features | API reference, concepts | -| `@videojs/html` | `/skins/frosted` | Web Components (Lit) | Component docs, styling | +| `@videojs/html` | `/skins/frosted` | Web Components | Component docs, styling | | `@videojs/react` | β€” | React adapter | Components, hooks | | `@videojs/react-native` | β€” | React Native adapter | Mobile-specific guides | diff --git a/.claude/skills/docs/references/multi-framework.md b/.claude/skills/docs/references/multi-framework.md index 299d74c1..c254c5d4 100644 --- a/.claude/skills/docs/references/multi-framework.md +++ b/.claude/skills/docs/references/multi-framework.md @@ -7,7 +7,7 @@ | Framework | Package | Status | | ------------------ | ----------------------- | --------- | -| Vanilla/HTML (Lit) | `@videojs/html` | Available | +| Vanilla/HTML | `@videojs/html` | Available | | React | `@videojs/react` | Available | | React Native | `@videojs/react-native` | Available | diff --git a/.claude/skills/docs/templates/api-reference.md b/.claude/skills/docs/templates/api-reference.md index f40f36c1..3c905147 100644 --- a/.claude/skills/docs/templates/api-reference.md +++ b/.claude/skills/docs/templates/api-reference.md @@ -376,18 +376,18 @@ type FeatureNameRequests = InferFeatureRequests; --- -## Lit Controller Template +## Controller Template -For `@videojs/store/lit` controllers: +For `@videojs/store/html` controllers: ```markdown ## ControllerName Brief description of what this controller does. -import { ControllerName } from '@videojs/store/lit'; +import { ControllerName } from '@videojs/store/html'; -class MyElement extends LitElement { +class MyElement extends ReactiveElement { #controller = new ControllerName(this, source, ...args); render() { @@ -403,7 +403,7 @@ new ControllerName(host, source, ...args) | Parameter | Type | Description | | --------- | -------------------------------------- | ----------------------- | -| `host` | `ReactiveControllerHost & HTMLElement` | The Lit element | +| `host` | `ReactiveControllerHost & HTMLElement` | The host element | | `source` | `Store \| Context` | Direct store or context | ### Properties @@ -421,9 +421,9 @@ new ControllerName(host, source, ...args) ### Example -import { SnapshotController } from '@videojs/store/lit'; +import { SnapshotController } from '@videojs/store/html'; -class PlayButton extends LitElement { +class PlayButton extends ReactiveElement { #state = new SnapshotController(this, store.state); #play = new RequestController(this, context, 'play'); @@ -439,7 +439,7 @@ return html\` ### See Also - [Related Controller](/api/controllers/related) -- [Lit Integration Guide](/guides/lit) +- [Web Components Guide](/guides/web-components) ``` --- diff --git a/.claude/skills/docs/templates/component-page.md b/.claude/skills/docs/templates/component-page.md index 087d2442..b38f33ca 100644 --- a/.claude/skills/docs/templates/component-page.md +++ b/.claude/skills/docs/templates/component-page.md @@ -306,9 +306,9 @@ outline: 2px solid blue; --- -## Web Component Template (Lit) +## Web Component Template -For `@videojs/html` components built with Lit: +For `@videojs/html` components built with `@videojs/element`: ```markdown ## element-name @@ -326,7 +326,7 @@ ElementName.define(); ElementName.define('custom-name'); // With custom mixin -import { createStore } from '@videojs/store/lit'; +import { createStore } from '@videojs/store/html'; import { extendConfig } from '@videojs/html/video/skin'; const { StoreMixin } = createStore( @@ -383,7 +383,7 @@ ElementName.define('custom-name', StoreMixin); ### Extending -import { createStore } from '@videojs/store/lit'; +import { createStore } from '@videojs/store/html'; import { extendConfig, FrostedSkinElement } from '@videojs/html/video/skin'; import { chaptersFeature } from './features/chapters'; @@ -395,7 +395,7 @@ FrostedSkinElement.define('my-player', StoreMixin); ### See Also -- [Lit Controllers](/api/controllers) +- [Controllers](/api/controllers) - [Creating Custom Elements](/guides/custom-elements) ``` diff --git a/.claude/skills/docs/templates/readme.md b/.claude/skills/docs/templates/readme.md index fa45ac35..c1abc68a 100644 --- a/.claude/skills/docs/templates/readme.md +++ b/.claude/skills/docs/templates/readme.md @@ -160,7 +160,7 @@ Core concepts: - Task Configuration (Keys, Modes, Cancels) - Error Handling -### @videojs/store/lit +### @videojs/store/html Core concepts: diff --git a/.size-limit.json b/.size-limit.json index a84b2329..2f505b5e 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,14 +1,24 @@ [ + { + "name": "@videojs/element", + "path": "packages/element/dist/default/index.js", + "import": "*" + }, + { + "name": "@videojs/element/context", + "path": "packages/element/dist/default/context.js", + "import": "*" + }, { "name": "@videojs/store", "path": "packages/store/dist/default/index.js", "import": "*" }, { - "name": "@videojs/store/lit", - "path": "packages/store/dist/default/lit.js", + "name": "@videojs/store/html", + "path": "packages/store/dist/default/html.js", "import": "*", - "ignore": ["@videojs/utils", "@lit/reactive-element", "@lit/context"] + "ignore": ["@videojs/utils", "@videojs/element"] }, { "name": "@videojs/store/react", diff --git a/CLAUDE.md b/CLAUDE.md index c7071df7..45ab9ea6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Refer to **[`CONTRIBUTING.md`](./CONTRIBUTING.md)** for setup, development, and | ----------------------- | ------------------------------------------------------------------- | | `packages/utils` | Shared utilities (`/dom` subpath for DOM‑specific helpers). | | `packages/core` | Core runtime‑agnostic logic (`/dom` subpath for DOM bindings). | -| `packages/store` | State management (`/dom`, `/lit`, `/react` subpaths for platforms). | +| `packages/store` | State management (`/html`, `/react` subpaths for platforms). | | `packages/html` | Web playerβ€”DOM/Browser‑specific implementation. | | `packages/react` | React playerβ€”adapts core state to React components. | | `packages/react-native` | React Native player integration layer. | @@ -30,7 +30,7 @@ utils/dom ← DOM-specific helpers store ← state management store/dom ← DOM platform APIs -store/lit ← Lit bindings (controllers, mixins) +store/html ← HTML bindings (controllers, mixins) store/react ← React bindings core ← runtime-agnostic logic diff --git a/biome.json b/biome.json index 0131ce51..5376403a 100644 --- a/biome.json +++ b/biome.json @@ -68,6 +68,16 @@ } }, "overrides": [ + { + "includes": ["packages/element/**"], + "linter": { + "rules": { + "complexity": { + "noThisInStatic": "off" + } + } + } + }, { "includes": ["**/*.astro"], "linter": { diff --git a/commitlint.config.js b/commitlint.config.js index 5300bb12..2d5dc413 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -18,6 +18,7 @@ export default { 'claude', 'core', 'design', + 'element', 'docs', 'html', 'icons', diff --git a/packages/element/README.md b/packages/element/README.md new file mode 100644 index 00000000..292c0b71 --- /dev/null +++ b/packages/element/README.md @@ -0,0 +1,114 @@ +# @videojs/element + +[![package-badge]][package] + +> **Warning: Alpha - SUBJECT TO CHANGE** Not recommended for production use. + +A lightweight reactive custom element base class for Video.js. Type-aligned with [Lit's ReactiveElement](https://github.com/lit/lit/tree/main/packages/reactive-element) but stripped down to only what we use. + +```bash +npm install @videojs/element +``` + +## Why? + +Video.js web components used `@lit/reactive-element` but only needed a fraction of its API β€” reactive properties, batched updates, and controllers. The rest (Shadow DOM, `static styles`, decorators, complex attribute converters, `shouldUpdate`, custom scheduling) shipped as dead code (~2.8 kB brotli). + +`@videojs/element` provides the same programming model at ~840 B brotli. + +## Quick Start + +```ts +import { ReactiveElement } from '@videojs/element'; +import type { PropertyValues } from '@videojs/element'; + +class MyElement extends ReactiveElement { + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + }; + + label = 'Click me'; + disabled = false; + + protected override update(changed: PropertyValues): void { + super.update(changed); + this.textContent = this.label; + } +} + +customElements.define('my-element', MyElement); +``` + +## Alignment with Lit + +The API is a subset of Lit's `ReactiveElement`. Types are aligned so controllers written for Lit work with `@videojs/element` without changes. + +### What's included + +- **`static properties`** β€” Declare reactive properties with `type` (`String`, `Boolean`, `Number`) and `attribute` (custom attribute name) +- **Reactive accessors** β€” Installed automatically, change detection via `Object.is()` +- **Batched updates** β€” Multiple property changes in one tick trigger a single update via `queueMicrotask()` +- **Full lifecycle** β€” `willUpdate` β†’ `update` β†’ `firstUpdated` (first time) β†’ `updated` β†’ `updateComplete` +- **`hasUpdated`** β€” `false` until first update completes, `true` during `firstUpdated` and `updated` (matches Lit) +- **`isUpdatePending`** β€” `true` while an update is queued or in progress +- **`performUpdate()`** β€” Synchronously flush a pending update +- **`scheduleUpdate()`** β€” Override point for custom update timing (default calls `performUpdate()`) +- **Reactive controllers** β€” `addController`/`removeController` with `hostConnected`, `hostDisconnected`, `hostUpdate`, `hostUpdated` +- **Element upgrade handling** β€” Properties set before registration are preserved + +### What's NOT included + +| Lit feature | Why excluded | +|---|---| +| Shadow DOM / `createRenderRoot()` | We use light DOM exclusively | +| `static styles` / CSS adoption | No shadow root to adopt into | +| Decorators (`@property`, `@state`) | We use `static properties` | +| `shouldUpdate()` | No use case for skipping updates | +| `getUpdateComplete()` | No async update chaining needed | +| `reflect` option | No property-to-attribute reflection | +| `converter` option | Simple type coercion is sufficient | +| `state` option | All properties are observable | +| `hasChanged` option | `Object.is()` is always used | + +### Property inheritance + +Lit walks the prototype chain to collect properties from all ancestors. We don't β€” subclasses that define their own `static properties` must spread the parent: + +```ts +class FancyButton extends MyButton { + static override properties = { + ...MyButton.properties, + variant: { type: String }, + }; +} +``` + +This is only needed when a subclass declares `static properties`. If it doesn't, JS static property inheritance means the parent's properties are used automatically. + +## Context + +Context is re-exported from [`@lit/context`](https://github.com/lit/lit/tree/main/packages/context) β€” the same implementation used across the Lit ecosystem: + +```ts +import { createContext, ContextProvider, ContextConsumer } from '@videojs/element/context'; +``` + +This provides tree-scoped data sharing without prop drilling, using Lit's [Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). + +## Community + +If you need help with anything related to Video.js v10, or if you'd like to casually chat with other +members: + +- [Join Discord Server][discord] +- [See GitHub Discussions][gh-discussions] + +## License + +[Apache-2.0](./LICENSE) + +[package]: https://www.npmjs.com/package/@videojs/element +[package-badge]: https://img.shields.io/npm/v/@videojs/element/next?label=@videojs/element@next +[discord]: https://discord.gg/JBqHh485uF +[gh-discussions]: https://github.com/videojs/v10/discussions diff --git a/packages/element/package.json b/packages/element/package.json new file mode 100644 index 00000000..5a1337d7 --- /dev/null +++ b/packages/element/package.json @@ -0,0 +1,51 @@ +{ + "name": "@videojs/element", + "type": "module", + "version": "0.1.0-alpha.1", + "description": "Lightweight reactive custom element base for Video.js", + "license": "Apache-2.0", + "main": "dist/default/index.js", + "module": "dist/default/index.js", + "types": "dist/dev/index.d.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/dev/index.d.ts", + "development": "./dist/dev/index.js", + "default": "./dist/default/index.js" + }, + "./context": { + "types": "./dist/dev/context.d.ts", + "development": "./dist/dev/context.js", + "default": "./dist/default/context.js" + } + }, + "scripts": { + "build": "tsdown", + "build:watch": "tsdown --watch ./src --no-clean", + "dev": "pnpm run build:watch", + "test": "vitest run", + "clean": "rm -rf dist types" + }, + "dependencies": { + "@lit/context": "^1.1.0" + }, + "devDependencies": { + "happy-dom": "^18.0.1", + "tsdown": "^0.20.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "custom-element", + "reactive", + "videojs", + "element" + ] +} diff --git a/packages/element/src/context.ts b/packages/element/src/context.ts new file mode 100644 index 00000000..bd83d1bb --- /dev/null +++ b/packages/element/src/context.ts @@ -0,0 +1 @@ +export { type Context, ContextConsumer, ContextProvider, createContext } from '@lit/context'; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts new file mode 100644 index 00000000..98a63686 --- /dev/null +++ b/packages/element/src/index.ts @@ -0,0 +1,8 @@ +export { ReactiveElement } from './reactive-element'; +export type { + PropertyDeclaration, + PropertyDeclarationMap, + PropertyValues, + ReactiveController, + ReactiveControllerHost, +} from './types'; diff --git a/packages/element/src/reactive-element.ts b/packages/element/src/reactive-element.ts new file mode 100644 index 00000000..e6cb965b --- /dev/null +++ b/packages/element/src/reactive-element.ts @@ -0,0 +1,407 @@ +import type { PropertyDeclaration, PropertyDeclarationMap, PropertyValues, ReactiveController } from './types'; + +interface ResolvedMeta { + props: Map; + attrToProp: Map; +} + +const cache = new WeakMap(); +const propertyKeys = new Map(); + +/** + * Lightweight reactive custom element base class. + * + * Drop-in subset of Lit's `ReactiveElement` β€” supports `static properties`, + * attribute reflection, batched async updates, and reactive controllers. + * No Shadow DOM, no `static styles`, no decorators. + * + * Updates are batched using the same Promise-based scheduling as Lit: + * property changes enqueue a microtask, and the update is gated behind + * `connectedCallback` so the first update only runs once the element + * is in the document. + * + * Subclasses that extend another element with properties must spread them: + * + * @example + * ```ts + * class MyButton extends ReactiveElement { + * static override properties = { + * label: { type: String }, + * disabled: { type: Boolean }, + * }; + * + * label = 'Click me'; + * disabled = false; + * + * protected override update(changed: PropertyValues): void { + * super.update(changed); + * this.textContent = this.label; + * } + * } + * + * // Inheritance β€” spread parent properties + * class FancyButton extends MyButton { + * static override properties = { + * ...MyButton.properties, + * variant: { type: String }, + * }; + * + * variant = 'primary'; + * } + * ``` + */ +export class ReactiveElement extends HTMLElement { + /** + * User-supplied object that maps property names to + * {@linkcode PropertyDeclaration} objects containing options for configuring + * reactive properties. When a reactive property is set the element will + * update and render. + */ + static properties: PropertyDeclarationMap = {}; + + /** + * Returns a list of attributes corresponding to the registered properties. + */ + static get observedAttributes(): string[] { + return [...resolve(this).attrToProp.keys()]; + } + + // --- Instance state --- + + #controllers: Set = new Set(); + #changedProperties: PropertyValues = new Map(); + #instanceProperties: Map | undefined; + + /** + * Promise that gates the first update until `connectedCallback`. Also + * used to serialize updates β€” each `#enqueueUpdate` awaits the previous + * `#updatePromise`, so property changes are batched and updates never + * overlap. Matches Lit's scheduling model. + */ + #updatePromise: Promise; + + /** + * True if there is a pending update as a result of calling + * `requestUpdate()`. Should only be read. + */ + isUpdatePending = false; + + /** + * Is set to `true` after the first update. The element code cannot assume + * that the DOM is fully initialized before the element `hasUpdated`. + */ + hasUpdated = false; + + constructor() { + super(); + + // Initialize to an unresolved Promise so we can make sure the element + // has connected before the first update. The resolver is assigned to + // `this.enableUpdating`, overriding the no-op prototype method. + this.#updatePromise = new Promise( + (res) => (this.enableUpdating = res as (requestedUpdate: boolean) => void) + ); + + // Save instance properties that might shadow prototype accessors. + // Handles the "upgrade" case where properties were set before registration. + const { props } = resolve(this.constructor as typeof ReactiveElement); + + for (const name of props.keys()) { + if (Object.hasOwn(this, name)) { + (this.#instanceProperties ??= new Map()).set(name, (this as Record)[name]); + delete (this as Record)[name]; + } + } + + // Enqueue the first update. It won't run until connectedCallback calls + // `this.enableUpdating(true)` which resolves the #updatePromise gate. + this.requestUpdate(); + } + + /** + * Note, this method should be considered final and not overridden. It is + * overridden on the element instance with a function that triggers the + * first update. + */ + protected enableUpdating(_requestedUpdate: boolean): void {} + + /** + * Registers a {@linkcode ReactiveController} to participate in the + * element's reactive update cycle. The element automatically calls into + * any registered controllers during its lifecycle callbacks. + * + * If the element is connected when `addController()` is called, the + * controller's `hostConnected()` callback will be immediately called. + */ + addController(controller: ReactiveController): void { + this.#controllers.add(controller); + + if (this.isConnected) { + controller.hostConnected?.(); + } + } + + /** Removes a {@linkcode ReactiveController} from the element. */ + removeController(controller: ReactiveController): void { + this.#controllers.delete(controller); + } + + /** + * On first connection, enables updating and notifies controllers. + */ + connectedCallback(): void { + this.enableUpdating(true); + + for (const c of this.#controllers) { + c.hostConnected?.(); + } + } + + disconnectedCallback(): void { + for (const c of this.#controllers) { + c.hostDisconnected?.(); + } + } + + /** + * Synchronizes property values when attributes change. + * + * Specifically, when an attribute is set, the corresponding property is + * set. You should rarely need to implement this callback. If this method + * is overridden, `super.attributeChangedCallback(name, _old, value)` must + * be called. + */ + attributeChangedCallback(attr: string, oldValue: string | null, newValue: string | null): void { + if (oldValue === newValue) return; + + const { props, attrToProp } = resolve(this.constructor as typeof ReactiveElement); + const propName = attrToProp.get(attr); + if (!propName) return; + + const decl = props.get(propName); + if (!decl) return; + + let value: unknown = newValue; + + if (decl.type === Boolean) { + value = newValue !== null; + } else if (decl.type === Number) { + value = newValue === null ? null : Number(newValue); + } + (this as Record)[propName] = value; + } + + /** + * Requests an update which is processed asynchronously. This should be + * called when an element should update based on some state not triggered + * by setting a reactive property. In this case, pass no arguments. It + * should also be called when manually implementing a property setter. In + * this case, pass the property `name` and `oldValue` to ensure that any + * configured property options are honored. + */ + requestUpdate(name?: string, oldValue?: unknown): void { + if (name !== undefined) { + this.#changedProperties.set(name, oldValue); + } + + if (this.isUpdatePending) return; + this.#updatePromise = this.#enqueueUpdate(); + } + + /** + * Sets up the element to asynchronously update. Awaits the previous + * `#updatePromise` which both serializes updates and (on first update) + * waits for `connectedCallback` to resolve the gate. + */ + async #enqueueUpdate(): Promise { + this.isUpdatePending = true; + + try { + // Ensure any previous update has resolved before updating. + // This `await` also ensures that property changes are batched. + await this.#updatePromise; + } catch (e: unknown) { + // Refire any previous errors async so they do not disrupt the + // update cycle. + Promise.reject(e); + } + + const result = this.scheduleUpdate(); + + // If `scheduleUpdate` returns a Promise, we await it. This is done to + // enable coordinating updates with a scheduler. + if (result != null) { + await result; + } + + return !this.isUpdatePending; + } + + /** + * Schedules an element update. You can override this method to change the + * timing of updates by returning a Promise. The update will await the + * returned Promise, and you should resolve the Promise to allow the update + * to proceed. If this method is overridden, `super.scheduleUpdate()` must + * be called. + * + * For instance, to schedule updates to occur just before the next frame: + * + * ```ts + * override protected async scheduleUpdate(): Promise { + * await new Promise((resolve) => requestAnimationFrame(() => resolve())); + * super.scheduleUpdate(); + * } + * ``` + */ + protected scheduleUpdate(): void | Promise { + this.performUpdate(); + } + + /** + * Performs an element update. Note, if an exception is thrown during the + * update, `firstUpdated` and `updated` will not be called. + * + * Call `performUpdate()` to immediately process a pending update. This + * should generally not be needed, but it can be done in rare cases when + * you need to update synchronously. + */ + protected performUpdate(): void { + // Abort any update if one is not pending when this is called. + // This can happen if `performUpdate` is called early to "flush" + // the update. + if (!this.isUpdatePending) return; + + // Restore saved instance properties on first update. + if (!this.hasUpdated && this.#instanceProperties) { + for (const [name, value] of this.#instanceProperties) { + (this as Record)[name] = value; + } + this.#instanceProperties = undefined; + } + + const changed = this.#changedProperties; + + this.willUpdate(changed); + + for (const c of this.#controllers) { + c.hostUpdate?.(); + } + + this.update(changed); + + // The update is no longer pending and further updates are now allowed. + this.#changedProperties = new Map(); + this.isUpdatePending = false; + + for (const c of this.#controllers) { + c.hostUpdated?.(); + } + + if (!this.hasUpdated) { + this.hasUpdated = true; + this.firstUpdated(changed); + } + + this.updated(changed); + } + + /** + * Invoked before `update()` to compute values needed during the update. + * + * Implement `willUpdate` to compute property values that depend on other + * properties and are used in the rest of the update process. + * + * ```ts + * willUpdate(changed) { + * if (changed.has('firstName') || changed.has('lastName')) { + * this.sha = computeSHA(`${this.firstName} ${this.lastName}`); + * } + * } + * ``` + */ + protected willUpdate(_changed: PropertyValues): void {} + + /** + * Updates the element. This method reflects property values to attributes + * and can be overridden to render and keep updated element DOM. Setting + * properties inside this method will *not* trigger another update. + */ + protected update(_changed: PropertyValues): void {} + + /** + * Invoked when the element is first updated. Implement to perform one + * time work on the element after update. + * + * Setting properties inside this method will trigger the element to + * update again after this update cycle completes. + */ + protected firstUpdated(_changed: PropertyValues): void {} + + /** + * Invoked whenever the element is updated. Implement to perform + * post-updating tasks via DOM APIs, for example, focusing an element. + * + * Setting properties inside this method will trigger the element to + * update again after this update cycle completes. + */ + protected updated(_changed: PropertyValues): void {} + + /** + * Returns a Promise that resolves when the element has completed updating. + * The Promise value is a boolean that is `true` if the element completed + * the update without triggering another update. The Promise result is + * `false` if a property was set inside `updated()`. + */ + get updateComplete(): Promise { + return this.#updatePromise; + } +} + +/** + * Resolve `ctor.properties` into lookup Maps and install reactive accessors + * on the prototype. Runs once per class, result is cached. + * + * Subclasses that need parent properties must spread them: + * `static override properties = { ...Parent.properties, ... }`. + */ +function resolve(ctor: typeof ReactiveElement): ResolvedMeta { + const existing = cache.get(ctor); + if (existing) return existing; + + const props = new Map(); + const attrToProp = new Map(); + + for (const [name, decl] of Object.entries(ctor.properties)) { + props.set(name, decl); + attrToProp.set(decl.attribute ?? name, name); + + // Install reactive accessor on the prototype + if (!Object.getOwnPropertyDescriptor(ctor.prototype, name)?.get) { + let key = propertyKeys.get(name); + if (!key) { + key = Symbol(name); + propertyKeys.set(name, key); + } + + Object.defineProperty(ctor.prototype, name, { + get(this: ReactiveElement) { + return (this as unknown as Record)[key]; + }, + set(this: ReactiveElement, value: unknown) { + const old = (this as unknown as Record)[key]; + (this as unknown as Record)[key] = value; + + if (!Object.is(old, value)) { + this.requestUpdate(name, old); + } + }, + configurable: true, + enumerable: true, + }); + } + } + + const meta: ResolvedMeta = { props, attrToProp }; + cache.set(ctor, meta); + return meta; +} diff --git a/packages/element/src/tests/reactive-element.test.ts b/packages/element/src/tests/reactive-element.test.ts new file mode 100644 index 00000000..bec8e515 --- /dev/null +++ b/packages/element/src/tests/reactive-element.test.ts @@ -0,0 +1,649 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ReactiveElement } from '../reactive-element'; +import type { PropertyValues, ReactiveController } from '../types'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(ctor: { new (): T }): T { + const tag = uniqueTag('test-el'); + if (!customElements.get(tag)) { + customElements.define(tag, class extends (ctor as typeof HTMLElement) {} as typeof HTMLElement); + } + return document.createElement(tag) as T; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('ReactiveElement', () => { + it('extends HTMLElement', () => { + const el = createElement(ReactiveElement); + expect(el).toBeInstanceOf(HTMLElement); + }); + + it('calls connectedCallback and disconnectedCallback', () => { + const connected = vi.fn(); + const disconnected = vi.fn(); + + class TestElement extends ReactiveElement { + override connectedCallback() { + super.connectedCallback(); + connected(); + } + override disconnectedCallback() { + super.disconnectedCallback(); + disconnected(); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + expect(connected).toHaveBeenCalledOnce(); + + el.remove(); + expect(disconnected).toHaveBeenCalledOnce(); + }); + + it('runs willUpdate and update on first connect', async () => { + const willUpdate = vi.fn(); + const update = vi.fn(); + + class TestElement extends ReactiveElement { + protected override willUpdate(changed: PropertyValues) { + super.willUpdate(changed); + willUpdate(changed); + } + protected override update(changed: PropertyValues) { + super.update(changed); + update(changed); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + expect(willUpdate).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledOnce(); + }); + + it('does not run update before connect', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + protected override update() { + update(); + } + } + + createElement(TestElement); + + // Flush multiple microtasks β€” update should never fire without connect + await new Promise((r) => setTimeout(r, 10)); + + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe('ReactiveElement properties', () => { + it('reflects string attribute to property', () => { + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = 'default'; + } + + const el = createElement(TestElement); + el.setAttribute('label', 'hello'); + expect(el.label).toBe('hello'); + }); + + it('reflects boolean attribute to property', () => { + class TestElement extends ReactiveElement { + static override properties = { + disabled: { type: Boolean }, + }; + disabled = false; + } + + const el = createElement(TestElement); + + el.setAttribute('disabled', ''); + expect(el.disabled).toBe(true); + + el.removeAttribute('disabled'); + expect(el.disabled).toBe(false); + }); + + it('coerces number attribute to property', () => { + class TestElement extends ReactiveElement { + static override properties = { + count: { type: Number }, + }; + count = 0; + } + + const el = createElement(TestElement); + + el.setAttribute('count', '42'); + expect(el.count).toBe(42); + expect(typeof el.count).toBe('number'); + + el.setAttribute('count', '3.14'); + expect(el.count).toBe(3.14); + + el.setAttribute('count', 'not-a-number'); + expect(el.count).toBeNaN(); + }); + + it('supports custom attribute names', () => { + class TestElement extends ReactiveElement { + static override properties = { + negativeSign: { type: String, attribute: 'negative-sign' }, + }; + negativeSign = '-'; + } + + const el = createElement(TestElement); + el.setAttribute('negative-sign', '\u2212'); + expect(el.negativeSign).toBe('\u2212'); + }); + + it('triggers requestUpdate on property change', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = 'default'; + + protected override update(changed: PropertyValues) { + super.update(changed); + update(changed); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + update.mockClear(); + + el.label = 'new'; + await el.updateComplete; + + expect(update).toHaveBeenCalledOnce(); + const changed = update.mock.calls[0]![0] as PropertyValues; + expect(changed.get('label')).toBe('default'); + }); + + it('batches multiple property changes into single update', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + disabled: { type: Boolean }, + }; + label = ''; + disabled = false; + + protected override update(changed: PropertyValues) { + super.update(changed); + update(changed); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + update.mockClear(); + + el.label = 'hello'; + el.disabled = true; + await el.updateComplete; + + expect(update).toHaveBeenCalledOnce(); + const changed = update.mock.calls[0]![0] as PropertyValues; + expect(changed.has('label')).toBe(true); + expect(changed.has('disabled')).toBe(true); + }); + + it('does not trigger update when value is unchanged', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = 'same'; + + protected override update() { + update(); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + update.mockClear(); + + el.label = 'same'; + + // Flush multiple microtasks β€” update should not fire + await new Promise((r) => setTimeout(r, 10)); + + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe('ReactiveElement controllers', () => { + it('dispatches hostConnected on connect', () => { + const controller: ReactiveController = { + hostConnected: vi.fn(), + hostDisconnected: vi.fn(), + }; + + const el = createElement(ReactiveElement); + el.addController(controller); + + document.body.appendChild(el); + expect(controller.hostConnected).toHaveBeenCalledOnce(); + }); + + it('dispatches hostDisconnected on disconnect', () => { + const controller: ReactiveController = { + hostConnected: vi.fn(), + hostDisconnected: vi.fn(), + }; + + const el = createElement(ReactiveElement); + el.addController(controller); + + document.body.appendChild(el); + el.remove(); + expect(controller.hostDisconnected).toHaveBeenCalledOnce(); + }); + + it('dispatches hostConnected immediately if already connected', () => { + const controller: ReactiveController = { + hostConnected: vi.fn(), + }; + + const el = createElement(ReactiveElement); + document.body.appendChild(el); + + el.addController(controller); + expect(controller.hostConnected).toHaveBeenCalledOnce(); + }); + + it('removes controller', () => { + const controller: ReactiveController = { + hostDisconnected: vi.fn(), + }; + + const el = createElement(ReactiveElement); + el.addController(controller); + el.removeController(controller); + + document.body.appendChild(el); + el.remove(); + expect(controller.hostDisconnected).not.toHaveBeenCalled(); + }); + + it('dispatches to multiple controllers', () => { + const c1: ReactiveController = { hostConnected: vi.fn() }; + const c2: ReactiveController = { hostConnected: vi.fn() }; + + const el = createElement(ReactiveElement); + el.addController(c1); + el.addController(c2); + + document.body.appendChild(el); + expect(c1.hostConnected).toHaveBeenCalledOnce(); + expect(c2.hostConnected).toHaveBeenCalledOnce(); + }); +}); + +describe('ReactiveElement lifecycle', () => { + it('calls firstUpdated only once on first update', async () => { + const firstUpdated = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + + protected override firstUpdated(changed: PropertyValues) { + firstUpdated(new Map(changed)); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + expect(firstUpdated).toHaveBeenCalledOnce(); + + // Second update should not call firstUpdated again + firstUpdated.mockClear(); + el.label = 'new'; + await el.updateComplete; + + expect(firstUpdated).not.toHaveBeenCalled(); + }); + + it('calls updated after every update', async () => { + const updated = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + + protected override updated(changed: PropertyValues) { + updated(new Map(changed)); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + expect(updated).toHaveBeenCalledOnce(); + + // Should fire on subsequent updates too + updated.mockClear(); + el.label = 'new'; + await el.updateComplete; + + expect(updated).toHaveBeenCalledOnce(); + const changed = updated.mock.calls[0]![0] as PropertyValues; + expect(changed.get('label')).toBe(''); + }); + + it('calls lifecycle methods in correct order (matches Lit)', async () => { + const calls: string[] = []; + + const controller: ReactiveController = { + hostUpdate: () => calls.push('hostUpdate'), + hostUpdated: () => calls.push('hostUpdated'), + }; + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + + constructor() { + super(); + this.addController(controller); + } + + protected override willUpdate(_changed: PropertyValues) { + calls.push('willUpdate'); + } + protected override update(_changed: PropertyValues) { + calls.push('update'); + } + protected override firstUpdated(_changed: PropertyValues) { + calls.push('firstUpdated'); + } + protected override updated(_changed: PropertyValues) { + calls.push('updated'); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + // Lit order: willUpdate β†’ hostUpdate β†’ update β†’ hostUpdated β†’ firstUpdated β†’ updated + expect(calls).toEqual(['willUpdate', 'hostUpdate', 'update', 'hostUpdated', 'firstUpdated', 'updated']); + + // Second update β€” no firstUpdated + calls.length = 0; + el.label = 'new'; + await el.updateComplete; + + expect(calls).toEqual(['willUpdate', 'hostUpdate', 'update', 'hostUpdated', 'updated']); + }); + + it('sets hasUpdated to true after first update', async () => { + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + } + + const el = createElement(TestElement); + expect(el.hasUpdated).toBe(false); + + document.body.appendChild(el); + expect(el.hasUpdated).toBe(false); + + await el.updateComplete; + expect(el.hasUpdated).toBe(true); + }); + + it('hasUpdated is true inside firstUpdated and updated (matches Lit)', async () => { + let hasUpdatedDuringFirstUpdated: boolean | undefined; + let hasUpdatedDuringUpdated: boolean | undefined; + + class TestElement extends ReactiveElement { + protected override firstUpdated(_changed: PropertyValues) { + hasUpdatedDuringFirstUpdated = this.hasUpdated; + } + protected override updated(_changed: PropertyValues) { + hasUpdatedDuringUpdated = this.hasUpdated; + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + // Matches Lit: hasUpdated is set before firstUpdated and updated + expect(hasUpdatedDuringFirstUpdated).toBe(true); + expect(hasUpdatedDuringUpdated).toBe(true); + expect(el.hasUpdated).toBe(true); + }); +}); + +describe('ReactiveElement isUpdatePending', () => { + it('is true after requestUpdate, false after update completes', async () => { + const el = createElement(ReactiveElement); + document.body.appendChild(el); + await el.updateComplete; + + expect(el.isUpdatePending).toBe(false); + + el.requestUpdate(); + expect(el.isUpdatePending).toBe(true); + + await el.updateComplete; + expect(el.isUpdatePending).toBe(false); + }); + + it('is true during update cycle', async () => { + let pendingDuringUpdate: boolean | undefined; + let pendingDuringUpdated: boolean | undefined; + + class TestElement extends ReactiveElement { + protected override update(_changed: PropertyValues) { + pendingDuringUpdate = this.isUpdatePending; + } + protected override updated(_changed: PropertyValues) { + pendingDuringUpdated = this.isUpdatePending; + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + // isUpdatePending is true during update, false during updated + // (matches Lit: __markUpdated runs after update, before _$didUpdate) + expect(pendingDuringUpdate).toBe(true); + expect(pendingDuringUpdated).toBe(false); + }); +}); + +describe('ReactiveElement performUpdate', () => { + it('flushes a pending update synchronously', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + + override performUpdate() { + super.performUpdate(); + } + + protected override update(changed: PropertyValues) { + super.update(changed); + update(changed); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + update.mockClear(); + + el.label = 'sync'; + expect(el.isUpdatePending).toBe(true); + + // Flush synchronously + el.performUpdate(); + expect(update).toHaveBeenCalledOnce(); + expect(el.isUpdatePending).toBe(false); + }); + + it('is a no-op when no update is pending', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + override performUpdate() { + super.performUpdate(); + } + + protected override update() { + update(); + } + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + update.mockClear(); + + el.performUpdate(); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe('ReactiveElement updateComplete', () => { + it('resolves after first update when connected', async () => { + const el = createElement(ReactiveElement); + document.body.appendChild(el); + + const result = await el.updateComplete; + expect(result).toBe(true); + expect(el.hasUpdated).toBe(true); + }); + + it('resolves after property-triggered update', async () => { + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + } + + const el = createElement(TestElement); + document.body.appendChild(el); + await el.updateComplete; + + el.label = 'changed'; + const result = await el.updateComplete; + expect(result).toBe(true); + }); +}); + +describe('ReactiveElement property inheritance', () => { + it('inherits properties from parent class', () => { + class Base extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = ''; + } + + class Child extends Base { + static override properties = { + ...Base.properties, + disabled: { type: Boolean }, + }; + disabled = false; + } + + const el = createElement(Child); + + el.setAttribute('label', 'test'); + expect(el.label).toBe('test'); + + el.setAttribute('disabled', ''); + expect(el.disabled).toBe(true); + }); +}); + +describe('ReactiveElement upgrade', () => { + it('preserves properties set before upgrade', async () => { + const update = vi.fn(); + + class TestElement extends ReactiveElement { + static override properties = { + label: { type: String }, + }; + label = 'default'; + + protected override update(changed: PropertyValues) { + super.update(changed); + update(this.label); + } + } + + const tag = uniqueTag('upgrade-el'); + const el = document.createElement(tag) as TestElement; + + // Set property before defining custom element + (el as unknown as Record).label = 'pre-upgrade'; + document.body.appendChild(el); + + // Define after (upgrade scenario) + customElements.define(tag, class extends TestElement {}); + + // Wait for upgrade and first update + await new Promise((r) => setTimeout(r, 10)); + + expect(el.label).toBe('pre-upgrade'); + expect(update).toHaveBeenCalled(); + }); +}); diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts new file mode 100644 index 00000000..e54c2cfd --- /dev/null +++ b/packages/element/src/types.ts @@ -0,0 +1,104 @@ +/** + * An object that can host Reactive Controllers and call their lifecycle + * callbacks. + */ +export interface ReactiveControllerHost { + /** + * Adds a controller to the host, which sets up the controller's lifecycle + * methods to be called with the host's lifecycle. + */ + addController(controller: ReactiveController): void; + + /** Removes a controller from the host. */ + removeController(controller: ReactiveController): void; + + /** + * Requests a host update which is processed asynchronously. The update can + * be waited on via the `updateComplete` property. + */ + requestUpdate(): void; + + /** + * Returns a Promise that resolves when the host has completed updating. + * The Promise value is a boolean that is `true` if the element completed the + * update without triggering another update. The Promise result is `false` if + * a property was set inside `updated()`. If the Promise is rejected, an + * exception was thrown during the update. + */ + readonly updateComplete: Promise; +} + +/** + * A Reactive Controller is an object that enables sub-component code + * organization and reuse by aggregating the state, behavior, and lifecycle + * hooks related to a single feature. + * + * Controllers are added to a host component, or other object that implements + * the {@linkcode ReactiveControllerHost} interface, via the `addController()` + * method. They can hook their host component's lifecycle by implementing one + * or more of the lifecycle callbacks, or initiate an update of the host + * component by calling `requestUpdate()` on the host. + */ +export interface ReactiveController { + /** + * Called when the host is connected to the component tree. For custom + * element hosts, this corresponds to the `connectedCallback()` lifecycle, + * which is only called when the component is connected to the document. + */ + hostConnected?(): void; + + /** + * Called when the host is disconnected from the component tree. For custom + * element hosts, this corresponds to the `disconnectedCallback()` lifecycle, + * which is called when the host or an ancestor component is disconnected + * from the document. + */ + hostDisconnected?(): void; + + /** + * Called during the client-side host update, just before the host calls + * its own update. + */ + hostUpdate?(): void; + + /** + * Called after a host update, just before the host calls `firstUpdated` and + * `updated`. + */ + hostUpdated?(): void; +} + +/** + * A Map of property keys to previous values, provided to lifecycle methods + * that receive changed properties. + */ +export type PropertyValues = Map; + +/** Defines options for a reactive property. */ +export interface PropertyDeclaration { + /** + * Indicates the type of the property. This is used as a hint to determine + * how to convert between attributes and properties. + */ + readonly type?: typeof String | typeof Boolean | typeof Number; + + /** + * Indicates the attribute name to use for this property. If a string, + * that string is used as the attribute name. By default, the lowercased + * property name is used. + */ + readonly attribute?: string; +} + +/** + * Map of property names to {@linkcode PropertyDeclaration} options. + * + * @example + * ```ts + * static override properties = { + * src: { type: String }, + * muted: { type: Boolean }, + * } satisfies PropertyDeclarationMap; + * ``` + */ +export type PropertyDeclarationMap = Record; diff --git a/packages/element/tsconfig.json b/packages/element/tsconfig.json new file mode 100644 index 00000000..b84e05d6 --- /dev/null +++ b/packages/element/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "declarationDir": "types", + "useDefineForClassFields": false + }, + "include": ["src"] +} diff --git a/packages/element/tsdown.config.ts b/packages/element/tsdown.config.ts new file mode 100644 index 00000000..cbab71ef --- /dev/null +++ b/packages/element/tsdown.config.ts @@ -0,0 +1,26 @@ +import type { UserConfig } from 'tsdown'; +import { defineConfig } from 'tsdown'; + +type BuildMode = 'dev' | 'default'; + +const buildModes: BuildMode[] = ['dev', 'default']; + +const createConfig = (mode: BuildMode): UserConfig => ({ + entry: { + index: './src/index.ts', + context: './src/context.ts', + }, + platform: 'browser', + format: 'es', + sourcemap: true, + clean: true, + hash: false, + unbundle: true, + outDir: `dist/${mode}`, + define: { + __DEV__: mode === 'dev' ? 'true' : 'false', + }, + dts: mode === 'dev', +}); + +export default defineConfig(buildModes.map((mode) => createConfig(mode))); diff --git a/packages/element/vitest.config.ts b/packages/element/vitest.config.ts new file mode 100644 index 00000000..0ae70540 --- /dev/null +++ b/packages/element/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/html/package.json b/packages/html/package.json index a9273dcd..9d0d2d3f 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -58,9 +58,8 @@ "clean": "rm -rf dist types" }, "dependencies": { - "@lit/context": "^1.1.0", - "@lit/reactive-element": "^2.1.2", "@videojs/core": "workspace:*", + "@videojs/element": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*" }, diff --git a/packages/html/src/player/context.ts b/packages/html/src/player/context.ts index b662127b..4560b622 100644 --- a/packages/html/src/player/context.ts +++ b/packages/html/src/player/context.ts @@ -1,5 +1,5 @@ -import { type Context, createContext } from '@lit/context'; import type { AnyPlayerStore, PlayerStore } from '@videojs/core/dom'; +import { type Context, createContext } from '@videojs/element/context'; export const PLAYER_CONTEXT_KEY = Symbol('@videojs/player'); diff --git a/packages/html/src/player/player-controller.ts b/packages/html/src/player/player-controller.ts index 03e404d5..e6967ba3 100644 --- a/packages/html/src/player/player-controller.ts +++ b/packages/html/src/player/player-controller.ts @@ -1,8 +1,8 @@ -import { ContextConsumer } from '@lit/context'; -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; import type { PlayerStore } from '@videojs/core/dom'; +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; import type { InferStoreState, Selector } from '@videojs/store'; -import { StoreController } from '@videojs/store/lit'; +import { StoreController } from '@videojs/store/html'; import type { PlayerContext } from './context'; diff --git a/packages/html/src/store/container-mixin.ts b/packages/html/src/store/container-mixin.ts index 4a1d4cbe..3fdfac97 100644 --- a/packages/html/src/store/container-mixin.ts +++ b/packages/html/src/store/container-mixin.ts @@ -1,5 +1,5 @@ -import { ContextConsumer } from '@lit/context'; -import type { Media, MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom'; +import type { MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom'; +import { ContextConsumer } from '@videojs/element/context'; import { noop } from '@videojs/utils/function'; import type { MediaElementConstructor } from '@/ui/media-element'; import type { PlayerContext } from '../player/context'; @@ -25,10 +25,6 @@ export function createContainerMixin(context: PlayerC return this.#consumer.value ?? null; } - protected createRenderRoot() { - return this; - } - override connectedCallback() { super.connectedCallback(); diff --git a/packages/html/src/store/provider-mixin.ts b/packages/html/src/store/provider-mixin.ts index e156ea71..658c9a7a 100644 --- a/packages/html/src/store/provider-mixin.ts +++ b/packages/html/src/store/provider-mixin.ts @@ -1,5 +1,5 @@ -import { ContextProvider } from '@lit/context'; import type { PlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; import { isNull } from '@videojs/utils/predicate'; import type { MediaElementConstructor } from '@/ui/media-element'; import type { PlayerContext } from '../player/context'; @@ -30,10 +30,6 @@ export function createProviderMixin( return this.#store; } - protected createRenderRoot() { - return this; - } - override connectedCallback() { super.connectedCallback(); this.#provider.setValue(this.store); diff --git a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts index 5c95dfd6..9c744a00 100644 --- a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts +++ b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts @@ -1,4 +1,3 @@ -import type { PropertyValues } from '@lit/reactive-element'; import { FullscreenButtonCore, FullscreenButtonDataAttrs } from '@videojs/core'; import { applyElementProps, @@ -7,6 +6,7 @@ import { logMissingFeature, selectFullscreen, } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -18,7 +18,7 @@ export class FullscreenButtonElement extends MediaElement { static override properties = { label: { type: String }, disabled: { type: Boolean }, - }; + } satisfies PropertyDeclarationMap; label = FullscreenButtonCore.defaultProps.label; disabled = FullscreenButtonCore.defaultProps.disabled; diff --git a/packages/html/src/ui/media-element.ts b/packages/html/src/ui/media-element.ts index 1e441028..e0c586a6 100644 --- a/packages/html/src/ui/media-element.ts +++ b/packages/html/src/ui/media-element.ts @@ -1,10 +1,6 @@ -import { ReactiveElement } from '@lit/reactive-element'; +import { ReactiveElement } from '@videojs/element'; import type { Constructor } from '@videojs/utils/types'; -export class MediaElement extends ReactiveElement { - protected createRenderRoot() { - return this; - } -} +export class MediaElement extends ReactiveElement {} export interface MediaElementConstructor extends Constructor {} diff --git a/packages/html/src/ui/mute-button/mute-button-element.ts b/packages/html/src/ui/mute-button/mute-button-element.ts index cf551b32..f1ef7226 100644 --- a/packages/html/src/ui/mute-button/mute-button-element.ts +++ b/packages/html/src/ui/mute-button/mute-button-element.ts @@ -1,4 +1,3 @@ -import type { PropertyValues } from '@lit/reactive-element'; import { MuteButtonCore, MuteButtonDataAttrs } from '@videojs/core'; import { applyElementProps, @@ -7,6 +6,7 @@ import { logMissingFeature, selectVolume, } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -18,7 +18,7 @@ export class MuteButtonElement extends MediaElement { static override properties = { label: { type: String }, disabled: { type: Boolean }, - }; + } satisfies PropertyDeclarationMap; label = MuteButtonCore.defaultProps.label; disabled = MuteButtonCore.defaultProps.disabled; diff --git a/packages/html/src/ui/play-button/play-button-element.ts b/packages/html/src/ui/play-button/play-button-element.ts index a4dff046..f418a110 100644 --- a/packages/html/src/ui/play-button/play-button-element.ts +++ b/packages/html/src/ui/play-button/play-button-element.ts @@ -1,4 +1,3 @@ -import type { PropertyValues } from '@lit/reactive-element'; import { PlayButtonCore, PlayButtonDataAttrs } from '@videojs/core'; import { applyElementProps, @@ -7,6 +6,7 @@ import { logMissingFeature, selectPlayback, } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -18,7 +18,7 @@ export class PlayButtonElement extends MediaElement { static override properties = { label: { type: String }, disabled: { type: Boolean }, - }; + } satisfies PropertyDeclarationMap; label = PlayButtonCore.defaultProps.label; disabled = PlayButtonCore.defaultProps.disabled; diff --git a/packages/html/src/ui/poster/poster-element.ts b/packages/html/src/ui/poster/poster-element.ts index 1c5b7291..9f614aa1 100644 --- a/packages/html/src/ui/poster/poster-element.ts +++ b/packages/html/src/ui/poster/poster-element.ts @@ -1,6 +1,6 @@ -import type { PropertyValues } from '@lit/reactive-element'; import { PosterCore, PosterDataAttrs } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectPlayback } from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts index e41c94b3..5dbb6e63 100644 --- a/packages/html/src/ui/time/time-element.ts +++ b/packages/html/src/ui/time/time-element.ts @@ -1,6 +1,6 @@ -import type { PropertyValues } from '@lit/reactive-element'; import { TimeCore, TimeDataAttrs, type TimeType } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectTime } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; @@ -13,7 +13,7 @@ export class TimeElement extends MediaElement { type: { type: String }, negativeSign: { type: String, attribute: 'negative-sign' }, label: { type: String }, - }; + } satisfies PropertyDeclarationMap; type: TimeType = TimeCore.defaultProps.type; negativeSign = TimeCore.defaultProps.negativeSign; diff --git a/packages/store/package.json b/packages/store/package.json index 8e31219d..de9a4741 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -17,10 +17,10 @@ "development": "./dist/dev/index.js", "default": "./dist/default/index.js" }, - "./lit": { - "types": "./dist/dev/lit.d.ts", - "development": "./dist/dev/lit.js", - "default": "./dist/default/lit.js" + "./html": { + "types": "./dist/dev/html.d.ts", + "development": "./dist/dev/html.js", + "default": "./dist/default/html.js" }, "./react": { "types": "./dist/dev/react.d.ts", @@ -37,15 +37,11 @@ "clean": "rm -rf dist types" }, "peerDependencies": { - "@lit/context": "^1.1.0", - "@lit/reactive-element": "^2.0.0", + "@videojs/element": "workspace:*", "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@lit/context": { - "optional": true - }, - "@lit/reactive-element": { + "@videojs/element": { "optional": true }, "react": { @@ -56,8 +52,7 @@ "@videojs/utils": "workspace:*" }, "devDependencies": { - "@lit/context": "^1.1.6", - "@lit/reactive-element": "^2.1.0", + "@videojs/element": "workspace:*", "@testing-library/react": "^16.3.0", "@types/react": "^19.2.7", "jsdom": "^26.1.0", diff --git a/packages/store/src/lit/controllers/index.ts b/packages/store/src/html/controllers/index.ts similarity index 100% rename from packages/store/src/lit/controllers/index.ts rename to packages/store/src/html/controllers/index.ts diff --git a/packages/store/src/lit/controllers/store-controller.ts b/packages/store/src/html/controllers/store-controller.ts similarity index 99% rename from packages/store/src/lit/controllers/store-controller.ts rename to packages/store/src/html/controllers/store-controller.ts index 6f217ee4..c76b4d7a 100644 --- a/packages/store/src/lit/controllers/store-controller.ts +++ b/packages/store/src/html/controllers/store-controller.ts @@ -1,4 +1,4 @@ -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; import { noop } from '@videojs/utils/function'; import { isNull, isUndefined } from '@videojs/utils/predicate'; import { shallowEqual } from '../../core/shallow-equal'; diff --git a/packages/store/src/lit/controllers/subscription-controller.ts b/packages/store/src/html/controllers/subscription-controller.ts similarity index 99% rename from packages/store/src/lit/controllers/subscription-controller.ts rename to packages/store/src/html/controllers/subscription-controller.ts index 5e280d98..468eacf1 100644 --- a/packages/store/src/lit/controllers/subscription-controller.ts +++ b/packages/store/src/html/controllers/subscription-controller.ts @@ -1,4 +1,4 @@ -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; import { noop } from '@videojs/utils/function'; import { isNull } from '@videojs/utils/predicate'; import type { AnyStore } from '../../core/store'; diff --git a/packages/store/src/lit/controllers/tests/store-controller.test.ts b/packages/store/src/html/controllers/tests/store-controller.test.ts similarity index 93% rename from packages/store/src/lit/controllers/tests/store-controller.test.ts rename to packages/store/src/html/controllers/tests/store-controller.test.ts index 7b420038..e1c51e0d 100644 --- a/packages/store/src/lit/controllers/tests/store-controller.test.ts +++ b/packages/store/src/html/controllers/tests/store-controller.test.ts @@ -27,9 +27,13 @@ describe('StoreController', () => { new StoreController(host, store); document.body.appendChild(host); + // Wait for initial update cycle to complete + await Promise.resolve(); + const initialCount = host.updateCount; + await store.setVolume(0.5); - expect(host.updateCount).toBe(0); + expect(host.updateCount).toBe(initialCount); }); it('returns selected state with selector', () => { diff --git a/packages/store/src/lit/globals.d.ts b/packages/store/src/html/globals.d.ts similarity index 100% rename from packages/store/src/lit/globals.d.ts rename to packages/store/src/html/globals.d.ts diff --git a/packages/store/src/lit/index.ts b/packages/store/src/html/index.ts similarity index 100% rename from packages/store/src/lit/index.ts rename to packages/store/src/html/index.ts diff --git a/packages/store/src/lit/store-accessor.ts b/packages/store/src/html/store-accessor.ts similarity index 94% rename from packages/store/src/lit/store-accessor.ts rename to packages/store/src/html/store-accessor.ts index b6bd5ef9..c79e60c7 100644 --- a/packages/store/src/lit/store-accessor.ts +++ b/packages/store/src/html/store-accessor.ts @@ -1,6 +1,6 @@ -import type { Context } from '@lit/context'; -import { ContextConsumer } from '@lit/context'; -import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; +import type { Context } from '@videojs/element/context'; +import { ContextConsumer } from '@videojs/element/context'; import { noop } from '@videojs/utils/function'; import type { AnyStore } from '../core/store'; import { isStore } from '../core/store'; diff --git a/packages/store/src/lit/tests/store-accessor.test.ts b/packages/store/src/html/tests/store-accessor.test.ts similarity index 100% rename from packages/store/src/lit/tests/store-accessor.test.ts rename to packages/store/src/html/tests/store-accessor.test.ts diff --git a/packages/store/src/lit/tests/test-utils.ts b/packages/store/src/html/tests/test-utils.ts similarity index 98% rename from packages/store/src/lit/tests/test-utils.ts rename to packages/store/src/html/tests/test-utils.ts index eb5c5cb0..422c093e 100644 --- a/packages/store/src/lit/tests/test-utils.ts +++ b/packages/store/src/html/tests/test-utils.ts @@ -1,4 +1,4 @@ -import { ReactiveElement } from '@lit/reactive-element'; +import { ReactiveElement } from '@videojs/element'; import { noop } from '@videojs/utils/function'; import { afterEach } from 'vitest'; import { defineSlice } from '../../core/slice'; diff --git a/packages/store/src/lit/tsconfig.json b/packages/store/src/html/tsconfig.json similarity index 60% rename from packages/store/src/lit/tsconfig.json rename to packages/store/src/html/tsconfig.json index 4ad456df..9813c370 100644 --- a/packages/store/src/lit/tsconfig.json +++ b/packages/store/src/html/tsconfig.json @@ -3,8 +3,8 @@ "compilerOptions": { "composite": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], - "declarationDir": "../../types/lit" + "declarationDir": "../../types/html" }, - "references": [{ "path": "../.." }], + "references": [{ "path": "../.." }, { "path": "../../../element" }], "include": ["./**/*.ts"] } diff --git a/packages/store/tsdown.config.ts b/packages/store/tsdown.config.ts index 178e7155..542162f2 100644 --- a/packages/store/tsdown.config.ts +++ b/packages/store/tsdown.config.ts @@ -8,7 +8,7 @@ const buildModes: BuildMode[] = ['dev', 'default']; const createConfig = (mode: BuildMode): UserConfig => ({ entry: { index: './src/core/index.ts', - lit: './src/lit/index.ts', + html: './src/html/index.ts', react: './src/react/index.ts', }, platform: 'neutral', diff --git a/packages/store/vitest.config.ts b/packages/store/vitest.config.ts index c267f6d8..71c4f38c 100644 --- a/packages/store/vitest.config.ts +++ b/packages/store/vitest.config.ts @@ -17,8 +17,8 @@ export default defineConfig({ { extends: true, test: { - name: 'store/lit', - include: ['src/lit/**/*.test.ts'], + name: 'store/html', + include: ['src/html/**/*.test.ts'], environment: 'jsdom', }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96282fd1..55f36b26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,17 +190,33 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - packages/html: + packages/element: dependencies: '@lit/context': specifier: ^1.1.0 version: 1.1.6 - '@lit/reactive-element': - specifier: ^2.1.2 - version: 2.1.2 + devDependencies: + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 + tsdown: + specifier: ^0.20.3 + version: 0.20.3(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + + packages/html: + dependencies: '@videojs/core': specifier: workspace:* version: link:../core + '@videojs/element': + specifier: workspace:* + version: link:../element '@videojs/store': specifier: workspace:* version: link:../store @@ -325,18 +341,15 @@ importers: specifier: workspace:* version: link:../utils devDependencies: - '@lit/context': - specifier: ^1.1.6 - version: 1.1.6 - '@lit/reactive-element': - specifier: ^2.1.0 - version: 2.1.2 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: ^19.2.7 version: 19.2.7 + '@videojs/element': + specifier: workspace:* + version: link:../element jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -9550,7 +9563,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -9664,13 +9677,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.2.7(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.7(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -9701,7 +9714,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -13877,7 +13890,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.7(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -13895,7 +13908,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.2.7(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vite-node: 3.2.4(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: @@ -13922,7 +13935,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.7(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -13940,7 +13953,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.2.7(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vite-node: 3.2.4(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/rfc/player-api/architecture.md b/rfc/player-api/architecture.md index ea16830c..dac529aa 100644 --- a/rfc/player-api/architecture.md +++ b/rfc/player-api/architecture.md @@ -156,7 +156,7 @@ const playback = usePlayer(selectPlayback); | ------------------------------------- | ----------------------------------- | | `packages/store/src/core/` | Core store, slices, selectors | | `packages/store/src/react/` | React hooks (`useStore`) | -| `packages/store/src/lit/` | Lit controllers (`StoreController`) | +| `packages/store/src/html/` | HTML controllers (`StoreController`) | | `packages/core/src/dom/` | PlayerTarget, features, selectors | | `packages/html/src/` | HTML player, mixins, elements | | `packages/react/src/` | React player, context, hooks | @@ -179,4 +179,4 @@ Store internals are implementation details until you author slices. - Slices live in `@videojs/core/dom` - `createPlayer` lives in `@videojs/html` and `@videojs/react` - Single store, selectors for typed access -- `shallowEqual` comparison in React hooks and Lit controllers +- `shallowEqual` comparison in React hooks and HTML controllers diff --git a/tsconfig.json b/tsconfig.json index 19606760..43844387 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,10 @@ { "path": "packages/utils" }, { "path": "packages/utils/src/dom" }, + { "path": "packages/element" }, + { "path": "packages/store" }, - { "path": "packages/store/src/lit" }, + { "path": "packages/store/src/html" }, { "path": "packages/store/src/react" }, { "path": "packages/core" },