diff --git a/.changeset/tidy-elements-act.md b/.changeset/tidy-elements-act.md new file mode 100644 index 00000000..cfcc2da9 --- /dev/null +++ b/.changeset/tidy-elements-act.md @@ -0,0 +1,6 @@ +--- +'@remote-dom/core': minor +'@remote-dom/polyfill': minor +--- + +Forward polyfilled `Element.focus()` and `Element.scrollIntoView()` calls to their host implementations. diff --git a/packages/core/source/polyfill/hooks.ts b/packages/core/source/polyfill/hooks.ts index da4477df..5d9a444f 100644 --- a/packages/core/source/polyfill/hooks.ts +++ b/packages/core/source/polyfill/hooks.ts @@ -72,4 +72,11 @@ hooks.removeAttribute = (element, name) => { updateRemoteElementAttribute(element, name); }; +hooks.callMethod = (element, method, ...args) => { + const connection = remoteConnection(element); + if (connection == null) return; + + connection.call(remoteId(element), method, ...args); +}; + export {hooks, type Hooks}; diff --git a/packages/core/source/tests/imperative-methods.test.ts b/packages/core/source/tests/imperative-methods.test.ts new file mode 100644 index 00000000..db8ef2b4 --- /dev/null +++ b/packages/core/source/tests/imperative-methods.test.ts @@ -0,0 +1,41 @@ +import '../polyfill/polyfill.ts'; + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {RemoteRootElement} from '../elements/RemoteRootElement.ts'; +import {RemoteReceiver} from '../receivers/RemoteReceiver.ts'; + +customElements.define('imperative-method-root', RemoteRootElement); + +describe('polyfilled imperative element methods', () => { + let receiver: RemoteReceiver; + let root: RemoteRootElement; + + beforeEach(() => { + receiver = new RemoteReceiver(); + root = document.createElement( + 'imperative-method-root', + ) as RemoteRootElement; + root.connect(receiver.connection); + }); + + it('calls focus() and scrollIntoView() on the host implementation', () => { + const element = document.createElement('div'); + root.appendChild(element); + + const received = receiver.root.children[0]!; + const implementation = { + focus: vi.fn(), + scrollIntoView: vi.fn(), + }; + receiver.implement(received, implementation); + + const focusOptions = {preventScroll: true}; + const scrollOptions = {behavior: 'smooth'} as const; + element.focus(focusOptions); + element.scrollIntoView(scrollOptions); + + expect(implementation.focus).toHaveBeenCalledWith(focusOptions); + expect(implementation.scrollIntoView).toHaveBeenCalledWith(scrollOptions); + }); +}); diff --git a/packages/polyfill/source/Element.ts b/packages/polyfill/source/Element.ts index d4537cab..c1381a3b 100644 --- a/packages/polyfill/source/Element.ts +++ b/packages/polyfill/source/Element.ts @@ -1,6 +1,7 @@ import { NS, ATTRIBUTES, + HOOKS, HTML_NAMESPACE, NODE_TYPE_ELEMENT, type NamespaceURI, @@ -87,6 +88,14 @@ export class Element extends ParentNode { return sib; } + focus(...args: [options?: FocusOptions]) { + this[HOOKS].callMethod?.(this as any, 'focus', ...args); + } + + scrollIntoView(...args: [arg?: boolean | ScrollIntoViewOptions]) { + this[HOOKS].callMethod?.(this as any, 'scrollIntoView', ...args); + } + setAttribute(name: string, value: string) { this.attributes.setNamedItem(new Attr(name, String(value))); } diff --git a/packages/polyfill/source/hooks.ts b/packages/polyfill/source/hooks.ts index 3f3ad888..a58bd6ea 100644 --- a/packages/polyfill/source/hooks.ts +++ b/packages/polyfill/source/hooks.ts @@ -23,4 +23,5 @@ export interface Hooks { listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions, ): void; + callMethod(element: Element, method: string, ...args: unknown[]): unknown; } diff --git a/packages/polyfill/source/tests/ElementMethods.test.ts b/packages/polyfill/source/tests/ElementMethods.test.ts new file mode 100644 index 00000000..9bc462bd --- /dev/null +++ b/packages/polyfill/source/tests/ElementMethods.test.ts @@ -0,0 +1,59 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {HOOKS} from '../constants.ts'; +import {Window} from '../Window.ts'; + +describe('imperative element methods', () => { + let window: Window; + const callMethod = vi.fn(); + + beforeEach(() => { + window = new Window(); + window[HOOKS] = {callMethod}; + callMethod.mockClear(); + }); + + it('forwards focus() through the window hook', () => { + const element = window.document.createElement('div'); + const options = {preventScroll: true}; + + element.focus(); + element.focus(options); + + expect(callMethod).toHaveBeenNthCalledWith(1, element, 'focus'); + expect(callMethod).toHaveBeenNthCalledWith(2, element, 'focus', options); + }); + + it('forwards scrollIntoView() through the window hook', () => { + const element = window.document.createElement('div'); + const options = {behavior: 'smooth'} as const; + + element.scrollIntoView(); + element.scrollIntoView(false); + element.scrollIntoView(options); + + expect(callMethod).toHaveBeenNthCalledWith(1, element, 'scrollIntoView'); + expect(callMethod).toHaveBeenNthCalledWith( + 2, + element, + 'scrollIntoView', + false, + ); + expect(callMethod).toHaveBeenNthCalledWith( + 3, + element, + 'scrollIntoView', + options, + ); + }); + + it('is a no-op when no host method hook is installed', () => { + const standaloneWindow = new Window(); + const element = standaloneWindow.document.createElement('div'); + + expect(() => { + element.focus(); + element.scrollIntoView(); + }).not.toThrow(); + }); +});