Skip to content

Commit 7183886

Browse files
paoloricciutiieedanRich-Harris
authored
feat: attachments fromAction utility (#15933)
* feat: attachments `fromAction` utility * fix: typing of the utility Co-authored-by: Aidan Bleser <117548273+ieedan@users.noreply.github.com> * simplify implementation - create action first, then only create update/destroy effects if necessary * add since * regenerate * remove FromAction interface * overload * fix: use typedef instead of exported interface * get rid of the arg0, arg1 stuff * oops * god i hate overloads * defer to the reference documentation * damn ur weird typescript * gah --------- Co-authored-by: Aidan Bleser <117548273+ieedan@users.noreply.github.com> Co-authored-by: Rich Harris <rich.harris@vercel.com>
1 parent 22a0211 commit 7183886

File tree

6 files changed

+349
-0
lines changed

6 files changed

+349
-0
lines changed

.changeset/wise-tigers-happen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: attachments `fromAction` utility

documentation/docs/03-template-syntax/09-@attach.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,7 @@ function foo(+++getBar+++) {
160160
## Creating attachments programmatically
161161

162162
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
163+
164+
## Converting actions to attachments
165+
166+
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.

packages/svelte/src/attachments/index.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
/** @import { Action, ActionReturn } from '../action/public' */
2+
/** @import { Attachment } from './public' */
3+
import { noop, render_effect } from 'svelte/internal/client';
14
import { ATTACHMENT_KEY } from '../constants.js';
5+
import { untrack } from 'svelte';
6+
import { teardown } from '../internal/client/reactivity/effects.js';
27

38
/**
49
* 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';
2530
export function createAttachmentKey() {
2631
return Symbol(ATTACHMENT_KEY);
2732
}
33+
34+
/**
35+
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
36+
* It's useful if you want to start using attachments on components but you have actions provided by a library.
37+
*
38+
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
39+
* action function, not the argument itself.
40+
*
41+
* ```svelte
42+
* <!-- with an action -->
43+
* <div use:foo={bar}>...</div>
44+
*
45+
* <!-- with an attachment -->
46+
* <div {@attach fromAction(foo, () => bar)}>...</div>
47+
* ```
48+
* @template {EventTarget} E
49+
* @template {unknown} T
50+
* @overload
51+
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
52+
* @param {() => T} fn A function that returns the argument for the action
53+
* @returns {Attachment<E>}
54+
*/
55+
/**
56+
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
57+
* It's useful if you want to start using attachments on components but you have actions provided by a library.
58+
*
59+
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
60+
* action function, not the argument itself.
61+
*
62+
* ```svelte
63+
* <!-- with an action -->
64+
* <div use:foo={bar}>...</div>
65+
*
66+
* <!-- with an attachment -->
67+
* <div {@attach fromAction(foo, () => bar)}>...</div>
68+
* ```
69+
* @template {EventTarget} E
70+
* @overload
71+
* @param {Action<E, void> | ((element: E) => void | ActionReturn<void>)} action The action function
72+
* @returns {Attachment<E>}
73+
*/
74+
/**
75+
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
76+
* It's useful if you want to start using attachments on components but you have actions provided by a library.
77+
*
78+
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
79+
* action function, not the argument itself.
80+
*
81+
* ```svelte
82+
* <!-- with an action -->
83+
* <div use:foo={bar}>...</div>
84+
*
85+
* <!-- with an attachment -->
86+
* <div {@attach fromAction(foo, () => bar)}>...</div>
87+
* ```
88+
*
89+
* @template {EventTarget} E
90+
* @template {unknown} T
91+
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
92+
* @param {() => T} fn A function that returns the argument for the action
93+
* @returns {Attachment<E>}
94+
* @since 5.32
95+
*/
96+
export function fromAction(action, fn = /** @type {() => T} */ (noop)) {
97+
return (element) => {
98+
const { update, destroy } = untrack(() => action(element, fn()) ?? {});
99+
100+
if (update) {
101+
var ran = false;
102+
render_effect(() => {
103+
const arg = fn();
104+
if (ran) update(arg);
105+
});
106+
ran = true;
107+
}
108+
109+
if (destroy) {
110+
teardown(destroy);
111+
}
112+
};
113+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { ok, test } from '../../test';
2+
import { flushSync } from 'svelte';
3+
4+
export default test({
5+
async test({ assert, target, logs }) {
6+
const [btn, btn2, btn3] = target.querySelectorAll('button');
7+
8+
// both logs on creation it will not log on change
9+
assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment']);
10+
11+
// clicking the first button logs the right value
12+
flushSync(() => {
13+
btn?.click();
14+
});
15+
assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment', 0]);
16+
17+
// clicking the second button logs the right value
18+
flushSync(() => {
19+
btn2?.click();
20+
});
21+
assert.deepEqual(logs, ['create', 0, 'action', 'create', 0, 'attachment', 0, 0]);
22+
23+
// updating the arguments logs the update function for both
24+
flushSync(() => {
25+
btn3?.click();
26+
});
27+
assert.deepEqual(logs, [
28+
'create',
29+
0,
30+
'action',
31+
'create',
32+
0,
33+
'attachment',
34+
0,
35+
0,
36+
'update',
37+
1,
38+
'action',
39+
'update',
40+
1,
41+
'attachment'
42+
]);
43+
44+
// clicking the first button again shows the right value
45+
flushSync(() => {
46+
btn?.click();
47+
});
48+
assert.deepEqual(logs, [
49+
'create',
50+
0,
51+
'action',
52+
'create',
53+
0,
54+
'attachment',
55+
0,
56+
0,
57+
'update',
58+
1,
59+
'action',
60+
'update',
61+
1,
62+
'attachment',
63+
1
64+
]);
65+
66+
// clicking the second button again shows the right value
67+
flushSync(() => {
68+
btn2?.click();
69+
});
70+
assert.deepEqual(logs, [
71+
'create',
72+
0,
73+
'action',
74+
'create',
75+
0,
76+
'attachment',
77+
0,
78+
0,
79+
'update',
80+
1,
81+
'action',
82+
'update',
83+
1,
84+
'attachment',
85+
1,
86+
1
87+
]);
88+
89+
// unmounting logs the destroy function for both
90+
flushSync(() => {
91+
btn3?.click();
92+
});
93+
assert.deepEqual(logs, [
94+
'create',
95+
0,
96+
'action',
97+
'create',
98+
0,
99+
'attachment',
100+
0,
101+
0,
102+
'update',
103+
1,
104+
'action',
105+
'update',
106+
1,
107+
'attachment',
108+
1,
109+
1,
110+
'destroy',
111+
'action',
112+
'destroy',
113+
'attachment'
114+
]);
115+
}
116+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script>
2+
import { fromAction } from 'svelte/attachments';
3+
let { count = 0 } = $props();
4+
5+
function test(node, thing) {
6+
const kind = node.dataset.kind;
7+
console.log('create', thing, kind);
8+
let t = thing;
9+
const controller = new AbortController();
10+
node.addEventListener(
11+
'click',
12+
() => {
13+
console.log(t);
14+
},
15+
{
16+
signal: controller.signal
17+
}
18+
);
19+
return {
20+
update(new_thing) {
21+
console.log('update', new_thing, kind);
22+
t = new_thing;
23+
},
24+
destroy() {
25+
console.log('destroy', kind);
26+
controller.abort();
27+
}
28+
};
29+
}
30+
</script>
31+
32+
{#if count < 2}
33+
<button data-kind="action" use:test={count}></button>
34+
<button data-kind="attachment" {@attach fromAction(test, ()=>count)}></button>
35+
{/if}
36+
37+
<button onclick={()=> count++}></button>

packages/svelte/types/index.d.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,107 @@ declare module 'svelte/attachments' {
658658
* @since 5.29
659659
*/
660660
export function createAttachmentKey(): symbol;
661+
/**
662+
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
663+
* It's useful if you want to start using attachments on components but you have actions provided by a library.
664+
*
665+
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
666+
* action function, not the argument itself.
667+
*
668+
* ```svelte
669+
* <!-- with an action -->
670+
* <div use:foo={bar}>...</div>
671+
*
672+
* <!-- with an attachment -->
673+
* <div {@attach fromAction(foo, () => bar)}>...</div>
674+
* ```
675+
* */
676+
export function fromAction<E extends EventTarget, T extends unknown>(action: Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>), fn: () => T): Attachment<E>;
677+
/**
678+
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
679+
* It's useful if you want to start using attachments on components but you have actions provided by a library.
680+
*
681+
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
682+
* action function, not the argument itself.
683+
*
684+
* ```svelte
685+
* <!-- with an action -->
686+
* <div use:foo={bar}>...</div>
687+
*
688+
* <!-- with an attachment -->
689+
* <div {@attach fromAction(foo, () => bar)}>...</div>
690+
* ```
691+
* */
692+
export function fromAction<E extends EventTarget>(action: Action<E, void> | ((element: E) => void | ActionReturn<void>)): Attachment<E>;
693+
/**
694+
* Actions can return an object containing the two properties defined in this interface. Both are optional.
695+
* - update: An action can have a parameter. This method will be called whenever that parameter changes,
696+
* immediately after Svelte has applied updates to the markup. `ActionReturn` and `ActionReturn<undefined>` both
697+
* mean that the action accepts no parameters.
698+
* - destroy: Method that is called after the element is unmounted
699+
*
700+
* Additionally, you can specify which additional attributes and events the action enables on the applied element.
701+
* This applies to TypeScript typings only and has no effect at runtime.
702+
*
703+
* Example usage:
704+
* ```ts
705+
* interface Attributes {
706+
* newprop?: string;
707+
* 'on:event': (e: CustomEvent<boolean>) => void;
708+
* }
709+
*
710+
* export function myAction(node: HTMLElement, parameter: Parameter): ActionReturn<Parameter, Attributes> {
711+
* // ...
712+
* return {
713+
* update: (updatedParameter) => {...},
714+
* destroy: () => {...}
715+
* };
716+
* }
717+
* ```
718+
*/
719+
interface ActionReturn<
720+
Parameter = undefined,
721+
Attributes extends Record<string, any> = Record<never, any>
722+
> {
723+
update?: (parameter: Parameter) => void;
724+
destroy?: () => void;
725+
/**
726+
* ### DO NOT USE THIS
727+
* This exists solely for type-checking and has no effect at runtime.
728+
* Set this through the `Attributes` generic instead.
729+
*/
730+
$$_attributes?: Attributes;
731+
}
732+
733+
/**
734+
* Actions are functions that are called when an element is created.
735+
* You can use this interface to type such actions.
736+
* The following example defines an action that only works on `<div>` elements
737+
* and optionally accepts a parameter which it has a default value for:
738+
* ```ts
739+
* export const myAction: Action<HTMLDivElement, { someProperty: boolean } | undefined> = (node, param = { someProperty: true }) => {
740+
* // ...
741+
* }
742+
* ```
743+
* `Action<HTMLDivElement>` and `Action<HTMLDivElement, undefined>` both signal that the action accepts no parameters.
744+
*
745+
* You can return an object with methods `update` and `destroy` from the function and type which additional attributes and events it has.
746+
* See interface `ActionReturn` for more details.
747+
*/
748+
interface Action<
749+
Element = HTMLElement,
750+
Parameter = undefined,
751+
Attributes extends Record<string, any> = Record<never, any>
752+
> {
753+
<Node extends Element>(
754+
...args: undefined extends Parameter
755+
? [node: Node, parameter?: Parameter]
756+
: [node: Node, parameter: Parameter]
757+
): void | ActionReturn<Parameter, Attributes>;
758+
}
759+
760+
// Implementation notes:
761+
// - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode
661762

662763
export {};
663764
}

0 commit comments

Comments
 (0)