diff --git a/assets/js/phoenix_live_view/index.ts b/assets/js/phoenix_live_view/index.ts index e6f5a940ab..68d26b5e28 100644 --- a/assets/js/phoenix_live_view/index.ts +++ b/assets/js/phoenix_live_view/index.ts @@ -350,4 +350,59 @@ function createHook(el: HTMLElement, callbacks: Hook): ViewHook { return hook; } -export { LiveSocket, isUsedInput, createHook, ViewHook, Hook, HooksOptions }; +type HookCallbacks = Pick< + Hook, + | "mounted" + | "beforeUpdate" + | "updated" + | "destroyed" + | "disconnected" + | "reconnected" +>; + +// Interface for elements with connectedCallback (web components) +interface CustomElementLike extends HTMLElement { + connectedCallback?(): void; +} + +function HookedWebComponent< + T extends new (...args: any[]) => CustomElementLike, +>(Base: T) { + return class extends Base implements HookCallbacks { + hook!: ViewHook; + + // Lifecycle callbacks - override in subclass + mounted?(): void; + beforeUpdate?(): void; + updated?(): void; + destroyed?(): void; + disconnected?(): void; + reconnected?(): void; + + connectedCallback() { + super.connectedCallback?.(); + // Only include callbacks that are actually defined, so ViewHook's + // default implementations aren't overwritten with undefined + const callbacks: Hook = {}; + if (this.mounted) callbacks.mounted = this.mounted.bind(this); + if (this.beforeUpdate) + callbacks.beforeUpdate = this.beforeUpdate.bind(this); + if (this.updated) callbacks.updated = this.updated.bind(this); + if (this.destroyed) callbacks.destroyed = this.destroyed.bind(this); + if (this.disconnected) + callbacks.disconnected = this.disconnected.bind(this); + if (this.reconnected) callbacks.reconnected = this.reconnected.bind(this); + this.hook = createHook(this, callbacks); + } + }; +} + +export { + LiveSocket, + isUsedInput, + createHook, + HookedWebComponent, + ViewHook, + Hook, + HooksOptions, +}; diff --git a/assets/test/hook_types_test.ts b/assets/test/hook_types_test.ts index a41e03f5c6..47ff33351c 100644 --- a/assets/test/hook_types_test.ts +++ b/assets/test/hook_types_test.ts @@ -11,6 +11,7 @@ */ import type { Hook, HooksOptions } from "phoenix_live_view/view_hook"; +import { HookedWebComponent } from "phoenix_live_view"; // Hook with custom state interface CounterState { @@ -121,6 +122,45 @@ const InfiniteScroll: Hook = { export { InfiniteScroll }; +// ============================================================================= +// Test for HookedWebComponent mixin +// Verifies that web components can use hook functionality with proper typing +// ============================================================================= + +class MyCounter extends HookedWebComponent(HTMLElement) { + count = 0; + + mounted() { + // this.hook should be typed as ViewHook with all its methods + this.hook.handleEvent("set_count", (payload: { count: number }) => { + this.count = payload.count; + }); + + // pushEvent should be available + this.hook.pushEvent("ready", { id: this.id }); + + // pushEventTo should be available + this.hook.pushEventTo("#target", "init", {}); + + // js() should be available + const js = this.hook.js(); + js.show(this); + + // liveSocket should be accessible + console.log(this.hook.liveSocket); + } + + updated() { + // updated callback works + console.log("updated", this.count); + } +} + +// Verify the class can be used as a custom element +customElements.define("my-counter", MyCounter); + +export { MyCounter }; + // This file is primarily for compile-time type checking via `npm run typecheck:tests`. // The dummy test below satisfies Jest's requirement for at least one test. test("hook types compile correctly", () => {