diff --git a/.changeset/lovely-bugs-sneeze.md b/.changeset/lovely-bugs-sneeze.md new file mode 100644 index 0000000000..287089f047 --- /dev/null +++ b/.changeset/lovely-bugs-sneeze.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: bind `activeElement` and `pointerLockElement` in `` diff --git a/documentation/docs/02-template-syntax/07-special-elements.md b/documentation/docs/02-template-syntax/07-special-elements.md index 74a42d486e..20d7bcd7ea 100644 --- a/documentation/docs/02-template-syntax/07-special-elements.md +++ b/documentation/docs/02-template-syntax/07-special-elements.md @@ -268,7 +268,9 @@ As with ``, this element may only appear the top level of your co You can also bind to the following properties: +- `activeElement` - `fullscreenElement` +- `pointerLockElement` - `visibilityState` All are readonly. diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 0a6bcd4e78..191308bbf2 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -1364,7 +1364,9 @@ export interface SvelteMediaTimeRange { } export interface SvelteDocumentAttributes extends HTMLAttributes { + readonly 'bind:activeElement'?: Document['activeElement'] | undefined | null; readonly 'bind:fullscreenElement'?: Document['fullscreenElement'] | undefined | null; + readonly 'bind:pointerLockElement'?: Document['pointerLockElement'] | undefined | null; readonly 'bind:visibilityState'?: Document['visibilityState'] | undefined | null; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index 5784b77262..d53d7201cf 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -2773,6 +2773,11 @@ export const template_visitors = { call_expr = b.call('$.bind_window_size', b.literal(node.name), setter); break; + // document + case 'activeElement': + call_expr = b.call('$.bind_active_element', setter); + break; + // media case 'muted': call_expr = b.call(`$.bind_muted`, state.node, getter, setter); diff --git a/packages/svelte/src/compiler/phases/bindings.js b/packages/svelte/src/compiler/phases/bindings.js index 6ffbb2363d..ad998b3abf 100644 --- a/packages/svelte/src/compiler/phases/bindings.js +++ b/packages/svelte/src/compiler/phases/bindings.js @@ -86,11 +86,20 @@ export const binding_properties = { omit_in_ssr: true }, // document + activeElement: { + valid_elements: ['svelte:document'], + omit_in_ssr: true + }, fullscreenElement: { valid_elements: ['svelte:document'], event: 'fullscreenchange', omit_in_ssr: true }, + pointerLockElement: { + valid_elements: ['svelte:document'], + event: 'pointerlockchange', + omit_in_ssr: true + }, visibilityState: { valid_elements: ['svelte:document'], event: 'visibilitychange', diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/document.js b/packages/svelte/src/internal/client/dom/elements/bindings/document.js new file mode 100644 index 0000000000..420e4359ed --- /dev/null +++ b/packages/svelte/src/internal/client/dom/elements/bindings/document.js @@ -0,0 +1,17 @@ +import { listen } from './shared.js'; + +/** + * @param {(activeElement: Element | null) => void} update + * @returns {void} + */ +export function bind_active_element(update) { + listen(document, ['focusin', 'focusout'], (event) => { + if (event && event.type === 'focusout' && /** @type {FocusEvent} */ (event).relatedTarget) { + // The tests still pass if we remove this, because of JSDOM limitations, but it is necessary + // to avoid temporarily resetting to `document.body` + return; + } + + update(document.activeElement); + }); +} diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/shared.js b/packages/svelte/src/internal/client/dom/elements/bindings/shared.js index 505bdfbd03..fd8d6ae7f4 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/shared.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/shared.js @@ -4,9 +4,9 @@ import { add_form_reset_listener } from '../misc.js'; /** * Fires the handler once immediately (unless corresponding arg is set to `false`), * then listens to the given events until the render effect context is destroyed - * @param {Element | Window} target + * @param {EventTarget} target * @param {Array} events - * @param {() => void} handler + * @param {(event?: Event) => void} handler * @param {any} call_handler_immediately */ export function listen(target, events, handler, call_handler_immediately = true) { diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 73a36d4e9e..65983da3e2 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -36,6 +36,7 @@ export { event, delegate, replay_events } from './dom/elements/events.js'; export { autofocus, remove_textarea_child } from './dom/elements/misc.js'; export { set_style } from './dom/elements/style.js'; export { animation, transition } from './dom/elements/transitions.js'; +export { bind_active_element } from './dom/elements/bindings/document.js'; export { bind_checked, bind_files, bind_group, bind_value } from './dom/elements/bindings/input.js'; export { bind_buffered, diff --git a/packages/svelte/tests/runtime-legacy/samples/document-binding-active/_config.js b/packages/svelte/tests/runtime-legacy/samples/document-binding-active/_config.js new file mode 100644 index 0000000000..a8b85bf578 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/document-binding-active/_config.js @@ -0,0 +1,16 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// This test is slightly inaccurate, because blurring elements (or focusing the `` directly) +// doesn't trigger the relevant `focusin` event in JSDOM. +export default test({ + test({ assert, target, logs }) { + const [btn1, btn2] = target.querySelectorAll('button'); + + flushSync(() => btn1.focus()); + assert.deepEqual(logs, ['...', 'BODY', 'one']); + + flushSync(() => btn2.focus()); + assert.deepEqual(logs, ['...', 'BODY', 'one', 'two']); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/document-binding-active/main.svelte b/packages/svelte/tests/runtime-legacy/samples/document-binding-active/main.svelte new file mode 100644 index 0000000000..17768fa95e --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/document-binding-active/main.svelte @@ -0,0 +1,10 @@ + + + + + + diff --git a/packages/svelte/tests/validator/samples/document-binding-invalid-dimensions/errors.json b/packages/svelte/tests/validator/samples/document-binding-invalid-dimensions/errors.json index 57b4aea046..bb33a04c89 100644 --- a/packages/svelte/tests/validator/samples/document-binding-invalid-dimensions/errors.json +++ b/packages/svelte/tests/validator/samples/document-binding-invalid-dimensions/errors.json @@ -1,7 +1,7 @@ [ { "code": "bind_invalid_name", - "message": "`bind:clientWidth` is not a valid binding. Possible bindings for are focused, fullscreenElement, visibilityState, this", + "message": "`bind:clientWidth` is not a valid binding. Possible bindings for are focused, activeElement, fullscreenElement, pointerLockElement, visibilityState, this", "start": { "line": 5, "column": 17