Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wise-tigers-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: attachments `fromAction` utility
23 changes: 23 additions & 0 deletions documentation/docs/03-template-syntax/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ This allows you to create _wrapper components_ that augment elements ([demo](/pl
</Button>
```

### Converting actions to attachments

If you want to use this functionality on Components but you are using a library that only provides actions you can use the `fromAction` utility exported from `svelte/attachments` to convert between the two.

This function accept an action as the first argument and a function returning the arguments of the action as the second argument and returns an attachment.

```svelte
<script>
import Button from "./Button.svelte";
import { log } from "log-my-number";
import { fromAction } from "svelte/attachments";

let count = $state(0);
</script>

<Button
onclick={() => count++}
{@attach fromAction(log, () => count)}
>
{count}
</Button>
```

## Creating attachments programmatically

To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
29 changes: 29 additions & 0 deletions packages/svelte/src/attachments/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/** @import { FromAction } from './public.js' */
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,
Expand All @@ -25,3 +29,28 @@ import { ATTACHMENT_KEY } from '../constants.js';
export function createAttachmentKey() {
return Symbol(ATTACHMENT_KEY);
}

/**
* Converts an Action into an Attachment keeping the same behavior. It's useful if you want to start using
* attachments on Components but you have library provided actions.
* @type {FromAction}
* @since 5.32
*/
export function fromAction(action, /** @type {() => any} */ get_arg = noop) {
return (element) => {
const { update, destroy } = untrack(() => action(element, get_arg()) ?? {});

if (update) {
var ran = false;
render_effect(() => {
const arg = get_arg();
if (ran) update(arg);
});
ran = true;
}

if (destroy) {
teardown(destroy);
}
};
}
16 changes: 16 additions & 0 deletions packages/svelte/src/attachments/public.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ActionReturn } from 'svelte/action';

/**
* An [attachment](https://svelte.dev/docs/svelte/@attach) is a function that runs when an element is mounted
* to the DOM, and optionally returns a function that is called when the element is later removed.
Expand All @@ -9,4 +11,18 @@ export interface Attachment<T extends EventTarget = Element> {
(element: T): void | (() => void);
}

export interface FromAction<Element extends EventTarget = HTMLElement, Par = unknown> {
<Node extends Element, Parameter extends Par>(
...args: undefined extends NoInfer<Parameter>
? [
action: (node: Node, parameter?: never) => void | ActionReturn<Parameter>,
parameter?: () => NoInfer<Parameter>
]
: [
action: (node: Node, parameter: Parameter) => void | ActionReturn<Parameter>,
parameter: () => NoInfer<Parameter>
]
): Attachment<Node>;
}

export * from './index.js';
Original file line number Diff line number Diff line change
@@ -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'
]);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script>
import { fromAction } from 'svelte/attachments';
let { count = 0 } = $props();

function test(node, thing) {
const kind = node.dataset.kind;
console.log('create', thing, kind);
let t = thing;
const controller = new AbortController();
node.addEventListener(
'click',
() => {
console.log(t);
},
{
signal: controller.signal
}
);
return {
update(new_thing) {
console.log('update', new_thing, kind);
t = new_thing;
},
destroy() {
console.log('destroy', kind);
controller.abort();
}
};
}
</script>

{#if count < 2}
<button data-kind="action" use:test={count}></button>
<button data-kind="attachment" {@attach fromAction(test, ()=>count)}></button>
{/if}

<button onclick={()=> count++}></button>
58 changes: 58 additions & 0 deletions packages/svelte/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ declare module 'svelte/animate' {
}

declare module 'svelte/attachments' {
import type { ActionReturn } from 'svelte/action';
/**
* An [attachment](https://svelte.dev/docs/svelte/@attach) is a function that runs when an element is mounted
* to the DOM, and optionally returns a function that is called when the element is later removed.
Expand All @@ -635,6 +636,20 @@ declare module 'svelte/attachments' {
export interface Attachment<T extends EventTarget = Element> {
(element: T): void | (() => void);
}

export interface FromAction<Element extends EventTarget = HTMLElement, Par = unknown> {
<Node extends Element, Parameter extends Par>(
...args: undefined extends NoInfer<Parameter>
? [
action: (node: Node, parameter?: never) => void | ActionReturn<Parameter>,
parameter?: () => NoInfer<Parameter>
]
: [
action: (node: Node, parameter: Parameter) => void | ActionReturn<Parameter>,
parameter: () => NoInfer<Parameter>
]
): Attachment<Node>;
}
/**
* Creates an object key that will be recognised as an attachment when the object is spread onto an element,
* as a programmatic alternative to using `{@attach ...}`. This can be useful for library authors, though
Expand All @@ -658,6 +673,49 @@ declare module 'svelte/attachments' {
* @since 5.29
*/
export function createAttachmentKey(): symbol;
export function fromAction<Node extends HTMLElement, Parameter extends any>(...args: undefined extends NoInfer<Parameter> ? [action: (node: Node, parameter?: never) => void | ActionReturn_1<Parameter, Record<never, any>>, parameter?: (() => NoInfer<Parameter>) | undefined] : [action: (node: Node, parameter: Parameter) => void | ActionReturn_1<Parameter, Record<never, any>>, parameter: () => NoInfer<Parameter>]): Attachment<Node>;
/**
* 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<undefined>` 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<boolean>) => void;
* }
*
* export function myAction(node: HTMLElement, parameter: Parameter): ActionReturn<Parameter, Attributes> {
* // ...
* return {
* update: (updatedParameter) => {...},
* destroy: () => {...}
* };
* }
* ```
*/
interface ActionReturn_1<
Parameter = undefined,
Attributes extends Record<string, any> = Record<never, any>
> {
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;
}

// Implementation notes:
// - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode

export {};
}
Expand Down