diff --git a/.changeset/wise-tigers-happen.md b/.changeset/wise-tigers-happen.md new file mode 100644 index 0000000000..53cc671859 --- /dev/null +++ b/.changeset/wise-tigers-happen.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: attachments `fromAction` utility diff --git a/documentation/docs/03-template-syntax/09-@attach.md b/documentation/docs/03-template-syntax/09-@attach.md index 3644cbc8e8..b25fbb32a6 100644 --- a/documentation/docs/03-template-syntax/09-@attach.md +++ b/documentation/docs/03-template-syntax/09-@attach.md @@ -160,3 +160,7 @@ function foo(+++getBar+++) { ## Creating attachments programmatically To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey). + +## Converting actions to attachments + +If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](svelte-attachments#fromAction), allowing you to (for example) use them with components. diff --git a/packages/svelte/src/attachments/index.js b/packages/svelte/src/attachments/index.js index 948a19f4dd..dc2f3e93d7 100644 --- a/packages/svelte/src/attachments/index.js +++ b/packages/svelte/src/attachments/index.js @@ -1,4 +1,9 @@ +/** @import { Action, ActionReturn } from '../action/public' */ +/** @import { Attachment } from './public' */ +import { noop, render_effect } from 'svelte/internal/client'; import { ATTACHMENT_KEY } from '../constants.js'; +import { untrack } from 'svelte'; +import { teardown } from '../internal/client/reactivity/effects.js'; /** * Creates an object key that will be recognised as an attachment when the object is spread onto an element, @@ -25,3 +30,84 @@ import { ATTACHMENT_KEY } from '../constants.js'; export function createAttachmentKey() { return Symbol(ATTACHMENT_KEY); } + +/** + * Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior. + * It's useful if you want to start using attachments on components but you have actions provided by a library. + * + * Note that the second argument, if provided, must be a function that _returns_ the argument to the + * action function, not the argument itself. + * + * ```svelte + * + *
...
+ * + * + *
bar)}>...
+ * ``` + * @template {EventTarget} E + * @template {unknown} T + * @overload + * @param {Action | ((element: E, arg: T) => void | ActionReturn)} action The action function + * @param {() => T} fn A function that returns the argument for the action + * @returns {Attachment} + */ +/** + * Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior. + * It's useful if you want to start using attachments on components but you have actions provided by a library. + * + * Note that the second argument, if provided, must be a function that _returns_ the argument to the + * action function, not the argument itself. + * + * ```svelte + * + *
...
+ * + * + *
bar)}>...
+ * ``` + * @template {EventTarget} E + * @overload + * @param {Action | ((element: E) => void | ActionReturn)} action The action function + * @returns {Attachment} + */ +/** + * Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior. + * It's useful if you want to start using attachments on components but you have actions provided by a library. + * + * Note that the second argument, if provided, must be a function that _returns_ the argument to the + * action function, not the argument itself. + * + * ```svelte + * + *
...
+ * + * + *
bar)}>...
+ * ``` + * + * @template {EventTarget} E + * @template {unknown} T + * @param {Action | ((element: E, arg: T) => void | ActionReturn)} action The action function + * @param {() => T} fn A function that returns the argument for the action + * @returns {Attachment} + * @since 5.32 + */ +export function fromAction(action, fn = /** @type {() => T} */ (noop)) { + return (element) => { + const { update, destroy } = untrack(() => action(element, fn()) ?? {}); + + if (update) { + var ran = false; + render_effect(() => { + const arg = fn(); + if (ran) update(arg); + }); + ran = true; + } + + if (destroy) { + teardown(destroy); + } + }; +} diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-from-action/_config.js b/packages/svelte/tests/runtime-runes/samples/attachment-from-action/_config.js new file mode 100644 index 0000000000..2e53f0d29d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-from-action/_config.js @@ -0,0 +1,116 @@ +import { ok, test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3] = target.querySelectorAll('button'); + + // both logs on creation it will not log on change + assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment']); + + // clicking the first button logs the right value + flushSync(() => { + btn?.click(); + }); + assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment', 0]); + + // clicking the second button logs the right value + flushSync(() => { + btn2?.click(); + }); + assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment', 0, 0]); + + // updating the arguments logs the update function for both + flushSync(() => { + btn3?.click(); + }); + assert.deepEqual(logs, [ + 'create', + 0, + 'action', + 'create', + 0, + 'attachment', + 0, + 0, + 'update', + 1, + 'action', + 'update', + 1, + 'attachment' + ]); + + // clicking the first button again shows the right value + flushSync(() => { + btn?.click(); + }); + assert.deepEqual(logs, [ + 'create', + 0, + 'action', + 'create', + 0, + 'attachment', + 0, + 0, + 'update', + 1, + 'action', + 'update', + 1, + 'attachment', + 1 + ]); + + // clicking the second button again shows the right value + flushSync(() => { + btn2?.click(); + }); + assert.deepEqual(logs, [ + 'create', + 0, + 'action', + 'create', + 0, + 'attachment', + 0, + 0, + 'update', + 1, + 'action', + 'update', + 1, + 'attachment', + 1, + 1 + ]); + + // unmounting logs the destroy function for both + flushSync(() => { + btn3?.click(); + }); + assert.deepEqual(logs, [ + 'create', + 0, + 'action', + 'create', + 0, + 'attachment', + 0, + 0, + 'update', + 1, + 'action', + 'update', + 1, + 'attachment', + 1, + 1, + 'destroy', + 'action', + 'destroy', + 'attachment' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/attachment-from-action/main.svelte b/packages/svelte/tests/runtime-runes/samples/attachment-from-action/main.svelte new file mode 100644 index 0000000000..35079aa15e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/attachment-from-action/main.svelte @@ -0,0 +1,37 @@ + + +{#if count < 2} + + +{/if} + + \ No newline at end of file diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 1fda9a36b8..fcc1ec315c 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -658,6 +658,107 @@ declare module 'svelte/attachments' { * @since 5.29 */ export function createAttachmentKey(): symbol; + /** + * Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior. + * It's useful if you want to start using attachments on components but you have actions provided by a library. + * + * Note that the second argument, if provided, must be a function that _returns_ the argument to the + * action function, not the argument itself. + * + * ```svelte + * + *
...
+ * + * + *
bar)}>...
+ * ``` + * */ + export function fromAction(action: Action | ((element: E, arg: T) => void | ActionReturn), fn: () => T): Attachment; + /** + * Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior. + * It's useful if you want to start using attachments on components but you have actions provided by a library. + * + * Note that the second argument, if provided, must be a function that _returns_ the argument to the + * action function, not the argument itself. + * + * ```svelte + * + *
...
+ * + * + *
bar)}>...
+ * ``` + * */ + export function fromAction(action: Action | ((element: E) => void | ActionReturn)): Attachment; + /** + * Actions can return an object containing the two properties defined in this interface. Both are optional. + * - update: An action can have a parameter. This method will be called whenever that parameter changes, + * immediately after Svelte has applied updates to the markup. `ActionReturn` and `ActionReturn` both + * mean that the action accepts no parameters. + * - destroy: Method that is called after the element is unmounted + * + * Additionally, you can specify which additional attributes and events the action enables on the applied element. + * This applies to TypeScript typings only and has no effect at runtime. + * + * Example usage: + * ```ts + * interface Attributes { + * newprop?: string; + * 'on:event': (e: CustomEvent) => void; + * } + * + * export function myAction(node: HTMLElement, parameter: Parameter): ActionReturn { + * // ... + * return { + * update: (updatedParameter) => {...}, + * destroy: () => {...} + * }; + * } + * ``` + */ + interface ActionReturn< + Parameter = undefined, + Attributes extends Record = Record + > { + update?: (parameter: Parameter) => void; + destroy?: () => void; + /** + * ### DO NOT USE THIS + * This exists solely for type-checking and has no effect at runtime. + * Set this through the `Attributes` generic instead. + */ + $$_attributes?: Attributes; + } + + /** + * Actions are functions that are called when an element is created. + * You can use this interface to type such actions. + * The following example defines an action that only works on `
` elements + * and optionally accepts a parameter which it has a default value for: + * ```ts + * export const myAction: Action = (node, param = { someProperty: true }) => { + * // ... + * } + * ``` + * `Action` and `Action` both signal that the action accepts no parameters. + * + * You can return an object with methods `update` and `destroy` from the function and type which additional attributes and events it has. + * See interface `ActionReturn` for more details. + */ + interface Action< + Element = HTMLElement, + Parameter = undefined, + Attributes extends Record = Record + > { + ( + ...args: undefined extends Parameter + ? [node: Node, parameter?: Parameter] + : [node: Node, parameter: Parameter] + ): void | ActionReturn; + } + + // Implementation notes: + // - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode export {}; }