kenspeckle 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -34
- package/dist/copy.d.ts +11 -0
- package/dist/copy.js +40 -0
- package/dist/finite-state-machine.svelte.d.ts +53 -0
- package/dist/finite-state-machine.svelte.js +78 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -2
- package/dist/kit/index.d.ts +5 -0
- package/dist/kit/index.js +2 -0
- package/dist/kit/types.d.ts +12 -0
- package/dist/kit/types.js +1 -0
- package/dist/kit/view-transition-name.d.ts +16 -0
- package/dist/kit/view-transition-name.js +48 -0
- package/dist/kit/view-transition.d.ts +18 -0
- package/dist/kit/view-transition.js +57 -0
- package/dist/view-transition.d.ts +22 -0
- package/dist/view-transition.js +58 -0
- package/package.json +38 -6
- package/dist/vitest-examples/Welcome.svelte +0 -8
- package/dist/vitest-examples/Welcome.svelte.d.ts +0 -13
- package/dist/vitest-examples/greet.d.ts +0 -1
- package/dist/vitest-examples/greet.js +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brandon Pittman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,65 +1,199 @@
|
|
|
1
|
-
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/banner-dark.svg">
|
|
3
|
+
<img src="docs/assets/banner-light.svg" alt="kenspeckle, adjective, Scots, from Old Norse kennispeki: 1. easily recognised; conspicuous; familiar at sight. 2. (of Svelte utilities) knowable from the name alone.">
|
|
4
|
+
</picture>
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
Svelte utilities you recognize at sight. One naming convention, no `use` prefix, attachments first-class.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
## The sentence test
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Every export earns its shape by being said aloud: _"I want a new \___."_
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
- "a new **finite state machine**" — speakable, and you drive it with methods → `new FiniteStateMachine()`
|
|
13
|
+
- "a new **element size**" — not a thing you ask for → `elementSize()`
|
|
14
|
+
- "a new **is idle**" — nonsense → `idle()`
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
Two rules fall out:
|
|
17
|
+
|
|
18
|
+
1. **Class** only when "new X" is speakable _and_ you drive it with methods. There are exactly two: `FiniteStateMachine` and `StateHistory`.
|
|
19
|
+
2. **Everything else is a camelCase function.** No `use` prefix — that's a React Hooks rule, and Svelte has no such rule. No `is` prefix either — predicates are bare words read as implicit questions: `mounted()`, `idle()`, `documentVisible()`. The question lives in the library, the answer in your variable: `const isMounted = mounted()`.
|
|
20
|
+
|
|
21
|
+
That's why the name. Kenspeckle things are known at sight; so is every export here. The word is the spec.
|
|
22
|
+
|
|
23
|
+
## Attachments, curried
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
Element-bound utilities are one name in two forms. Options only → returns an [attachment](https://svelte.dev/docs/svelte/@attach). Element first → the imperative helper, cleanup returned.
|
|
26
|
+
|
|
27
|
+
```svelte
|
|
28
|
+
<script>
|
|
29
|
+
// same import, imperative
|
|
30
|
+
const cleanup = clickOutside(node, () => close());
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<div {@attach clickOutside(() => close())}>…</div>
|
|
17
34
|
```
|
|
18
35
|
|
|
19
|
-
|
|
36
|
+
Value-producing attachments are readable boxes — the factory's return _is_ the attachment, carrying reactive getters:
|
|
20
37
|
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
```svelte
|
|
39
|
+
<script>
|
|
40
|
+
import { elementSize } from 'kenspeckle';
|
|
41
|
+
|
|
42
|
+
const size = elementSize(); // no target yet
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div {@attach size}>…</div><p>{size.width} × {size.height}</p>
|
|
24
46
|
```
|
|
25
47
|
|
|
26
|
-
|
|
48
|
+
The same export works imperatively with a getter: `elementSize(() => node)`. Argument presence picks the mode.
|
|
27
49
|
|
|
28
|
-
|
|
50
|
+
## Install
|
|
29
51
|
|
|
30
52
|
```sh
|
|
31
|
-
npm
|
|
53
|
+
npm install kenspeckle
|
|
54
|
+
```
|
|
32
55
|
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
Peer dependencies: `svelte >= 5.40`, plus `@sveltejs/kit >= 2` for the `kenspeckle/kit` subpath (optional). SSR-safe — value factories return inert defaults on the server.
|
|
57
|
+
|
|
58
|
+
## What's inside
|
|
59
|
+
|
|
60
|
+
| export | form | lineage |
|
|
61
|
+
| ------------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------- |
|
|
62
|
+
| `FiniteStateMachine` | class, with typed reactive `context` | runed `FiniteStateMachine` |
|
|
63
|
+
| `StateHistory` | class | runed `StateHistory` |
|
|
64
|
+
| `mounted()` | value factory | runed `IsMounted` |
|
|
65
|
+
| `idle()` | value factory | runed `IsIdle` |
|
|
66
|
+
| `documentVisible()` | value factory | runed `IsDocumentVisible` |
|
|
67
|
+
| `focusWithin()` | attachment, readable box | runed `IsFocusWithin` |
|
|
68
|
+
| `inViewport()` | attachment, readable box | runed `IsInViewport` |
|
|
69
|
+
| `elementSize()` | attachment, readable box | runed `ElementSize` |
|
|
70
|
+
| `elementRect()` | attachment, readable box | runed `ElementRect` |
|
|
71
|
+
| `scrollState()` | attachment, readable box; window form | runed `ScrollState` |
|
|
72
|
+
| `autosize()` | attachment | runed `TextareaAutosize` |
|
|
73
|
+
| `clickOutside()` | attachment + helper | runed `onClickOutside` · svelte-put `clickoutside` |
|
|
74
|
+
| `intersected()` | attachment + helper | runed `useIntersectionObserver` · svelte-put `intersect` |
|
|
75
|
+
| `resized()` | attachment + helper | runed `useResizeObserver` · svelte-put `resize` |
|
|
76
|
+
| `mutated()` | attachment + helper | runed `useMutationObserver` |
|
|
77
|
+
| `copy()` | attachment + helper | svelte-put `copy` |
|
|
78
|
+
| `shortcut()` | attachment + helper | svelte-put `shortcut` |
|
|
79
|
+
| `lockScroll` | attachment ≡ helper, stacked lock counting | new |
|
|
80
|
+
| `dragScroll()` | attachment | svelte-put `dragscroll` |
|
|
81
|
+
| `viewTransition()` | function; navigation form under `/kit` | new |
|
|
82
|
+
| `viewTransitionName()` | attachment + helper | new |
|
|
83
|
+
| `retreat()` | function, returns a disposer | new |
|
|
84
|
+
| `debounced()` / `throttled()` | value factory | runed `Debounced` / `Throttled` |
|
|
85
|
+
| `debounce()` / `throttle()` | function wrapper | runed `useDebounce` / `useThrottle` |
|
|
86
|
+
| `previous()` | value factory | runed `Previous` |
|
|
87
|
+
| `activeElement()` | value factory | runed `ActiveElement` |
|
|
88
|
+
| `pressedKeys()` | value factory | runed `PressedKeys` |
|
|
89
|
+
| `persisted()` | value factory | runed `PersistedState` |
|
|
90
|
+
| `animationFrames()` | value factory | runed `AnimationFrames` |
|
|
91
|
+
| `geolocation()` | value factory | runed `useGeolocation` |
|
|
92
|
+
| `listen()` | wiring | runed `useEventListener` |
|
|
93
|
+
| `interval()` | wiring | runed `useInterval` |
|
|
94
|
+
| `searchParams()` + helpers | value factory | runed `useSearchParams` |
|
|
95
|
+
| `watch` / `watchOnce` / `extract` / `onCleanup` / `boolAttr` | unchanged | runed |
|
|
96
|
+
|
|
97
|
+
Dropped, deliberately: runed's `Context` — Svelte ≥ 5.40's `createContext` returns a typed `[get, set]` pair and covers it. runed's `resource` waits in the backlog — SvelteKit remote functions cover the server-data case; it returns only if a real client-only async need shows up.
|
|
98
|
+
|
|
99
|
+
`FiniteStateMachine` gains a typed, `$state`-backed `context` object visible to lifecycle hooks and guards — the sidecar-data mechanism every real FSM grows, built in.
|
|
100
|
+
|
|
101
|
+
View transitions ship in two tiers. `viewTransition(update)` animates a state change within a route; the SvelteKit navigation form, plus `viewTransitionName` and `retreat`, come from the `kenspeckle/kit` subpath — separate so `$app/navigation` never enters the main entry. Both tiers fall back cleanly: with no `document.startViewTransition`, or under `prefers-reduced-motion`, the update still runs, untransitioned.
|
|
102
|
+
|
|
103
|
+
## View transitions
|
|
104
|
+
|
|
105
|
+
Two entry points. `kenspeckle` exports the same-route form; `kenspeckle/kit` adds the SvelteKit
|
|
106
|
+
navigation form, so `$app/navigation` never enters the main entry. The `/kit` subpath expects
|
|
107
|
+
`@sveltejs/kit` — an optional peer.
|
|
108
|
+
|
|
109
|
+
### The contract
|
|
110
|
+
|
|
111
|
+
One attribute on `<html>`, absent when idle:
|
|
112
|
+
|
|
113
|
+
| `data-view-transition` | written by |
|
|
114
|
+
| ---------------------- | ----------------------------------------------------- |
|
|
115
|
+
| `forward` | a navigation |
|
|
116
|
+
| `retreat` | a navigation the predicate claims, or a Back popstate |
|
|
117
|
+
| `step-forward` | `viewTransition(update)` |
|
|
118
|
+
| `step-retreat` | `viewTransition(update, { retreat: true })` |
|
|
119
|
+
|
|
120
|
+
Every animation is yours, keyed off that value. An unprefixed `::view-transition-old|new(name)` rule
|
|
121
|
+
matches both kinds, so scope it when a step and a navigation must differ.
|
|
122
|
+
|
|
123
|
+
```css
|
|
124
|
+
html[data-view-transition='forward']::view-transition-old(root) {
|
|
125
|
+
animation: slide-out-left 300ms;
|
|
126
|
+
}
|
|
35
127
|
```
|
|
36
128
|
|
|
37
|
-
|
|
129
|
+
### Navigations
|
|
38
130
|
|
|
39
|
-
|
|
131
|
+
Register once, from the root layout:
|
|
40
132
|
|
|
41
|
-
|
|
133
|
+
```svelte
|
|
134
|
+
<script>
|
|
135
|
+
import { viewTransition } from 'kenspeckle/kit';
|
|
42
136
|
|
|
43
|
-
|
|
44
|
-
|
|
137
|
+
viewTransition();
|
|
138
|
+
</script>
|
|
45
139
|
```
|
|
46
140
|
|
|
47
|
-
|
|
141
|
+
Direction is a predicate, never a flag: Kit skips `beforeNavigate` for a navigation begun while
|
|
142
|
+
another is in flight, so a flag set at click time can be stranded and reverse a later navigation.
|
|
143
|
+
`retreat()` returns an identity-guarded disposer, shaped for `$effect` cleanup.
|
|
48
144
|
|
|
49
|
-
```
|
|
50
|
-
|
|
145
|
+
```svelte
|
|
146
|
+
<script>
|
|
147
|
+
import { retreat } from 'kenspeckle/kit';
|
|
148
|
+
|
|
149
|
+
$effect(() => retreat((navigation) => navigation.to?.route.id === '/'));
|
|
150
|
+
</script>
|
|
51
151
|
```
|
|
52
152
|
|
|
53
|
-
|
|
153
|
+
A Back popstate is a retreat without asking. A Forward popstate is not.
|
|
54
154
|
|
|
55
|
-
|
|
155
|
+
### Steps within a route
|
|
56
156
|
|
|
57
|
-
|
|
157
|
+
`onNavigate` never fires for a wizard advancing its own state, so drive the primitive directly:
|
|
58
158
|
|
|
59
|
-
|
|
159
|
+
```svelte
|
|
160
|
+
<script>
|
|
161
|
+
import { viewTransition } from 'kenspeckle';
|
|
60
162
|
|
|
61
|
-
|
|
163
|
+
let step = $state(0);
|
|
164
|
+
const go = (delta) => viewTransition(() => (step += delta), { retreat: delta < 0 });
|
|
165
|
+
</script>
|
|
166
|
+
```
|
|
62
167
|
|
|
63
|
-
|
|
64
|
-
|
|
168
|
+
The update callback suspends rendering until it settles, which past half a second reads as a hang —
|
|
169
|
+
`deadline` (600ms) skips the transition rather than let it. With no `document.startViewTransition`,
|
|
170
|
+
or under `prefers-reduced-motion`, the update still runs, untransitioned, and no attribute is
|
|
171
|
+
written.
|
|
172
|
+
|
|
173
|
+
### Named elements
|
|
174
|
+
|
|
175
|
+
`viewTransitionName` claims `view-transition-name` for the duration of a transition and releases it
|
|
176
|
+
after. Two elements holding one name silently abort the whole transition, which is why claims are
|
|
177
|
+
scoped rather than left in static CSS.
|
|
178
|
+
|
|
179
|
+
```svelte
|
|
180
|
+
<button {@attach viewTransitionName('back-button')}>Back</button>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`when` claims only for navigations it accepts; `onArrival` claims inside the update callback instead,
|
|
184
|
+
for an element that mounts during the transition. The imperative form takes the element first and
|
|
185
|
+
returns a disposer:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
const dispose = viewTransitionName(element, 'back-button');
|
|
65
189
|
```
|
|
190
|
+
|
|
191
|
+
**Claims are navigation-only.** The registration form drives the capture and arrival phases;
|
|
192
|
+
`viewTransition(update)` on a same-route step names nothing. Morph a stepping element with static
|
|
193
|
+
`view-transition-name` in CSS instead.
|
|
194
|
+
|
|
195
|
+
## Provenance
|
|
196
|
+
|
|
197
|
+
kenspeckle is a curation, not a fork. [runed](https://runed.dev) has genuinely useful utilities under three naming regimes at once — PascalCase classes, bare functions, and React-style `use` hooks, with pairs like `Debounced` / `useDebounce` coexisting. [svelte-put](https://svelte-put.vnphanquang.com) has good actions from the Svelte 4 era, before attachments existed. kenspeckle reshapes both pools under one convention and adds what neither ships: attachments as a first-class layer.
|
|
198
|
+
|
|
199
|
+
Portions adapted from runed (MIT) and svelte-put (MIT). kenspeckle is MIT.
|
package/dist/copy.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
export type CopyText = string | ((node: HTMLElement) => string | Promise<string>);
|
|
3
|
+
export interface CopyOptions<K extends keyof HTMLElementEventMap = 'click'> {
|
|
4
|
+
text?: CopyText;
|
|
5
|
+
event?: K | K[];
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
onCopy?: (text: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function copyText(text: string): Promise<void>;
|
|
10
|
+
export declare function copy<K extends keyof HTMLElementEventMap = 'click'>(options?: CopyOptions<K>): Attachment<HTMLElement>;
|
|
11
|
+
export declare function copy<K extends keyof HTMLElementEventMap = 'click'>(node: HTMLElement, options?: CopyOptions<K>): () => void;
|
package/dist/copy.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { on } from 'svelte/events';
|
|
2
|
+
export async function copyText(text) {
|
|
3
|
+
try {
|
|
4
|
+
await navigator.clipboard.writeText(text);
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
const el = document.createElement('textarea');
|
|
8
|
+
el.value = text;
|
|
9
|
+
el.setAttribute('readonly', '');
|
|
10
|
+
el.style.position = 'absolute';
|
|
11
|
+
el.style.left = '-9999px';
|
|
12
|
+
document.body.appendChild(el);
|
|
13
|
+
el.select();
|
|
14
|
+
document.execCommand('copy');
|
|
15
|
+
document.body.removeChild(el);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function copy(nodeOrOptions, maybeOptions) {
|
|
19
|
+
if (typeof Element !== 'undefined' && nodeOrOptions instanceof Element) {
|
|
20
|
+
return wire(nodeOrOptions, maybeOptions);
|
|
21
|
+
}
|
|
22
|
+
const options = nodeOrOptions;
|
|
23
|
+
return (node) => wire(node, options);
|
|
24
|
+
}
|
|
25
|
+
function wire(node, options = {}) {
|
|
26
|
+
const { text, event, enabled = true, onCopy } = options;
|
|
27
|
+
if (!enabled)
|
|
28
|
+
return () => { };
|
|
29
|
+
const events = (event === undefined ? ['click'] : Array.isArray(event) ? event : [event]);
|
|
30
|
+
async function handler() {
|
|
31
|
+
const resolved = typeof text === 'function' ? await text(node) : (text ?? node.textContent ?? '');
|
|
32
|
+
await copyText(resolved);
|
|
33
|
+
onCopy?.(resolved);
|
|
34
|
+
}
|
|
35
|
+
const offs = events.map((e) => on(node, e, handler));
|
|
36
|
+
return () => {
|
|
37
|
+
for (const off of offs)
|
|
38
|
+
off();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type EventMap = Record<string, unknown[]>;
|
|
2
|
+
type EventArgs<EventsT extends EventMap> = {
|
|
3
|
+
[K in keyof EventsT]: {
|
|
4
|
+
event: K;
|
|
5
|
+
args: EventsT[K];
|
|
6
|
+
};
|
|
7
|
+
}[keyof EventsT];
|
|
8
|
+
export type ActionMeta<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT, K extends keyof EventsT> = {
|
|
9
|
+
from: CurrentT;
|
|
10
|
+
event: K;
|
|
11
|
+
args: EventsT[K];
|
|
12
|
+
context: ContextT;
|
|
13
|
+
};
|
|
14
|
+
export type EnterMeta<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT> = {
|
|
15
|
+
to: CurrentT;
|
|
16
|
+
context: ContextT;
|
|
17
|
+
} & (({
|
|
18
|
+
from: StatesT;
|
|
19
|
+
} & EventArgs<EventsT>) | {
|
|
20
|
+
from: null;
|
|
21
|
+
event: null;
|
|
22
|
+
args: [];
|
|
23
|
+
});
|
|
24
|
+
export type ExitMeta<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT> = {
|
|
25
|
+
from: CurrentT;
|
|
26
|
+
to: StatesT;
|
|
27
|
+
context: ContextT;
|
|
28
|
+
} & EventArgs<EventsT>;
|
|
29
|
+
export type Action<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT, K extends keyof EventsT> = StatesT | ((meta: ActionMeta<StatesT, EventsT, ContextT, CurrentT, K>) => StatesT | void);
|
|
30
|
+
export type StateHandler<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT> = {
|
|
31
|
+
[K in keyof EventsT]?: Action<StatesT, EventsT, ContextT, CurrentT, K>;
|
|
32
|
+
} & {
|
|
33
|
+
_enter?: (meta: EnterMeta<StatesT, EventsT, ContextT, CurrentT>) => void;
|
|
34
|
+
_exit?: (meta: ExitMeta<StatesT, EventsT, ContextT, CurrentT>) => void;
|
|
35
|
+
};
|
|
36
|
+
export type Transition<StatesT extends string, EventsT extends EventMap, ContextT = undefined> = {
|
|
37
|
+
[S in StatesT]: StateHandler<StatesT, EventsT, ContextT, S>;
|
|
38
|
+
} & {
|
|
39
|
+
'*'?: StateHandler<StatesT, EventsT, ContextT, StatesT>;
|
|
40
|
+
};
|
|
41
|
+
export declare class FiniteStateMachine<StatesT extends string, EventsT extends EventMap, ContextT = undefined> {
|
|
42
|
+
#private;
|
|
43
|
+
readonly states: Transition<StatesT, EventsT, ContextT>;
|
|
44
|
+
constructor(initial: StatesT, states: Transition<StatesT, EventsT, ContextT>, ...options: undefined extends ContextT ? [] : [{
|
|
45
|
+
context: ContextT;
|
|
46
|
+
}]);
|
|
47
|
+
send: <K extends keyof EventsT>(event: K, ...args: EventsT[K]) => StatesT;
|
|
48
|
+
debounce: <K extends keyof EventsT>(event: K, wait?: number, ...args: EventsT[K]) => Promise<StatesT>;
|
|
49
|
+
get current(): StatesT;
|
|
50
|
+
get context(): ContextT;
|
|
51
|
+
set context(next: ContextT);
|
|
52
|
+
}
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
export class FiniteStateMachine {
|
|
3
|
+
#current = $state();
|
|
4
|
+
#context = $state();
|
|
5
|
+
states;
|
|
6
|
+
constructor(initial, states, ...options) {
|
|
7
|
+
this.#current = initial;
|
|
8
|
+
this.states = states;
|
|
9
|
+
this.#context = options[0]?.context;
|
|
10
|
+
this.#lifecycle('_enter', initial, {
|
|
11
|
+
from: null,
|
|
12
|
+
to: initial,
|
|
13
|
+
event: null,
|
|
14
|
+
args: [],
|
|
15
|
+
context: this.#context
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
send = (event, ...args) => {
|
|
19
|
+
const action = this.states[this.#current]?.[event] ?? this.states['*']?.[event];
|
|
20
|
+
if (action === undefined) {
|
|
21
|
+
if (DEV) {
|
|
22
|
+
console.warn(`kenspeckle: unhandled event '${String(event)}' in state '${this.#current}'`);
|
|
23
|
+
}
|
|
24
|
+
return this.#current;
|
|
25
|
+
}
|
|
26
|
+
const target = typeof action === 'function'
|
|
27
|
+
? action({
|
|
28
|
+
from: this.#current,
|
|
29
|
+
event,
|
|
30
|
+
args,
|
|
31
|
+
context: this.#context
|
|
32
|
+
})
|
|
33
|
+
: action;
|
|
34
|
+
if (target != null)
|
|
35
|
+
this.#transition(target, event, args);
|
|
36
|
+
return this.#current;
|
|
37
|
+
};
|
|
38
|
+
#timeouts = {};
|
|
39
|
+
debounce = (event, wait = 500, ...args) => {
|
|
40
|
+
const pending = this.#timeouts[event];
|
|
41
|
+
if (pending)
|
|
42
|
+
clearTimeout(pending.id);
|
|
43
|
+
const resolvers = pending?.resolvers ?? [];
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
resolvers.push(resolve);
|
|
46
|
+
this.#timeouts[event] = {
|
|
47
|
+
id: setTimeout(() => {
|
|
48
|
+
delete this.#timeouts[event];
|
|
49
|
+
const state = this.send(event, ...args);
|
|
50
|
+
for (const r of resolvers)
|
|
51
|
+
r(state);
|
|
52
|
+
}, wait),
|
|
53
|
+
resolvers
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
#transition(to, event, args) {
|
|
58
|
+
const from = this.#current;
|
|
59
|
+
this.#lifecycle('_exit', from, { from, to, event, args, context: this.#context });
|
|
60
|
+
if (this.#current !== from)
|
|
61
|
+
return; // _exit sent elsewhere; that transition supersedes
|
|
62
|
+
this.#current = to;
|
|
63
|
+
this.#lifecycle('_enter', to, { from, to, event, args, context: this.#context });
|
|
64
|
+
}
|
|
65
|
+
#lifecycle(kind, state, meta) {
|
|
66
|
+
const fn = this.states[state]?.[kind] ?? this.states['*']?.[kind];
|
|
67
|
+
fn?.(meta);
|
|
68
|
+
}
|
|
69
|
+
get current() {
|
|
70
|
+
return this.#current;
|
|
71
|
+
}
|
|
72
|
+
get context() {
|
|
73
|
+
return this.#context;
|
|
74
|
+
}
|
|
75
|
+
set context(next) {
|
|
76
|
+
this.#context = next;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { copy, copyText } from './copy.js';
|
|
2
|
+
export type { CopyOptions, CopyText } from './copy.js';
|
|
3
|
+
export { FiniteStateMachine } from './finite-state-machine.svelte.js';
|
|
4
|
+
export type { Action, ActionMeta, EnterMeta, EventMap, ExitMeta, StateHandler, Transition } from './finite-state-machine.svelte.js';
|
|
5
|
+
export { viewTransition } from './view-transition.js';
|
|
6
|
+
export type { StartViewTransition, ViewTransitionOptions, ViewTransitionUpdate } from './view-transition.js';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export { copy, copyText } from './copy.js';
|
|
2
|
+
export { FiniteStateMachine } from './finite-state-machine.svelte.js';
|
|
3
|
+
export { viewTransition } from './view-transition.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { retreat, viewTransition } from './view-transition.js';
|
|
2
|
+
export type { NavigationTransitionOptions } from './view-transition.js';
|
|
3
|
+
export { viewTransitionName } from './view-transition-name.js';
|
|
4
|
+
export type { ViewTransitionNameOptions } from './view-transition-name.js';
|
|
5
|
+
export type { Navigation } from './types.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Structural, not imported from Kit — any router handing over a completion promise fits. */
|
|
2
|
+
export interface Navigation {
|
|
3
|
+
complete: Promise<void>;
|
|
4
|
+
type?: string;
|
|
5
|
+
delta?: number | null;
|
|
6
|
+
from?: {
|
|
7
|
+
url: URL;
|
|
8
|
+
} | null;
|
|
9
|
+
to?: {
|
|
10
|
+
url: URL;
|
|
11
|
+
} | null;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
import type { Navigation } from './types.js';
|
|
3
|
+
export interface ViewTransitionNameOptions {
|
|
4
|
+
/** Only claim the name for navigations this returns true for. */
|
|
5
|
+
when?: (navigation: Navigation) => boolean;
|
|
6
|
+
/** Claim inside the update callback instead, for an element that mounts during the transition. */
|
|
7
|
+
onArrival?: boolean;
|
|
8
|
+
}
|
|
9
|
+
type Phase = 'capture' | 'arrival';
|
|
10
|
+
/** @internal — driven by the navigation registration. */
|
|
11
|
+
export declare function beginPhase(navigation: Navigation, phase: Phase): void;
|
|
12
|
+
/** @internal */
|
|
13
|
+
export declare function endTransition(): void;
|
|
14
|
+
export declare function viewTransitionName(name: string, options?: ViewTransitionNameOptions): Attachment<HTMLElement>;
|
|
15
|
+
export declare function viewTransitionName(element: HTMLElement, name: string, options?: ViewTransitionNameOptions): () => void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const claims = new Set();
|
|
2
|
+
let current = null;
|
|
3
|
+
function wanted(claim, phase, navigation) {
|
|
4
|
+
if ((claim.options.onArrival ? 'arrival' : 'capture') !== phase)
|
|
5
|
+
return false;
|
|
6
|
+
return claim.options.when?.(navigation) ?? true;
|
|
7
|
+
}
|
|
8
|
+
function apply(claim) {
|
|
9
|
+
claim.element.style.viewTransitionName = claim.name;
|
|
10
|
+
claim.named = true;
|
|
11
|
+
}
|
|
12
|
+
function release(claim) {
|
|
13
|
+
if (!claim.named)
|
|
14
|
+
return;
|
|
15
|
+
claim.element.style.viewTransitionName = '';
|
|
16
|
+
claim.named = false;
|
|
17
|
+
}
|
|
18
|
+
/** @internal — driven by the navigation registration. */
|
|
19
|
+
export function beginPhase(navigation, phase) {
|
|
20
|
+
current = { navigation, phase };
|
|
21
|
+
for (const claim of claims)
|
|
22
|
+
if (wanted(claim, phase, navigation))
|
|
23
|
+
apply(claim);
|
|
24
|
+
}
|
|
25
|
+
/** @internal */
|
|
26
|
+
export function endTransition() {
|
|
27
|
+
current = null;
|
|
28
|
+
for (const claim of claims)
|
|
29
|
+
release(claim);
|
|
30
|
+
}
|
|
31
|
+
function wire(element, name, options = {}) {
|
|
32
|
+
const claim = { element, name, options, named: false };
|
|
33
|
+
claims.add(claim);
|
|
34
|
+
// An element mounting mid-transition registers after its phase already swept.
|
|
35
|
+
if (current && wanted(claim, current.phase, current.navigation))
|
|
36
|
+
apply(claim);
|
|
37
|
+
return () => {
|
|
38
|
+
release(claim);
|
|
39
|
+
claims.delete(claim);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function viewTransitionName(first, second, third) {
|
|
43
|
+
if (typeof Element !== 'undefined' && first instanceof Element) {
|
|
44
|
+
return wire(first, second, third);
|
|
45
|
+
}
|
|
46
|
+
const options = second;
|
|
47
|
+
return (element) => wire(element, first, options);
|
|
48
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ViewTransitionOptions, type ViewTransitionUpdate } from '../view-transition.js';
|
|
2
|
+
import type { Navigation } from './types.js';
|
|
3
|
+
export interface NavigationTransitionOptions extends Omit<ViewTransitionOptions, 'retreat' | 'onStart'> {
|
|
4
|
+
onStart?: (navigation: Navigation) => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A predicate, never a flag: Kit skips `beforeNavigate` for a navigation begun while another is in
|
|
8
|
+
* flight, so a flag set at click time can be stranded and reverse a later navigation.
|
|
9
|
+
*
|
|
10
|
+
* Returns a disposer shaped for `$effect` cleanup.
|
|
11
|
+
*/
|
|
12
|
+
export declare function retreat(next: (navigation: Navigation) => boolean): () => void;
|
|
13
|
+
/** @internal */
|
|
14
|
+
export declare function isRetreat(navigation: Navigation): boolean;
|
|
15
|
+
/** @internal — the handler `viewTransition()` registers, exported so it is testable without a component. */
|
|
16
|
+
export declare function navigationTransition({ onStart, onSettle, ...rest }?: NavigationTransitionOptions): (navigation: Navigation) => Promise<void>;
|
|
17
|
+
export declare function viewTransition(options?: NavigationTransitionOptions): void;
|
|
18
|
+
export declare function viewTransition(update: ViewTransitionUpdate, options?: ViewTransitionOptions): ViewTransition | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { onNavigate } from '$app/navigation';
|
|
2
|
+
import { runViewTransition, viewTransition as directTransition } from '../view-transition.js';
|
|
3
|
+
import { beginPhase, endTransition } from './view-transition-name.js';
|
|
4
|
+
const forward = () => false;
|
|
5
|
+
let predicate = forward;
|
|
6
|
+
/**
|
|
7
|
+
* A predicate, never a flag: Kit skips `beforeNavigate` for a navigation begun while another is in
|
|
8
|
+
* flight, so a flag set at click time can be stranded and reverse a later navigation.
|
|
9
|
+
*
|
|
10
|
+
* Returns a disposer shaped for `$effect` cleanup.
|
|
11
|
+
*/
|
|
12
|
+
export function retreat(next) {
|
|
13
|
+
predicate = next;
|
|
14
|
+
return () => {
|
|
15
|
+
// Identity-guarded: a stale disposer must not unregister whoever replaced it.
|
|
16
|
+
if (predicate === next)
|
|
17
|
+
predicate = forward;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** @internal */
|
|
21
|
+
export function isRetreat(navigation) {
|
|
22
|
+
// Kit sets delta only on a popstate: negative is Back, positive Forward. Absent, assume Back.
|
|
23
|
+
const back = navigation.type === 'popstate' && (navigation.delta ?? -1) < 0;
|
|
24
|
+
return back || predicate(navigation);
|
|
25
|
+
}
|
|
26
|
+
/** @internal — the handler `viewTransition()` registers, exported so it is testable without a component. */
|
|
27
|
+
export function navigationTransition({ onStart, onSettle, ...rest } = {}) {
|
|
28
|
+
return (navigation) => new Promise((resolve) => {
|
|
29
|
+
let transitioning = false;
|
|
30
|
+
runViewTransition(async () => {
|
|
31
|
+
// resolve() BEFORE awaiting breaks a mutual wait: Kit holds the navigation until this
|
|
32
|
+
// promise settles, the browser holds the snapshot until the callback settles.
|
|
33
|
+
resolve();
|
|
34
|
+
await navigation.complete;
|
|
35
|
+
if (transitioning)
|
|
36
|
+
beginPhase(navigation, 'arrival');
|
|
37
|
+
}, {
|
|
38
|
+
...rest,
|
|
39
|
+
state: isRetreat(navigation) ? 'retreat' : 'forward',
|
|
40
|
+
onStart: () => {
|
|
41
|
+
transitioning = true;
|
|
42
|
+
beginPhase(navigation, 'capture');
|
|
43
|
+
onStart?.(navigation);
|
|
44
|
+
},
|
|
45
|
+
onSettle: () => {
|
|
46
|
+
transitioning = false;
|
|
47
|
+
endTransition();
|
|
48
|
+
onSettle?.();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function viewTransition(first, second) {
|
|
54
|
+
if (typeof first === 'function')
|
|
55
|
+
return directTransition(first, second);
|
|
56
|
+
onNavigate(navigationTransition(first));
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type StartViewTransition = (update: () => void | Promise<void>) => ViewTransition;
|
|
2
|
+
/** A union of signatures, not `() => void | Promise<void>`: TS's void-return relaxation applies per
|
|
3
|
+
* member, so only this shape accepts a concise arrow like `() => (count += 1)`. */
|
|
4
|
+
export type ViewTransitionUpdate = (() => void) | (() => Promise<void>);
|
|
5
|
+
export interface ViewTransitionOptions {
|
|
6
|
+
/** Reverse direction — writes `step-retreat` instead of `step-forward`. */
|
|
7
|
+
retreat?: boolean;
|
|
8
|
+
/** Past this a crossfade reads as a hang: the update callback suspends rendering until it settles. */
|
|
9
|
+
deadline?: number;
|
|
10
|
+
onStart?: () => void;
|
|
11
|
+
onSettle?: () => void;
|
|
12
|
+
reducedMotion?: () => boolean;
|
|
13
|
+
/** Injected in tests; keeps the `bind` internal and init SSR-safe. */
|
|
14
|
+
start?: StartViewTransition;
|
|
15
|
+
}
|
|
16
|
+
/** @internal — `/kit` supplies its own attribute vocabulary. Not re-exported from the barrel. */
|
|
17
|
+
export interface RunOptions extends ViewTransitionOptions {
|
|
18
|
+
state?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function viewTransition(update: ViewTransitionUpdate, options?: ViewTransitionOptions): ViewTransition | undefined;
|
|
21
|
+
/** @internal */
|
|
22
|
+
export declare function runViewTransition(update: ViewTransitionUpdate, { deadline, onStart, onSettle, reducedMotion, start, state }?: RunOptions): ViewTransition | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { settled } from 'svelte';
|
|
2
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
3
|
+
const ignore = () => { };
|
|
4
|
+
const root = () => document.documentElement;
|
|
5
|
+
// A consumer throw must not strand the transition, so it goes to the page's error handling instead.
|
|
6
|
+
const guard = (callback) => {
|
|
7
|
+
try {
|
|
8
|
+
callback?.();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
reportError(error);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
// One transition per document: starting a second skips the first, whose `finished` then settles.
|
|
15
|
+
let active;
|
|
16
|
+
export function viewTransition(update, options = {}) {
|
|
17
|
+
return runViewTransition(update, {
|
|
18
|
+
...options,
|
|
19
|
+
state: options.retreat ? 'step-retreat' : 'step-forward'
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** @internal */
|
|
23
|
+
export function runViewTransition(update, { deadline = 600, onStart, onSettle, reducedMotion = () => prefersReducedMotion.current, start, state = 'step-forward' } = {}) {
|
|
24
|
+
// Resolved per call, never at module scope: SSR must not touch document.
|
|
25
|
+
const startTransition = start ??
|
|
26
|
+
(typeof document === 'undefined' ? undefined : document.startViewTransition?.bind(document));
|
|
27
|
+
// No transition at all, not merely no animation — the update callback suspends rendering.
|
|
28
|
+
if (!startTransition || reducedMotion()) {
|
|
29
|
+
// A sync throw still propagates; an async rejection is swallowed as the transition path swallows `finished`.
|
|
30
|
+
void Promise.resolve(update()).catch(ignore);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
root().dataset.viewTransition = state;
|
|
34
|
+
guard(onStart);
|
|
35
|
+
// `settled()`, not `tick()`: the browser snapshots the moment this callback resolves, and an async
|
|
36
|
+
// component's commit is batched — `tick()` only flushes synchronous updates and passes by luck.
|
|
37
|
+
const transition = startTransition(async () => {
|
|
38
|
+
await update();
|
|
39
|
+
await settled();
|
|
40
|
+
});
|
|
41
|
+
// Synchronously, before any microtask: a superseded predecessor settles one tick later.
|
|
42
|
+
active = transition;
|
|
43
|
+
const timer = setTimeout(() => transition.skipTransition(), deadline);
|
|
44
|
+
// Disarmed on the update callback, not `finished`: armed through the animation it snaps a running morph.
|
|
45
|
+
void transition.updateCallbackDone.catch(ignore).finally(() => clearTimeout(timer));
|
|
46
|
+
// Swallowed so cleanup still runs when the update callback threw; a skip fulfils `finished`.
|
|
47
|
+
void transition.finished.catch(ignore).finally(() => {
|
|
48
|
+
// Superseded: the successor owns the state and the claimed names, so its cleanup is not ours to run.
|
|
49
|
+
if (active !== transition)
|
|
50
|
+
return;
|
|
51
|
+
active = undefined;
|
|
52
|
+
delete root().dataset.viewTransition;
|
|
53
|
+
guard(onSettle);
|
|
54
|
+
});
|
|
55
|
+
// A skip rejects `ready`; unhandled, Chrome logs AbortError on the slow navigations the deadline rescues.
|
|
56
|
+
void transition.ready.catch(ignore);
|
|
57
|
+
return transition;
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenspeckle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Svelte 5 utilities under one naming convention: attachments first-class, view transitions, and a state machine with context.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Brandon Pittman <brandon@brandonpittman.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/brandonpittman/kenspeckle.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/brandonpittman/kenspeckle#readme",
|
|
12
|
+
"bugs": "https://github.com/brandonpittman/kenspeckle/issues",
|
|
4
13
|
"scripts": {
|
|
5
14
|
"dev": "vite dev",
|
|
6
15
|
"build": "wrangler types --check && vite build && npm run prepack",
|
|
@@ -19,7 +28,8 @@
|
|
|
19
28
|
"files": [
|
|
20
29
|
"dist",
|
|
21
30
|
"!dist/**/*.test.*",
|
|
22
|
-
"!dist/**/*.spec.*"
|
|
31
|
+
"!dist/**/*.spec.*",
|
|
32
|
+
"!dist/vitest-examples"
|
|
23
33
|
],
|
|
24
34
|
"sideEffects": [
|
|
25
35
|
"**/*.css"
|
|
@@ -30,11 +40,23 @@
|
|
|
30
40
|
"exports": {
|
|
31
41
|
".": {
|
|
32
42
|
"types": "./dist/index.d.ts",
|
|
33
|
-
"svelte": "./dist/index.js"
|
|
43
|
+
"svelte": "./dist/index.js",
|
|
44
|
+
"default": "./dist/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./kit": {
|
|
47
|
+
"types": "./dist/kit/index.d.ts",
|
|
48
|
+
"svelte": "./dist/kit/index.js",
|
|
49
|
+
"default": "./dist/kit/index.js"
|
|
34
50
|
}
|
|
35
51
|
},
|
|
36
52
|
"peerDependencies": {
|
|
37
|
-
"svelte": "^5.
|
|
53
|
+
"svelte": "^5.40.0",
|
|
54
|
+
"@sveltejs/kit": "^2.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@sveltejs/kit": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
38
60
|
},
|
|
39
61
|
"devDependencies": {
|
|
40
62
|
"@eslint/js": "^10.0.1",
|
|
@@ -64,6 +86,16 @@
|
|
|
64
86
|
"wrangler": "^4.97.0"
|
|
65
87
|
},
|
|
66
88
|
"keywords": [
|
|
67
|
-
"svelte"
|
|
68
|
-
|
|
89
|
+
"svelte",
|
|
90
|
+
"svelte5",
|
|
91
|
+
"sveltekit",
|
|
92
|
+
"runes",
|
|
93
|
+
"attachments",
|
|
94
|
+
"view-transitions",
|
|
95
|
+
"state-machine",
|
|
96
|
+
"utilities"
|
|
97
|
+
],
|
|
98
|
+
"dependencies": {
|
|
99
|
+
"esm-env": "^1.2.2"
|
|
100
|
+
}
|
|
69
101
|
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export default Welcome;
|
|
2
|
-
type Welcome = {
|
|
3
|
-
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
-
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
-
};
|
|
6
|
-
declare const Welcome: import("svelte").Component<{
|
|
7
|
-
host?: string;
|
|
8
|
-
guest?: string;
|
|
9
|
-
}, {}, "">;
|
|
10
|
-
type $$ComponentProps = {
|
|
11
|
-
host?: string;
|
|
12
|
-
guest?: string;
|
|
13
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function greet(name: string): string;
|