slot-text 0.1.1 → 0.2.2

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 CHANGED
@@ -76,9 +76,23 @@ const label = slotText(element, "Copy", options);
76
76
 
77
77
  label.set("Copied");
78
78
  label.set("Copy", { direction: "down" });
79
+ label.flash("Copied"); // rolls in, then auto-reverts to "Copy"
79
80
  label.destroy();
80
81
  ```
81
82
 
83
+ `flash(text, options)` shows temporary text and rolls back automatically —
84
+ the classic Copy → Copied → Copy button in one call. It is spam-safe: repeat
85
+ flashes restart the revert timer instead of queuing extra rolls, and an
86
+ explicit `set()` cancels any pending revert.
87
+
88
+ ```ts
89
+ label.flash("Copied", {
90
+ revertAfter: 1400, // ms before rolling back (default 1400)
91
+ enter: { direction: "up", color: chromatic() }, // roll-in options
92
+ exit: { direction: "down" }, // roll-back options
93
+ });
94
+ ```
95
+
82
96
  Framework components:
83
97
 
84
98
  ```ts
@@ -109,9 +123,15 @@ type SlotOptions = {
109
123
  color?: string | ((index: number, total: number) => string);
110
124
  colorFade?: number;
111
125
  skipUnchanged?: boolean;
126
+ interrupt?: boolean;
112
127
  };
113
128
  ```
114
129
 
130
+ `interrupt: true` (default) cuts off any roll still in flight and starts
131
+ fresh. `interrupt: false` lets the current roll finish: the latest call made
132
+ mid-roll plays once it lands, and calls targeting the text already displayed
133
+ are dropped — ideal for spam-prone triggers like buttons.
134
+
115
135
  Defaults are tuned for a soft, springy roll:
116
136
 
117
137
  ```ts
@@ -124,6 +144,7 @@ Defaults are tuned for a soft, springy roll:
124
144
  bounce: 0.6,
125
145
  colorFade: 280,
126
146
  skipUnchanged: true,
147
+ interrupt: true,
127
148
  }
128
149
  ```
129
150
 
@@ -136,13 +157,15 @@ Defaults are tuned for a soft, springy roll:
136
157
 
137
158
  <script type="module">
138
159
  import "slot-text/style.css";
139
- import { slotText } from "slot-text";
160
+ import { slotText, chromatic } from "slot-text";
140
161
 
141
162
  const label = slotText(document.querySelector("#copy-label"), "Copy");
142
163
 
143
164
  document.querySelector("button").addEventListener("click", () => {
144
- label.set("Copied", { direction: "up", skipUnchanged: false });
145
- window.setTimeout(() => label.set("Copy"), 1400);
165
+ label.flash("Copied", {
166
+ enter: { direction: "up", skipUnchanged: false, color: chromatic() },
167
+ exit: { direction: "down", skipUnchanged: false },
168
+ });
146
169
  });
147
170
  </script>
148
171
  ```
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);
@@ -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) || Math.ceil(parseFloat(cs.fontSize) * 1.3) || 18;
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 ? `${rollTrans}, color ${colorFade}ms linear ${d}ms` : rollTrans;
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/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.set("Copied", {
41
- direction: "up",
42
- skipUnchanged: false,
43
- color: chromatic({ from: 190 }),
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slot-text",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
4
4
  "description": "Dependency-free text roll animation for tiny, tactile UI labels.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/style.css CHANGED
@@ -6,6 +6,11 @@
6
6
  .char-slot {
7
7
  position: relative;
8
8
  display: inline-flex;
9
+ /* Cells must never flex-shrink. When a cell's overflow stops being visible
10
+ (e.g. .is-resizing), its automatic minimum size drops to 0, and a
11
+ width-constrained line would crush those cells into overlapping slivers
12
+ instead of letting the row overflow like it does at rest. */
13
+ flex: none;
9
14
  justify-content: center;
10
15
  /* Clip only vertically: the roll needs a top/bottom mask, but glyph side
11
16
  bearings, kerning overhang and the settle tilt must stay visible so