slot-text 0.1.2 → 0.3.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/README.md +106 -122
- package/dist/index.d.ts +19 -2
- package/dist/index.js +37 -2
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/slotText.d.ts +7 -0
- package/dist/slotText.js +28 -3
- package/dist/solid.d.ts +14 -0
- package/dist/solid.js +22 -0
- package/dist/svelte.d.ts +9 -0
- package/dist/svelte.js +16 -0
- package/dist/vue.d.ts +1 -1
- package/dist/vue.js +1 -1
- package/examples/basic/index.html +11 -8
- package/examples/env.d.ts +1 -0
- package/examples/solid.tsx +30 -0
- package/examples/svelte.svelte +27 -0
- package/examples/tsconfig.json +18 -0
- package/package.json +29 -3
- package/style.css.d.ts +2 -0
package/README.md
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
# slot-text
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p>
|
|
4
|
+
<a href="https://www.npmjs.com/package/slot-text"><img src="https://img.shields.io/npm/v/slot-text?color=cb3837&label=npm" alt="npm version"></a>
|
|
5
|
+
<img src="https://img.shields.io/badge/dependencies-zero-22c55e" alt="zero dependencies">
|
|
6
|
+
<img src="https://img.shields.io/bundlephobia/minzip/slot-text?color=8b5cf6&label=size" alt="bundle size">
|
|
7
|
+
<img src="https://img.shields.io/badge/react-✓-61dafb" alt="react">
|
|
8
|
+
<img src="https://img.shields.io/badge/vue-✓-42b883" alt="vue">
|
|
9
|
+
<img src="https://img.shields.io/badge/solid-✓-2c4f7c" alt="solid">
|
|
10
|
+
<img src="https://img.shields.io/badge/svelte-✓-ff3e00" alt="svelte">
|
|
11
|
+
<img src="https://img.shields.io/badge/typescript-✓-3178c6" alt="typescript">
|
|
12
|
+
</p>
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
Dependency-free text roll animation for tiny, tactile UI labels.
|
|
6
15
|
|
|
7
|
-
## Install
|
|
16
|
+
## 📦 Install
|
|
8
17
|
|
|
9
18
|
```bash
|
|
10
19
|
npm install slot-text
|
|
11
20
|
```
|
|
12
21
|
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
### Vanilla
|
|
22
|
+
## 🚀 Quick start
|
|
16
23
|
|
|
17
24
|
```ts
|
|
18
25
|
import "slot-text/style.css";
|
|
@@ -20,166 +27,143 @@ import { slotText, chromatic } from "slot-text";
|
|
|
20
27
|
|
|
21
28
|
const label = slotText(document.querySelector("#copy")!, "Copy");
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
// the classic Copy → Copied → Copy, in one call
|
|
31
|
+
label.flash("Copied", { enter: { color: chromatic() } });
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it — import the CSS once, attach to an element, roll text.
|
|
35
|
+
|
|
36
|
+
## 🧩 API
|
|
37
|
+
|
|
38
|
+
| Method | What it does |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `set(text, options?)` | Roll to new text — the text **stays** |
|
|
41
|
+
| `flash(text, options?)` | Roll in, then **auto-revert** to the previous text |
|
|
42
|
+
| `destroy()` | Clean up |
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const label = slotText(element, "Copy", options);
|
|
46
|
+
|
|
47
|
+
label.set("Copied"); // permanent change
|
|
48
|
+
label.set("Copy", { direction: "down" });
|
|
49
|
+
label.flash("Copied"); // temporary — rolls back after 1.4s
|
|
50
|
+
label.destroy();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> 💡 **`flash` is spam-safe** — repeat clicks restart the revert timer instead
|
|
54
|
+
> of queuing extra rolls, and an explicit `set()` cancels any pending revert.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
label.flash("Copied", {
|
|
58
|
+
revertAfter: 1400, // ms before rolling back
|
|
59
|
+
enter: { direction: "up", color: chromatic() }, // roll-in
|
|
60
|
+
exit: { direction: "down" }, // roll-back
|
|
26
61
|
});
|
|
27
62
|
```
|
|
28
63
|
|
|
29
|
-
|
|
64
|
+
## ⚛️ React
|
|
30
65
|
|
|
31
66
|
```tsx
|
|
32
67
|
import "slot-text/style.css";
|
|
33
68
|
import { SlotText } from "slot-text/react";
|
|
34
69
|
import { chromatic } from "slot-text";
|
|
35
70
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
options={{
|
|
41
|
-
direction: copied ? "up" : "down",
|
|
42
|
-
skipUnchanged: false,
|
|
43
|
-
color: copied ? chromatic() : undefined,
|
|
44
|
-
}}
|
|
45
|
-
/>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
71
|
+
<SlotText
|
|
72
|
+
text={copied ? "Copied" : "Copy"}
|
|
73
|
+
options={{ direction: copied ? "up" : "down", color: copied ? chromatic() : undefined }}
|
|
74
|
+
/>
|
|
48
75
|
```
|
|
49
76
|
|
|
50
|
-
|
|
77
|
+
## 💚 Vue
|
|
51
78
|
|
|
52
79
|
```vue
|
|
53
80
|
<script setup lang="ts">
|
|
54
81
|
import "slot-text/style.css";
|
|
55
82
|
import { SlotText } from "slot-text/vue";
|
|
56
|
-
import { chromatic } from "slot-text";
|
|
57
|
-
|
|
58
|
-
const options = {
|
|
59
|
-
direction: "up",
|
|
60
|
-
skipUnchanged: false,
|
|
61
|
-
color: chromatic(),
|
|
62
|
-
} as const;
|
|
63
83
|
</script>
|
|
64
84
|
|
|
65
85
|
<template>
|
|
66
|
-
<SlotText text="Copied" :options="
|
|
86
|
+
<SlotText text="Copied" :options="{ direction: 'up' }" />
|
|
67
87
|
</template>
|
|
68
88
|
```
|
|
69
89
|
|
|
70
|
-
##
|
|
71
|
-
|
|
72
|
-
Vanilla controller:
|
|
73
|
-
|
|
74
|
-
```ts
|
|
75
|
-
const label = slotText(element, "Copy", options);
|
|
76
|
-
|
|
77
|
-
label.set("Copied");
|
|
78
|
-
label.set("Copy", { direction: "down" });
|
|
79
|
-
label.destroy();
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Framework components:
|
|
90
|
+
## 🔷 Solid
|
|
83
91
|
|
|
84
|
-
```
|
|
85
|
-
import
|
|
86
|
-
import {
|
|
87
|
-
|
|
92
|
+
```tsx
|
|
93
|
+
import "slot-text/style.css";
|
|
94
|
+
import { createSignal } from "solid-js";
|
|
95
|
+
import { slotText } from "slot-text/solid";
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
const [label, setLabel] = createSignal("Copy");
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
animateSlotText,
|
|
95
|
-
chromatic,
|
|
96
|
-
} from "slot-text";
|
|
99
|
+
<button aria-label={label()} onClick={() => setLabel("Copied")}>
|
|
100
|
+
<span use:slotText={{ text: label(), options: { direction: "up" } }} />
|
|
101
|
+
</button>
|
|
97
102
|
```
|
|
98
103
|
|
|
99
|
-
##
|
|
104
|
+
## 🧡 Svelte
|
|
100
105
|
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
duration?: number;
|
|
106
|
-
exitOffset?: number;
|
|
107
|
-
easing?: string;
|
|
108
|
-
bounce?: number;
|
|
109
|
-
color?: string | ((index: number, total: number) => string);
|
|
110
|
-
colorFade?: number;
|
|
111
|
-
skipUnchanged?: boolean;
|
|
112
|
-
};
|
|
113
|
-
```
|
|
106
|
+
```svelte
|
|
107
|
+
<script lang="ts">
|
|
108
|
+
import "slot-text/style.css";
|
|
109
|
+
import { slotText } from "slot-text/svelte";
|
|
114
110
|
|
|
115
|
-
|
|
111
|
+
let label = "Copy";
|
|
112
|
+
</script>
|
|
116
113
|
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
stagger: 45,
|
|
121
|
-
duration: 300,
|
|
122
|
-
exitOffset: 50,
|
|
123
|
-
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
|
|
124
|
-
bounce: 0.6,
|
|
125
|
-
colorFade: 280,
|
|
126
|
-
skipUnchanged: true,
|
|
127
|
-
}
|
|
114
|
+
<button aria-label={label} on:click={() => label = "Copied"}>
|
|
115
|
+
<span use:slotText={{ text: label, options: { direction: "up" } }}></span>
|
|
116
|
+
</button>
|
|
128
117
|
```
|
|
129
118
|
|
|
130
|
-
##
|
|
119
|
+
## ⚙️ Options
|
|
131
120
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
121
|
+
| Option | Default | Description |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `direction` | `"down"` | Roll direction: `"up"` or `"down"` |
|
|
124
|
+
| `stagger` | `45` | Delay between characters (ms) |
|
|
125
|
+
| `duration` | `300` | Per-character animation time (ms) |
|
|
126
|
+
| `exitOffset` | `50` | Delay before the old character exits (ms) |
|
|
127
|
+
| `easing` | springy bezier | CSS easing function |
|
|
128
|
+
| `bounce` | `0.6` | Overshoot amount |
|
|
129
|
+
| `color` | — | Color string, or `(index, total) => string` |
|
|
130
|
+
| `colorFade` | `280` | Fade back to base color (ms) |
|
|
131
|
+
| `skipUnchanged` | `true` | Don't re-roll identical characters |
|
|
132
|
+
| `interrupt` | `true` | See below 👇 |
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
import "slot-text/style.css";
|
|
139
|
-
import { slotText } from "slot-text";
|
|
134
|
+
### 🛑 `interrupt`
|
|
140
135
|
|
|
141
|
-
|
|
136
|
+
- `interrupt: true` *(default)* — cuts off any roll in flight, starts fresh.
|
|
137
|
+
- `interrupt: false` — lets the current roll **finish**; the latest call plays
|
|
138
|
+
after it lands, duplicates are dropped. Ideal for spam-prone buttons.
|
|
142
139
|
|
|
143
|
-
|
|
144
|
-
label.set("Copied", { direction: "up", skipUnchanged: false });
|
|
145
|
-
window.setTimeout(() => label.set("Copy"), 1400);
|
|
146
|
-
});
|
|
147
|
-
</script>
|
|
148
|
-
```
|
|
140
|
+
### 🌈 `chromatic()`
|
|
149
141
|
|
|
150
|
-
|
|
142
|
+
Built-in rainbow color helper — pass it as `color` for a per-character hue sweep.
|
|
151
143
|
|
|
152
|
-
|
|
153
|
-
font you give the element — so widths are always correct.
|
|
144
|
+
## 🔤 Font support
|
|
154
145
|
|
|
155
|
-
|
|
146
|
+
Each character animates in its own measured cell using your element's exact
|
|
147
|
+
font, so widths are always correct.
|
|
156
148
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
including italics and glyphs with overhang.
|
|
149
|
+
✅ **Great with:** monospace fonts, proportional Latin / Cyrillic / Greek
|
|
150
|
+
(Geist, Inter, SF, …), italics, glyphs with overhang.
|
|
160
151
|
|
|
161
|
-
|
|
152
|
+
⚠️ **Tradeoffs** (inherent to any per-character slot animation):
|
|
162
153
|
|
|
163
|
-
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
-
|
|
167
|
-
-
|
|
168
|
-
scripts need contextual letterforms across the string and will render as
|
|
169
|
-
isolated forms.
|
|
170
|
-
- **Complex emoji split.** Single emoji are fine (surrogate pairs are
|
|
171
|
-
handled), but ZWJ sequences (👨👩👧) and combining marks break into
|
|
172
|
-
multiple cells.
|
|
173
|
-
- **Very tall display/script fonts** may clip at the vertical roll mask,
|
|
174
|
-
which is sized to `line-height: 1.3`.
|
|
154
|
+
- Kerning is lost — pairs like `AV` sit slightly looser (invisible at label sizes).
|
|
155
|
+
- Ligatures won't form (`fi`, `fl`, coding ligatures).
|
|
156
|
+
- Joined scripts (Arabic, Devanagari) render as isolated forms.
|
|
157
|
+
- ZWJ emoji sequences (👨👩👧) split into cells; single emoji are fine.
|
|
158
|
+
- Very tall display fonts may clip at the roll mask (`line-height: 1.3`).
|
|
175
159
|
|
|
176
|
-
In short
|
|
160
|
+
**In short:** ideal for short labels, numbers, statuses and commands — in
|
|
177
161
|
essentially any font you'd use for those.
|
|
178
162
|
|
|
179
|
-
## Notes
|
|
163
|
+
## 📝 Notes
|
|
180
164
|
|
|
181
|
-
- Browser-only DOM utility.
|
|
182
|
-
-
|
|
183
|
-
|
|
184
|
-
- Works best on short labels, buttons, counters, and command text.
|
|
165
|
+
- Browser-only DOM utility, zero runtime dependencies.
|
|
166
|
+
- React, Vue, Solid, and Svelte are optional peer dependencies — plain JS users
|
|
167
|
+
don't need them.
|
|
185
168
|
- Import the CSS once before using the animation.
|
|
169
|
+
- Low-level helpers also exported: `buildSlotText`, `animateSlotText`, `chromatic`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
export { animateSlotText, buildSlotText, chromatic, clearSlotText, type ChromaticOptions, type SlotOptions, } from "./slotText";
|
|
2
|
-
import { type SlotOptions } from "./slotText";
|
|
1
|
+
export { animateSlotText, buildSlotText, chromatic, clearSlotText, type ChromaticOptions, type SlotOptions, } from "./slotText.js";
|
|
2
|
+
import { type SlotOptions } from "./slotText.js";
|
|
3
|
+
export interface FlashOptions {
|
|
4
|
+
/** How long the flash text stays before rolling back, in ms (default 1400). */
|
|
5
|
+
revertAfter?: number;
|
|
6
|
+
/** Roll options for the flash text rolling in. */
|
|
7
|
+
enter?: SlotOptions;
|
|
8
|
+
/** Roll options for the original text rolling back. */
|
|
9
|
+
exit?: SlotOptions;
|
|
10
|
+
}
|
|
3
11
|
export interface SlotTextController {
|
|
4
12
|
readonly element: HTMLElement;
|
|
13
|
+
/** The text currently displayed (the flash text while a flash is showing). */
|
|
5
14
|
readonly value: string;
|
|
15
|
+
/** Roll to new text. Cancels any pending flash revert. */
|
|
6
16
|
set(text: string, options?: SlotOptions): void;
|
|
17
|
+
/**
|
|
18
|
+
* Roll to temporary text, then roll back automatically — the classic
|
|
19
|
+
* Copy → Copied → Copy button in one call. Spam-safe: repeat flashes restart
|
|
20
|
+
* the revert timer instead of queuing extra rolls.
|
|
21
|
+
*/
|
|
22
|
+
flash(text: string, options?: FlashOptions): void;
|
|
7
23
|
destroy(): void;
|
|
8
24
|
}
|
|
9
25
|
/**
|
|
@@ -13,5 +29,6 @@ export interface SlotTextController {
|
|
|
13
29
|
*
|
|
14
30
|
* const label = slotText(buttonLabel, "Copy");
|
|
15
31
|
* label.set("Copied", { direction: "up" });
|
|
32
|
+
* label.flash("Copied", { revertAfter: 1400 }); // auto-reverts to "Copy"
|
|
16
33
|
*/
|
|
17
34
|
export declare function slotText(element: HTMLElement, initialText: string, options?: SlotOptions): SlotTextController;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { animateSlotText, buildSlotText, chromatic, clearSlotText, } from "./slotText";
|
|
2
|
-
import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
|
|
1
|
+
export { animateSlotText, buildSlotText, chromatic, clearSlotText, } from "./slotText.js";
|
|
2
|
+
import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText.js";
|
|
3
3
|
/**
|
|
4
4
|
* Create a text-roll controller for one element.
|
|
5
5
|
*
|
|
@@ -7,9 +7,12 @@ import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
|
|
|
7
7
|
*
|
|
8
8
|
* const label = slotText(buttonLabel, "Copy");
|
|
9
9
|
* label.set("Copied", { direction: "up" });
|
|
10
|
+
* label.flash("Copied", { revertAfter: 1400 }); // auto-reverts to "Copy"
|
|
10
11
|
*/
|
|
11
12
|
export function slotText(element, initialText, options = {}) {
|
|
12
13
|
let value = initialText;
|
|
14
|
+
let revertTimeout;
|
|
15
|
+
let restingText;
|
|
13
16
|
buildSlotText(element, initialText);
|
|
14
17
|
return {
|
|
15
18
|
element,
|
|
@@ -17,10 +20,42 @@ export function slotText(element, initialText, options = {}) {
|
|
|
17
20
|
return value;
|
|
18
21
|
},
|
|
19
22
|
set(text, nextOptions = {}) {
|
|
23
|
+
// An explicit set wins over a pending flash revert.
|
|
24
|
+
clearTimeout(revertTimeout);
|
|
25
|
+
restingText = undefined;
|
|
20
26
|
value = text;
|
|
21
27
|
animateSlotText(element, text, { ...options, ...nextOptions });
|
|
22
28
|
},
|
|
29
|
+
flash(text, { revertAfter = 1400, enter, exit } = {}) {
|
|
30
|
+
// Capture the resting text only on the first flash of a burst, so a
|
|
31
|
+
// flash-during-flash still reverts to the original label.
|
|
32
|
+
if (restingText === undefined) {
|
|
33
|
+
restingText = value;
|
|
34
|
+
}
|
|
35
|
+
// Flashes default to non-interrupting rolls: spam-friendly, no mid-roll
|
|
36
|
+
// cutoffs. Callers can still override via `enter`/`exit`.
|
|
37
|
+
value = text;
|
|
38
|
+
animateSlotText(element, text, {
|
|
39
|
+
...options,
|
|
40
|
+
interrupt: false,
|
|
41
|
+
...enter,
|
|
42
|
+
});
|
|
43
|
+
// Restart the revert timer: one revert per burst, after the last flash.
|
|
44
|
+
clearTimeout(revertTimeout);
|
|
45
|
+
revertTimeout = window.setTimeout(() => {
|
|
46
|
+
const back = restingText;
|
|
47
|
+
restingText = undefined;
|
|
48
|
+
revertTimeout = undefined;
|
|
49
|
+
value = back;
|
|
50
|
+
animateSlotText(element, back, {
|
|
51
|
+
...options,
|
|
52
|
+
interrupt: false,
|
|
53
|
+
...exit,
|
|
54
|
+
});
|
|
55
|
+
}, revertAfter);
|
|
56
|
+
},
|
|
23
57
|
destroy() {
|
|
58
|
+
clearTimeout(revertTimeout);
|
|
24
59
|
clearSlotText(element, value);
|
|
25
60
|
},
|
|
26
61
|
};
|
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type HTMLAttributes } from "react";
|
|
2
|
-
import { type SlotOptions } from "./slotText";
|
|
2
|
+
import { type SlotOptions } from "./slotText.js";
|
|
3
3
|
export interface SlotTextProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
4
4
|
text: string;
|
|
5
5
|
options?: SlotOptions;
|
package/dist/react.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createElement, forwardRef, useEffect, useImperativeHandle, useRef, } from "react";
|
|
2
|
-
import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
|
|
2
|
+
import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText.js";
|
|
3
3
|
export const SlotText = forwardRef(({ text, options, "aria-label": ariaLabel, ...props }, forwardedRef) => {
|
|
4
4
|
const elementRef = useRef(null);
|
|
5
5
|
const mountedRef = useRef(false);
|
package/dist/slotText.d.ts
CHANGED
|
@@ -44,6 +44,13 @@ export interface SlotOptions {
|
|
|
44
44
|
* uniformly instead of leaving stray letters frozen.
|
|
45
45
|
*/
|
|
46
46
|
skipUnchanged?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* true (default): a new call interrupts any roll in flight, snapping it to
|
|
49
|
+
* its target before starting fresh. false: the current roll finishes and the
|
|
50
|
+
* latest call made mid-roll plays after it lands; calls targeting the text
|
|
51
|
+
* already displayed are dropped. Ideal for spam-prone triggers like buttons.
|
|
52
|
+
*/
|
|
53
|
+
interrupt?: boolean;
|
|
47
54
|
}
|
|
48
55
|
export interface ChromaticOptions {
|
|
49
56
|
from?: number;
|
package/dist/slotText.js
CHANGED
|
@@ -20,6 +20,7 @@ const DEFAULTS = {
|
|
|
20
20
|
bounce: 0.6,
|
|
21
21
|
colorFade: 280,
|
|
22
22
|
skipUnchanged: true,
|
|
23
|
+
interrupt: true,
|
|
23
24
|
};
|
|
24
25
|
const NBSP = "\u00A0";
|
|
25
26
|
const glyph = (char) => (char === " " ? NBSP : char);
|
|
@@ -71,10 +72,20 @@ export function buildSlotText(container, text) {
|
|
|
71
72
|
container.replaceChildren(...Array.from(text, buildSlot));
|
|
72
73
|
}
|
|
73
74
|
export function animateSlotText(container, toText, options = {}) {
|
|
74
|
-
const { direction, stagger, duration, exitOffset, easing, bounce, color, colorFade, skipUnchanged, } = {
|
|
75
|
+
const { direction, stagger, duration, exitOffset, easing, bounce, color, colorFade, skipUnchanged, interrupt, } = {
|
|
75
76
|
...DEFAULTS,
|
|
76
77
|
...options,
|
|
77
78
|
};
|
|
79
|
+
// Non-interrupting mode: if a roll is already in flight, let it finish and
|
|
80
|
+
// remember this request instead. Only the latest request survives, so spam
|
|
81
|
+
// taps coalesce into a single follow-up roll once the current one lands.
|
|
82
|
+
const running = states.get(container);
|
|
83
|
+
if (running && !interrupt) {
|
|
84
|
+
if (toText !== running.target) {
|
|
85
|
+
running.pending = { text: toText, options };
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
78
89
|
// Interrupt: if a previous roll is still running, fast-forward it to its
|
|
79
90
|
// target and tear down its timers before we start fresh. This is what kills
|
|
80
91
|
// the "switch bun→npm mid-animation" glitch.
|
|
@@ -86,6 +97,10 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
86
97
|
}
|
|
87
98
|
const slots = Array.from(container.querySelectorAll(".char-slot"));
|
|
88
99
|
const fromText = slots.map((s) => s.dataset.char ?? "").join("");
|
|
100
|
+
// Non-interrupting mode also drops rolls to the text already on screen, so
|
|
101
|
+
// repeated triggers do not visibly re-roll an unchanged label.
|
|
102
|
+
if (!interrupt && fromText === toText)
|
|
103
|
+
return;
|
|
89
104
|
const maxLen = Math.max(fromText.length, toText.length);
|
|
90
105
|
// Whole-pixel slide distance = one cell height, so glyphs clip cleanly.
|
|
91
106
|
// If layout has not produced dimensions yet, fall back to line-height/font-size
|
|
@@ -98,7 +113,9 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
98
113
|
sample?.offsetHeight ||
|
|
99
114
|
container.getBoundingClientRect().height ||
|
|
100
115
|
parseFloat(cs.lineHeight) ||
|
|
101
|
-
0) ||
|
|
116
|
+
0) ||
|
|
117
|
+
Math.ceil(parseFloat(cs.fontSize) * 1.3) ||
|
|
118
|
+
18;
|
|
102
119
|
// Resting color to settle the chromatic flash back to.
|
|
103
120
|
const restColor = color ? cs.color : "";
|
|
104
121
|
// Pre-create any extra cells up front so the row never reflows mid-roll.
|
|
@@ -160,7 +177,9 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
160
177
|
const base = Math.round(staggerIndex * stagger * (1 + bounce * 0.25 * wobble(i, 2)));
|
|
161
178
|
const tilt = (bounce * 5 * wobble(i, 3)).toFixed(2);
|
|
162
179
|
const rollTrans = `transform ${d}ms ${easing}`;
|
|
163
|
-
const trans = color
|
|
180
|
+
const trans = color
|
|
181
|
+
? `${rollTrans}, color ${colorFade}ms linear ${d}ms`
|
|
182
|
+
: rollTrans;
|
|
164
183
|
const newFace = makeFace(toChar);
|
|
165
184
|
newFace.style.transformOrigin = "50% 50%";
|
|
166
185
|
newFace.style.transform = `translateY(${inStart}px) rotate(${tilt}deg)`;
|
|
@@ -228,10 +247,16 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
228
247
|
}, base + exitOffset));
|
|
229
248
|
}
|
|
230
249
|
// Safety net: snap to a pristine DOM once the slowest letter has settled.
|
|
250
|
+
// If a non-interrupting call was deferred mid-roll, replay it now — it runs
|
|
251
|
+
// as a fresh roll from this clean baseline.
|
|
231
252
|
const total = maxEnd + 80;
|
|
232
253
|
timers.push(window.setTimeout(() => {
|
|
254
|
+
const pending = state.pending;
|
|
233
255
|
states.delete(container);
|
|
234
256
|
buildSlotText(container, toText);
|
|
257
|
+
if (pending) {
|
|
258
|
+
animateSlotText(container, pending.text, pending.options);
|
|
259
|
+
}
|
|
235
260
|
}, total));
|
|
236
261
|
}
|
|
237
262
|
export function clearSlotText(container, text = "") {
|
package/dist/solid.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Accessor } from "solid-js";
|
|
2
|
+
import { type SlotOptions } from "./index.js";
|
|
3
|
+
export interface SlotTextParams {
|
|
4
|
+
text: string;
|
|
5
|
+
options?: SlotOptions;
|
|
6
|
+
}
|
|
7
|
+
export declare function slotText(element: HTMLElement, accessor: Accessor<SlotTextParams>): void;
|
|
8
|
+
declare module "solid-js" {
|
|
9
|
+
namespace JSX {
|
|
10
|
+
interface Directives {
|
|
11
|
+
slotText: SlotTextParams;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/solid.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createRenderEffect, onCleanup } from "solid-js";
|
|
2
|
+
import { slotText as createSlotText } from "./index.js";
|
|
3
|
+
export function slotText(element, accessor) {
|
|
4
|
+
const initial = accessor();
|
|
5
|
+
const controller = createSlotText(element, initial.text);
|
|
6
|
+
let previousText = initial.text;
|
|
7
|
+
let isFirstRun = true;
|
|
8
|
+
createRenderEffect(() => {
|
|
9
|
+
const next = accessor();
|
|
10
|
+
if (isFirstRun) {
|
|
11
|
+
isFirstRun = false;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (next.text !== previousText) {
|
|
15
|
+
previousText = next.text;
|
|
16
|
+
controller.set(next.text, next.options);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
onCleanup(() => {
|
|
20
|
+
controller.destroy();
|
|
21
|
+
});
|
|
22
|
+
}
|
package/dist/svelte.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SlotOptions } from "./index.js";
|
|
2
|
+
export interface SlotTextParams {
|
|
3
|
+
text: string;
|
|
4
|
+
options?: SlotOptions;
|
|
5
|
+
}
|
|
6
|
+
export declare function slotText(element: HTMLElement, params: SlotTextParams): {
|
|
7
|
+
update(params: SlotTextParams): void;
|
|
8
|
+
destroy(): void;
|
|
9
|
+
};
|
package/dist/svelte.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { slotText as createSlotText } from "./index.js";
|
|
2
|
+
export function slotText(element, params) {
|
|
3
|
+
const controller = createSlotText(element, params.text);
|
|
4
|
+
let previousText = params.text;
|
|
5
|
+
return {
|
|
6
|
+
update(params) {
|
|
7
|
+
if (params.text === previousText)
|
|
8
|
+
return;
|
|
9
|
+
previousText = params.text;
|
|
10
|
+
controller.set(params.text, params.options);
|
|
11
|
+
},
|
|
12
|
+
destroy() {
|
|
13
|
+
controller.destroy();
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/vue.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type PropType } from "vue";
|
|
2
|
-
import { type SlotOptions } from "./slotText";
|
|
2
|
+
import { type SlotOptions } from "./slotText.js";
|
|
3
3
|
export declare const SlotText: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
4
4
|
text: {
|
|
5
5
|
type: StringConstructor;
|
package/dist/vue.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, } from "vue";
|
|
2
|
-
import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
|
|
2
|
+
import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText.js";
|
|
3
3
|
export const SlotText = defineComponent({
|
|
4
4
|
name: "SlotText",
|
|
5
5
|
inheritAttrs: false,
|
|
@@ -37,15 +37,18 @@
|
|
|
37
37
|
const label = slotText(document.querySelector("#copy-label"), "Copy");
|
|
38
38
|
|
|
39
39
|
button.addEventListener("click", () => {
|
|
40
|
-
label.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
label.flash("Copied", {
|
|
41
|
+
enter: {
|
|
42
|
+
direction: "up",
|
|
43
|
+
skipUnchanged: false,
|
|
44
|
+
color: chromatic({ from: 190 }),
|
|
45
|
+
},
|
|
46
|
+
exit: {
|
|
47
|
+
direction: "down",
|
|
48
|
+
skipUnchanged: false,
|
|
49
|
+
color: chromatic({ from: 190 }),
|
|
50
|
+
},
|
|
44
51
|
});
|
|
45
|
-
|
|
46
|
-
window.setTimeout(() => {
|
|
47
|
-
label.set("Copy", { direction: "down", skipUnchanged: false });
|
|
48
|
-
}, 1400);
|
|
49
52
|
});
|
|
50
53
|
</script>
|
|
51
54
|
</body>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "slot-text/style.css";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import "slot-text/style.css";
|
|
3
|
+
import { chromatic } from "slot-text";
|
|
4
|
+
import { slotText } from "slot-text/solid";
|
|
5
|
+
|
|
6
|
+
void slotText;
|
|
7
|
+
|
|
8
|
+
export function CopyButton() {
|
|
9
|
+
const [copied, setCopied] = createSignal(false);
|
|
10
|
+
|
|
11
|
+
function copy() {
|
|
12
|
+
setCopied(true);
|
|
13
|
+
window.setTimeout(() => setCopied(false), 1400);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<button type="button" aria-label={copied() ? "Copied" : "Copy"} onClick={copy}>
|
|
18
|
+
<span
|
|
19
|
+
use:slotText={{
|
|
20
|
+
text: copied() ? "Copied" : "Copy",
|
|
21
|
+
options: {
|
|
22
|
+
direction: copied() ? "up" : "down",
|
|
23
|
+
skipUnchanged: false,
|
|
24
|
+
color: copied() ? chromatic({ from: 190 }) : undefined,
|
|
25
|
+
},
|
|
26
|
+
}}
|
|
27
|
+
/>
|
|
28
|
+
</button>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import "slot-text/style.css";
|
|
3
|
+
import { chromatic } from "slot-text";
|
|
4
|
+
import { slotText } from "slot-text/svelte";
|
|
5
|
+
|
|
6
|
+
let copied = false;
|
|
7
|
+
|
|
8
|
+
function copy() {
|
|
9
|
+
copied = true;
|
|
10
|
+
window.setTimeout(() => {
|
|
11
|
+
copied = false;
|
|
12
|
+
}, 1400);
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<button type="button" aria-label={copied ? "Copied" : "Copy"} on:click={copy}>
|
|
17
|
+
<span
|
|
18
|
+
use:slotText={{
|
|
19
|
+
text: copied ? "Copied" : "Copy",
|
|
20
|
+
options: {
|
|
21
|
+
direction: copied ? "up" : "down",
|
|
22
|
+
skipUnchanged: false,
|
|
23
|
+
color: copied ? chromatic({ from: 190 }) : undefined,
|
|
24
|
+
},
|
|
25
|
+
}}
|
|
26
|
+
></span>
|
|
27
|
+
</button>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ES2020",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2020", "DOM"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"jsx": "preserve",
|
|
10
|
+
"jsxImportSource": "solid-js",
|
|
11
|
+
"paths": {
|
|
12
|
+
"slot-text": ["../src/index.ts"],
|
|
13
|
+
"slot-text/solid": ["../src/solid.ts"],
|
|
14
|
+
"slot-text/svelte": ["../src/svelte.ts"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"include": ["env.d.ts", "solid.tsx", "*.svelte", "../src/**/*.ts"]
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slot-text",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Dependency-free text roll animation for tiny, tactile UI labels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"dist",
|
|
14
14
|
"examples",
|
|
15
15
|
"style.css",
|
|
16
|
+
"style.css.d.ts",
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
@@ -29,11 +30,24 @@
|
|
|
29
30
|
"types": "./dist/vue.d.ts",
|
|
30
31
|
"import": "./dist/vue.js"
|
|
31
32
|
},
|
|
32
|
-
"./
|
|
33
|
+
"./solid": {
|
|
34
|
+
"types": "./dist/solid.d.ts",
|
|
35
|
+
"import": "./dist/solid.js"
|
|
36
|
+
},
|
|
37
|
+
"./svelte": {
|
|
38
|
+
"types": "./dist/svelte.d.ts",
|
|
39
|
+
"import": "./dist/svelte.js"
|
|
40
|
+
},
|
|
41
|
+
"./style.css": {
|
|
42
|
+
"types": "./style.css.d.ts",
|
|
43
|
+
"default": "./style.css"
|
|
44
|
+
}
|
|
33
45
|
},
|
|
34
46
|
"types": "./dist/index.d.ts",
|
|
35
47
|
"peerDependencies": {
|
|
36
48
|
"react": ">=18 <20",
|
|
49
|
+
"solid-js": ">=1.8 <2",
|
|
50
|
+
"svelte": ">=4 <6",
|
|
37
51
|
"vue": ">=3.4 <4"
|
|
38
52
|
},
|
|
39
53
|
"peerDependenciesMeta": {
|
|
@@ -42,17 +56,29 @@
|
|
|
42
56
|
},
|
|
43
57
|
"vue": {
|
|
44
58
|
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"solid-js": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"svelte": {
|
|
64
|
+
"optional": true
|
|
45
65
|
}
|
|
46
66
|
},
|
|
47
67
|
"scripts": {
|
|
68
|
+
"prepublishOnly": "npm run build",
|
|
48
69
|
"build": "tsc -p tsconfig.json",
|
|
49
70
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
50
|
-
"pack:check": "npm pack --dry-run"
|
|
71
|
+
"pack:check": "npm pack --dry-run",
|
|
72
|
+
"test": "vitest run"
|
|
51
73
|
},
|
|
52
74
|
"devDependencies": {
|
|
53
75
|
"@types/react": "^19.2.17",
|
|
76
|
+
"happy-dom": "^20.10.2",
|
|
54
77
|
"react": "^19.2.7",
|
|
78
|
+
"solid-js": "^1.9.13",
|
|
79
|
+
"svelte": "^5.56.3",
|
|
55
80
|
"typescript": "^5.8.3",
|
|
81
|
+
"vitest": "^4.1.8",
|
|
56
82
|
"vue": "^3.5.35"
|
|
57
83
|
}
|
|
58
84
|
}
|
package/style.css.d.ts
ADDED