slot-text 0.3.1 โ 0.3.3
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 +37 -1
- package/dist/constants.d.ts +56 -0
- package/dist/constants.js +56 -0
- package/dist/dom.d.ts +40 -0
- package/dist/dom.js +209 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +31 -30
- package/dist/react.js +2 -2
- package/dist/slotText.d.ts +23 -37
- package/dist/slotText.js +100 -248
- package/dist/text.d.ts +4 -0
- package/dist/text.js +14 -0
- package/dist/timing.d.ts +25 -0
- package/dist/timing.js +68 -0
- package/dist/vue.js +2 -2
- package/examples/basic/index.html +46 -7
- package/examples/css-race/index.html +39 -0
- package/package.json +1 -1
- package/style.css +2 -1
package/README.md
CHANGED
|
@@ -15,10 +15,32 @@ Dependency-free text roll animation for tiny, tactile UI labels.
|
|
|
15
15
|
|
|
16
16
|
## ๐ฆ Install
|
|
17
17
|
|
|
18
|
+
Choose your preferred package manager:
|
|
19
|
+
|
|
20
|
+
**npm**
|
|
21
|
+
|
|
18
22
|
```bash
|
|
19
23
|
npm install slot-text
|
|
20
24
|
```
|
|
21
25
|
|
|
26
|
+
**pnpm**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add slot-text
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Bun**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun add slot-text
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Yarn**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
yarn add slot-text
|
|
42
|
+
```
|
|
43
|
+
|
|
22
44
|
## ๐ Quick start
|
|
23
45
|
|
|
24
46
|
```ts
|
|
@@ -33,6 +55,17 @@ label.flash("Copied", { enter: { color: chromatic() } });
|
|
|
33
55
|
|
|
34
56
|
That's it โ import the CSS once, attach to an element, roll text.
|
|
35
57
|
|
|
58
|
+
## ๐งช Try it locally
|
|
59
|
+
|
|
60
|
+
The included playground has one button that flashes `Copy` โ `Copied` โ `Copy`.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm run build
|
|
64
|
+
python3 -m http.server
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Open [http://localhost:8000/examples/basic/](http://localhost:8000/examples/basic/).
|
|
68
|
+
|
|
36
69
|
## ๐งฉ API
|
|
37
70
|
|
|
38
71
|
| Method | What it does |
|
|
@@ -154,7 +187,8 @@ font, so widths are always correct.
|
|
|
154
187
|
- Kerning is lost โ pairs like `AV` sit slightly looser (invisible at label sizes).
|
|
155
188
|
- Ligatures won't form (`fi`, `fl`, coding ligatures).
|
|
156
189
|
- Joined scripts (Arabic, Devanagari) render as isolated forms.
|
|
157
|
-
-
|
|
190
|
+
- Grapheme clusters such as combining marks and ZWJ emoji stay together when
|
|
191
|
+
`Intl.Segmenter` is available; older browsers fall back to Unicode code points.
|
|
158
192
|
- Very tall display fonts may clip at the roll mask (`line-height: 1.3`).
|
|
159
193
|
|
|
160
194
|
**In short:** ideal for short labels, numbers, statuses and commands โ in
|
|
@@ -166,4 +200,6 @@ essentially any font you'd use for those.
|
|
|
166
200
|
- React, Vue, Solid, and Svelte are optional peer dependencies โ plain JS users
|
|
167
201
|
don't need them.
|
|
168
202
|
- Import the CSS once before using the animation.
|
|
203
|
+
- If the stylesheet loads late, labels stay as plain readable text and upgrade
|
|
204
|
+
to slot animation once the required layout styles become available.
|
|
169
205
|
- Low-level helpers also exported: `buildSlotText`, `animateSlotText`, `chromatic`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export declare const TUNING: {
|
|
2
|
+
readonly animation: {
|
|
3
|
+
readonly direction: "down";
|
|
4
|
+
readonly stagger: 45;
|
|
5
|
+
readonly duration: 300;
|
|
6
|
+
readonly exitOffset: 50;
|
|
7
|
+
readonly easing: "cubic-bezier(0.34, 1.56, 0.64, 1)";
|
|
8
|
+
readonly bounce: 0.6;
|
|
9
|
+
readonly colorFade: 280;
|
|
10
|
+
readonly skipUnchanged: true;
|
|
11
|
+
readonly interrupt: true;
|
|
12
|
+
};
|
|
13
|
+
readonly variation: {
|
|
14
|
+
readonly indexFrequency: 12.9898;
|
|
15
|
+
readonly channelFrequency: 78.233;
|
|
16
|
+
readonly seedScale: 43758.5453;
|
|
17
|
+
readonly durationChannel: 1;
|
|
18
|
+
readonly staggerChannel: 2;
|
|
19
|
+
readonly tiltChannel: 3;
|
|
20
|
+
};
|
|
21
|
+
readonly glyph: {
|
|
22
|
+
readonly tailDurationMultiplier: 0.75;
|
|
23
|
+
readonly durationVariationStrength: 0.45;
|
|
24
|
+
readonly tailStaggerStartMultiplier: 0.5;
|
|
25
|
+
readonly tailStaggerStepMultiplier: 0.25;
|
|
26
|
+
readonly staggerVariationStrength: 0.25;
|
|
27
|
+
readonly maximumTiltDegrees: 5;
|
|
28
|
+
readonly tiltDecimalPlaces: 2;
|
|
29
|
+
};
|
|
30
|
+
readonly width: {
|
|
31
|
+
readonly tailCollapseDelayMultiplier: 0.55;
|
|
32
|
+
readonly tailCollapseDurationMultiplier: 0.6;
|
|
33
|
+
readonly newSlotExpansionDurationMultiplier: 0.45;
|
|
34
|
+
readonly minimumTransitionMs: 140;
|
|
35
|
+
readonly resizeEasing: "cubic-bezier(0.2, 0, 0, 1)";
|
|
36
|
+
readonly minimumVisibleChangePx: 0.5;
|
|
37
|
+
};
|
|
38
|
+
readonly layout: {
|
|
39
|
+
readonly probeOffscreenPositionPx: -9999;
|
|
40
|
+
readonly fallbackLineHeightMultiplier: 1.3;
|
|
41
|
+
readonly fallbackCharacterHeightPx: 18;
|
|
42
|
+
readonly characterTransformOrigin: "50% 50%";
|
|
43
|
+
readonly characterRestTransform: "translateY(0) rotate(0deg)";
|
|
44
|
+
};
|
|
45
|
+
readonly chromatic: {
|
|
46
|
+
readonly hueStartDegrees: 0;
|
|
47
|
+
readonly hueSpreadDegrees: 320;
|
|
48
|
+
readonly saturationPercent: 92;
|
|
49
|
+
readonly lightnessPercent: 60;
|
|
50
|
+
readonly fullHueRotationDegrees: 360;
|
|
51
|
+
};
|
|
52
|
+
readonly lifecycle: {
|
|
53
|
+
readonly completionBufferMs: 80;
|
|
54
|
+
readonly flashRevertDelayMs: 1400;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const TUNING = {
|
|
2
|
+
animation: {
|
|
3
|
+
direction: "down",
|
|
4
|
+
stagger: 45,
|
|
5
|
+
duration: 300,
|
|
6
|
+
exitOffset: 50,
|
|
7
|
+
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
|
|
8
|
+
bounce: 0.6,
|
|
9
|
+
colorFade: 280,
|
|
10
|
+
skipUnchanged: true,
|
|
11
|
+
interrupt: true,
|
|
12
|
+
},
|
|
13
|
+
variation: {
|
|
14
|
+
indexFrequency: 12.9898,
|
|
15
|
+
channelFrequency: 78.233,
|
|
16
|
+
seedScale: 43758.5453,
|
|
17
|
+
durationChannel: 1,
|
|
18
|
+
staggerChannel: 2,
|
|
19
|
+
tiltChannel: 3,
|
|
20
|
+
},
|
|
21
|
+
glyph: {
|
|
22
|
+
tailDurationMultiplier: 0.75,
|
|
23
|
+
durationVariationStrength: 0.45,
|
|
24
|
+
tailStaggerStartMultiplier: 0.5,
|
|
25
|
+
tailStaggerStepMultiplier: 0.25,
|
|
26
|
+
staggerVariationStrength: 0.25,
|
|
27
|
+
maximumTiltDegrees: 5,
|
|
28
|
+
tiltDecimalPlaces: 2,
|
|
29
|
+
},
|
|
30
|
+
width: {
|
|
31
|
+
tailCollapseDelayMultiplier: 0.55,
|
|
32
|
+
tailCollapseDurationMultiplier: 0.6,
|
|
33
|
+
newSlotExpansionDurationMultiplier: 0.45,
|
|
34
|
+
minimumTransitionMs: 140,
|
|
35
|
+
resizeEasing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
36
|
+
minimumVisibleChangePx: 0.5,
|
|
37
|
+
},
|
|
38
|
+
layout: {
|
|
39
|
+
probeOffscreenPositionPx: -9999,
|
|
40
|
+
fallbackLineHeightMultiplier: 1.3,
|
|
41
|
+
fallbackCharacterHeightPx: 18,
|
|
42
|
+
characterTransformOrigin: "50% 50%",
|
|
43
|
+
characterRestTransform: "translateY(0) rotate(0deg)",
|
|
44
|
+
},
|
|
45
|
+
chromatic: {
|
|
46
|
+
hueStartDegrees: 0,
|
|
47
|
+
hueSpreadDegrees: 320,
|
|
48
|
+
saturationPercent: 92,
|
|
49
|
+
lightnessPercent: 60,
|
|
50
|
+
fullHueRotationDegrees: 360,
|
|
51
|
+
},
|
|
52
|
+
lifecycle: {
|
|
53
|
+
completionBufferMs: 80,
|
|
54
|
+
flashRevertDelayMs: 1400,
|
|
55
|
+
},
|
|
56
|
+
};
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ResolvedSlotOptions } from "./timing.js";
|
|
2
|
+
/** Internal test seam; not re-exported from the package. */
|
|
3
|
+
export declare function resetSlotLayoutCache(): void;
|
|
4
|
+
interface CharacterSlotElements {
|
|
5
|
+
slot: HTMLElement;
|
|
6
|
+
sizer: HTMLElement;
|
|
7
|
+
previousFace: HTMLElement | null;
|
|
8
|
+
}
|
|
9
|
+
interface SlotMeasurement extends CharacterSlotElements {
|
|
10
|
+
index: number;
|
|
11
|
+
currentSegment: string;
|
|
12
|
+
targetSegment: string;
|
|
13
|
+
currentWidth: number;
|
|
14
|
+
targetWidth: number;
|
|
15
|
+
widthWillChange: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Render slot markup without touching animation lifecycle state. */
|
|
18
|
+
export declare function renderCharacterSlots(container: HTMLElement, text: string): void;
|
|
19
|
+
export declare function renderPlainText(container: HTMLElement, text: string): void;
|
|
20
|
+
export declare function getCharacterSlots(container: HTMLElement): HTMLElement[];
|
|
21
|
+
export declare function isSlotLayoutReady(slot: HTMLElement | undefined): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Check the globally imported stylesheet contract. A successful check is
|
|
24
|
+
* cached because an applied stylesheet does not normally disappear. A failed
|
|
25
|
+
* check is retried so asynchronously loaded CSS can upgrade plain text later.
|
|
26
|
+
*/
|
|
27
|
+
export declare function canRenderSlotLayout(): boolean;
|
|
28
|
+
export declare function addMissingCharacterSlots(container: HTMLElement, characterSlots: HTMLElement[], requiredSlotCount: number): void;
|
|
29
|
+
export declare function measureCharacterHeight(container: HTMLElement, characterSlots: HTMLElement[], containerStyle: CSSStyleDeclaration): number;
|
|
30
|
+
/** Measure all old widths, apply all target sizers, then read all new widths. */
|
|
31
|
+
export declare function measureChangedSlots(characterSlots: HTMLElement[], currentSegments: string[], targetSegments: string[], skipUnchanged: boolean): SlotMeasurement[];
|
|
32
|
+
export declare function prepareSlotAnimation(measurement: SlotMeasurement, targetSegmentCount: number, characterHeight: number, options: ResolvedSlotOptions): {
|
|
33
|
+
measurement: SlotMeasurement;
|
|
34
|
+
timing: import("./timing.js").GlyphTiming;
|
|
35
|
+
widthTiming: import("./timing.js").WidthTiming;
|
|
36
|
+
incomingFace: HTMLSpanElement;
|
|
37
|
+
completionTimeMs: number;
|
|
38
|
+
};
|
|
39
|
+
export declare function scheduleSlotAnimation(preparedAnimation: ReturnType<typeof prepareSlotAnimation>, characterHeight: number, restingColor: string, options: ResolvedSlotOptions, scheduleTask: (callback: () => void, delayMs: number) => void): void;
|
|
40
|
+
export {};
|
package/dist/dom.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { TUNING } from "./constants.js";
|
|
2
|
+
import { getVisibleSegmentText, segmentTextIntoGraphemes, } from "./text.js";
|
|
3
|
+
import { calculateGlyphTiming, calculateWidthTiming, } from "./timing.js";
|
|
4
|
+
const CSS_PROBE_TEXT = "x";
|
|
5
|
+
const { layout, width } = TUNING;
|
|
6
|
+
let cachedSlotLayoutReady = false;
|
|
7
|
+
/** Internal test seam; not re-exported from the package. */
|
|
8
|
+
export function resetSlotLayoutCache() {
|
|
9
|
+
cachedSlotLayoutReady = false;
|
|
10
|
+
}
|
|
11
|
+
function createCharacterFace(segment) {
|
|
12
|
+
const face = document.createElement("span");
|
|
13
|
+
face.className = "char-face";
|
|
14
|
+
face.textContent = getVisibleSegmentText(segment);
|
|
15
|
+
return face;
|
|
16
|
+
}
|
|
17
|
+
function createCharacterSlot(segment) {
|
|
18
|
+
const slot = document.createElement("span");
|
|
19
|
+
slot.className = "char-slot";
|
|
20
|
+
slot.dataset.char = segment;
|
|
21
|
+
const sizer = document.createElement("span");
|
|
22
|
+
sizer.className = "char-sizer";
|
|
23
|
+
sizer.textContent = getVisibleSegmentText(segment);
|
|
24
|
+
slot.append(sizer, createCharacterFace(segment));
|
|
25
|
+
return slot;
|
|
26
|
+
}
|
|
27
|
+
/** Render slot markup without touching animation lifecycle state. */
|
|
28
|
+
export function renderCharacterSlots(container, text) {
|
|
29
|
+
container.classList.add("slot-text");
|
|
30
|
+
container.replaceChildren(...segmentTextIntoGraphemes(text).map(createCharacterSlot));
|
|
31
|
+
}
|
|
32
|
+
export function renderPlainText(container, text) {
|
|
33
|
+
container.classList.remove("slot-text");
|
|
34
|
+
container.textContent = text;
|
|
35
|
+
}
|
|
36
|
+
export function getCharacterSlots(container) {
|
|
37
|
+
return Array.from(container.querySelectorAll(".char-slot"));
|
|
38
|
+
}
|
|
39
|
+
export function isSlotLayoutReady(slot) {
|
|
40
|
+
if (!slot)
|
|
41
|
+
return false;
|
|
42
|
+
const face = slot.querySelector(".char-face");
|
|
43
|
+
if (!face)
|
|
44
|
+
return false;
|
|
45
|
+
const slotStyle = getComputedStyle(slot);
|
|
46
|
+
const faceStyle = getComputedStyle(face);
|
|
47
|
+
const slotIsFlex = slotStyle.display === "inline-flex" || slotStyle.display === "flex";
|
|
48
|
+
return (slotStyle.position === "relative" &&
|
|
49
|
+
slotIsFlex &&
|
|
50
|
+
faceStyle.position === "absolute");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check the globally imported stylesheet contract. A successful check is
|
|
54
|
+
* cached because an applied stylesheet does not normally disappear. A failed
|
|
55
|
+
* check is retried so asynchronously loaded CSS can upgrade plain text later.
|
|
56
|
+
*/
|
|
57
|
+
export function canRenderSlotLayout() {
|
|
58
|
+
if (cachedSlotLayoutReady)
|
|
59
|
+
return true;
|
|
60
|
+
if (!document.body)
|
|
61
|
+
return false;
|
|
62
|
+
const cssProbe = document.createElement("span");
|
|
63
|
+
cssProbe.setAttribute("aria-hidden", "true");
|
|
64
|
+
cssProbe.style.cssText =
|
|
65
|
+
`position:absolute;left:${layout.probeOffscreenPositionPx}px;` +
|
|
66
|
+
`top:${layout.probeOffscreenPositionPx}px;visibility:hidden;pointer-events:none;`;
|
|
67
|
+
renderCharacterSlots(cssProbe, CSS_PROBE_TEXT);
|
|
68
|
+
document.body.appendChild(cssProbe);
|
|
69
|
+
const firstProbeSlot = getCharacterSlots(cssProbe)[0];
|
|
70
|
+
cachedSlotLayoutReady = isSlotLayoutReady(firstProbeSlot);
|
|
71
|
+
cssProbe.remove();
|
|
72
|
+
return cachedSlotLayoutReady;
|
|
73
|
+
}
|
|
74
|
+
export function addMissingCharacterSlots(container, characterSlots, requiredSlotCount) {
|
|
75
|
+
for (let index = characterSlots.length; index < requiredSlotCount; index++) {
|
|
76
|
+
const slot = createCharacterSlot("");
|
|
77
|
+
container.appendChild(slot);
|
|
78
|
+
characterSlots.push(slot);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function measureCharacterHeight(container, characterSlots, containerStyle) {
|
|
82
|
+
const firstCharacterSlot = characterSlots[0];
|
|
83
|
+
const sampleSlot = characterSlots.find((slot) => (slot.dataset.char ?? "") !== "") ??
|
|
84
|
+
firstCharacterSlot;
|
|
85
|
+
return (Math.ceil(sampleSlot?.getBoundingClientRect().height ||
|
|
86
|
+
sampleSlot?.offsetHeight ||
|
|
87
|
+
container.getBoundingClientRect().height ||
|
|
88
|
+
parseFloat(containerStyle.lineHeight) ||
|
|
89
|
+
0) ||
|
|
90
|
+
Math.ceil(parseFloat(containerStyle.fontSize) *
|
|
91
|
+
layout.fallbackLineHeightMultiplier) ||
|
|
92
|
+
layout.fallbackCharacterHeightPx);
|
|
93
|
+
}
|
|
94
|
+
/** Measure all old widths, apply all target sizers, then read all new widths. */
|
|
95
|
+
export function measureChangedSlots(characterSlots, currentSegments, targetSegments, skipUnchanged) {
|
|
96
|
+
const changedSlots = [];
|
|
97
|
+
for (let index = 0; index < characterSlots.length; index++) {
|
|
98
|
+
const currentSegment = currentSegments[index] ?? "";
|
|
99
|
+
const targetSegment = targetSegments[index] ?? "";
|
|
100
|
+
if (currentSegment === targetSegment &&
|
|
101
|
+
(skipUnchanged || currentSegment === "")) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const slot = characterSlots[index];
|
|
105
|
+
const sizer = slot.querySelector(".char-sizer");
|
|
106
|
+
changedSlots.push({
|
|
107
|
+
index,
|
|
108
|
+
slot,
|
|
109
|
+
sizer,
|
|
110
|
+
previousFace: slot.querySelector(".char-face"),
|
|
111
|
+
currentSegment,
|
|
112
|
+
targetSegment,
|
|
113
|
+
currentWidth: slot.getBoundingClientRect().width,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
changedSlots.forEach(({ sizer, targetSegment }) => {
|
|
117
|
+
sizer.textContent = getVisibleSegmentText(targetSegment);
|
|
118
|
+
});
|
|
119
|
+
return changedSlots.map((changedSlot) => {
|
|
120
|
+
const targetWidth = changedSlot.sizer.getBoundingClientRect().width;
|
|
121
|
+
return {
|
|
122
|
+
...changedSlot,
|
|
123
|
+
targetWidth,
|
|
124
|
+
widthWillChange: Math.abs(targetWidth - changedSlot.currentWidth) >
|
|
125
|
+
width.minimumVisibleChangePx,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export function prepareSlotAnimation(measurement, targetSegmentCount, characterHeight, options) {
|
|
130
|
+
const { index, slot, currentSegment, targetSegment } = measurement;
|
|
131
|
+
const { currentWidth, widthWillChange } = measurement;
|
|
132
|
+
const timing = calculateGlyphTiming(index, targetSegmentCount, targetSegment === "", options);
|
|
133
|
+
const widthTiming = calculateWidthTiming(currentSegment, targetSegment, timing);
|
|
134
|
+
const incomingOffsetY = options.direction === "down" ? -characterHeight : characterHeight;
|
|
135
|
+
const incomingColor = typeof options.color === "function"
|
|
136
|
+
? options.color(index, targetSegmentCount)
|
|
137
|
+
: options.color;
|
|
138
|
+
if (widthWillChange)
|
|
139
|
+
slot.style.width = `${currentWidth}px`;
|
|
140
|
+
if (currentSegment === "" || targetSegment === "") {
|
|
141
|
+
slot.classList.add("is-resizing");
|
|
142
|
+
}
|
|
143
|
+
const incomingFace = createCharacterFace(targetSegment);
|
|
144
|
+
incomingFace.style.transformOrigin = layout.characterTransformOrigin;
|
|
145
|
+
incomingFace.style.transform =
|
|
146
|
+
`translateY(${incomingOffsetY}px) ` +
|
|
147
|
+
`rotate(${timing.startingTiltDegrees}deg)`;
|
|
148
|
+
if (incomingColor)
|
|
149
|
+
incomingFace.style.color = incomingColor;
|
|
150
|
+
slot.appendChild(incomingFace);
|
|
151
|
+
const widthCompletionTimeMs = widthWillChange
|
|
152
|
+
? widthTiming.startDelayMs + widthTiming.durationMs
|
|
153
|
+
: 0;
|
|
154
|
+
const glyphCompletionTimeMs = timing.startDelayMs +
|
|
155
|
+
options.exitOffset +
|
|
156
|
+
timing.durationMs +
|
|
157
|
+
(options.color ? options.colorFade : 0);
|
|
158
|
+
return {
|
|
159
|
+
measurement,
|
|
160
|
+
timing,
|
|
161
|
+
widthTiming,
|
|
162
|
+
incomingFace,
|
|
163
|
+
completionTimeMs: Math.max(widthCompletionTimeMs, glyphCompletionTimeMs),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export function scheduleSlotAnimation(preparedAnimation, characterHeight, restingColor, options, scheduleTask) {
|
|
167
|
+
const { measurement, timing, widthTiming, incomingFace } = preparedAnimation;
|
|
168
|
+
const { slot, previousFace, targetWidth, widthWillChange } = measurement;
|
|
169
|
+
const outgoingOffsetY = options.direction === "down" ? characterHeight : -characterHeight;
|
|
170
|
+
const rollTransition = `transform ${timing.durationMs}ms ${options.easing}`;
|
|
171
|
+
const incomingTransition = options.color
|
|
172
|
+
? `${rollTransition}, color ${options.colorFade}ms linear ${timing.durationMs}ms`
|
|
173
|
+
: rollTransition;
|
|
174
|
+
if (widthWillChange) {
|
|
175
|
+
scheduleTask(() => {
|
|
176
|
+
slot.style.transition =
|
|
177
|
+
`width ${widthTiming.durationMs}ms ${width.resizeEasing}`;
|
|
178
|
+
slot.style.width = `${targetWidth}px`;
|
|
179
|
+
}, widthTiming.startDelayMs);
|
|
180
|
+
}
|
|
181
|
+
if (previousFace) {
|
|
182
|
+
scheduleTask(() => {
|
|
183
|
+
previousFace.style.transition = rollTransition;
|
|
184
|
+
previousFace.style.transform =
|
|
185
|
+
`translateY(${outgoingOffsetY}px) ` +
|
|
186
|
+
`rotate(${-timing.startingTiltDegrees}deg)`;
|
|
187
|
+
}, timing.startDelayMs);
|
|
188
|
+
}
|
|
189
|
+
scheduleTask(() => {
|
|
190
|
+
incomingFace.style.transition = incomingTransition;
|
|
191
|
+
incomingFace.style.transform = layout.characterRestTransform;
|
|
192
|
+
if (options.color)
|
|
193
|
+
incomingFace.style.color = restingColor;
|
|
194
|
+
const finishOnTransformEnd = (event) => {
|
|
195
|
+
if (event.propertyName !== "transform")
|
|
196
|
+
return;
|
|
197
|
+
incomingFace.removeEventListener("transitionend", finishOnTransformEnd);
|
|
198
|
+
slot.dataset.char = measurement.targetSegment;
|
|
199
|
+
slot.style.removeProperty("transition");
|
|
200
|
+
slot.style.removeProperty("width");
|
|
201
|
+
slot.classList.remove("is-resizing");
|
|
202
|
+
slot.querySelectorAll(".char-face").forEach((face) => {
|
|
203
|
+
if (face !== incomingFace)
|
|
204
|
+
face.remove();
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
incomingFace.addEventListener("transitionend", finishOnTransformEnd);
|
|
208
|
+
}, timing.startDelayMs + options.exitOffset);
|
|
209
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -31,4 +31,4 @@ export interface SlotTextController {
|
|
|
31
31
|
* label.set("Copied", { direction: "up" });
|
|
32
32
|
* label.flash("Copied", { revertAfter: 1400 }); // auto-reverts to "Copy"
|
|
33
33
|
*/
|
|
34
|
-
export declare function slotText(element: HTMLElement, initialText: string,
|
|
34
|
+
export declare function slotText(element: HTMLElement, initialText: string, defaultOptions?: SlotOptions): SlotTextController;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { animateSlotText, buildSlotText, chromatic, clearSlotText, } from "./slotText.js";
|
|
2
|
-
import {
|
|
2
|
+
import { TUNING } from "./constants.js";
|
|
3
|
+
import { animateSlotText, clearSlotText, renderTextWithCssFallback, } from "./slotText.js";
|
|
3
4
|
/**
|
|
4
5
|
* Create a text-roll controller for one element.
|
|
5
6
|
*
|
|
@@ -9,54 +10,54 @@ import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText.js";
|
|
|
9
10
|
* label.set("Copied", { direction: "up" });
|
|
10
11
|
* label.flash("Copied", { revertAfter: 1400 }); // auto-reverts to "Copy"
|
|
11
12
|
*/
|
|
12
|
-
export function slotText(element, initialText,
|
|
13
|
-
let
|
|
14
|
-
let
|
|
13
|
+
export function slotText(element, initialText, defaultOptions = {}) {
|
|
14
|
+
let currentValue = initialText;
|
|
15
|
+
let revertTimerId;
|
|
15
16
|
let restingText;
|
|
16
|
-
|
|
17
|
+
renderTextWithCssFallback(element, initialText);
|
|
18
|
+
const animateWithoutInterrupt = (text, overrides) => animateSlotText(element, text, {
|
|
19
|
+
...defaultOptions,
|
|
20
|
+
interrupt: false,
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
17
23
|
return {
|
|
18
24
|
element,
|
|
19
25
|
get value() {
|
|
20
|
-
return
|
|
26
|
+
return currentValue;
|
|
21
27
|
},
|
|
22
|
-
set(text,
|
|
28
|
+
set(text, optionOverrides = {}) {
|
|
23
29
|
// An explicit set wins over a pending flash revert.
|
|
24
|
-
clearTimeout(
|
|
30
|
+
clearTimeout(revertTimerId);
|
|
25
31
|
restingText = undefined;
|
|
26
|
-
|
|
27
|
-
animateSlotText(element, text, {
|
|
32
|
+
currentValue = text;
|
|
33
|
+
animateSlotText(element, text, {
|
|
34
|
+
...defaultOptions,
|
|
35
|
+
...optionOverrides,
|
|
36
|
+
});
|
|
28
37
|
},
|
|
29
|
-
flash(text, { revertAfter =
|
|
38
|
+
flash(text, { revertAfter = TUNING.lifecycle.flashRevertDelayMs, enter: enterOptions, exit: exitOptions, } = {}) {
|
|
30
39
|
// Capture the resting text only on the first flash of a burst, so a
|
|
31
40
|
// flash-during-flash still reverts to the original label.
|
|
32
41
|
if (restingText === undefined) {
|
|
33
|
-
restingText =
|
|
42
|
+
restingText = currentValue;
|
|
34
43
|
}
|
|
35
44
|
// Flashes default to non-interrupting rolls: spam-friendly, no mid-roll
|
|
36
45
|
// cutoffs. Callers can still override via `enter`/`exit`.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
...options,
|
|
40
|
-
interrupt: false,
|
|
41
|
-
...enter,
|
|
42
|
-
});
|
|
46
|
+
currentValue = text;
|
|
47
|
+
animateWithoutInterrupt(text, enterOptions);
|
|
43
48
|
// Restart the revert timer: one revert per burst, after the last flash.
|
|
44
|
-
clearTimeout(
|
|
45
|
-
|
|
46
|
-
const
|
|
49
|
+
clearTimeout(revertTimerId);
|
|
50
|
+
revertTimerId = window.setTimeout(() => {
|
|
51
|
+
const originalText = restingText;
|
|
47
52
|
restingText = undefined;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
...options,
|
|
52
|
-
interrupt: false,
|
|
53
|
-
...exit,
|
|
54
|
-
});
|
|
53
|
+
revertTimerId = undefined;
|
|
54
|
+
currentValue = originalText;
|
|
55
|
+
animateWithoutInterrupt(originalText, exitOptions);
|
|
55
56
|
}, revertAfter);
|
|
56
57
|
},
|
|
57
58
|
destroy() {
|
|
58
|
-
clearTimeout(
|
|
59
|
-
clearSlotText(element,
|
|
59
|
+
clearTimeout(revertTimerId);
|
|
60
|
+
clearSlotText(element, currentValue);
|
|
60
61
|
},
|
|
61
62
|
};
|
|
62
63
|
}
|
package/dist/react.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createElement, forwardRef, useEffect, useImperativeHandle, useRef, } from "react";
|
|
2
|
-
import { animateSlotText,
|
|
2
|
+
import { animateSlotText, clearSlotText, renderTextWithCssFallback, } 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);
|
|
@@ -13,7 +13,7 @@ export const SlotText = forwardRef(({ text, options, "aria-label": ariaLabel, ..
|
|
|
13
13
|
const element = elementRef.current;
|
|
14
14
|
if (!element)
|
|
15
15
|
return;
|
|
16
|
-
|
|
16
|
+
renderTextWithCssFallback(element, text);
|
|
17
17
|
mountedRef.current = true;
|
|
18
18
|
return () => {
|
|
19
19
|
clearSlotText(element);
|
package/dist/slotText.d.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Adapted from motion-primitives' TextRoll (vertical-slide variant): each
|
|
5
|
-
* character sits in its own clipped cell and changes by sliding. The new
|
|
6
|
-
* glyph enters from one side while the old glyph slides out the other, with
|
|
7
|
-
* the incoming glyph chasing the outgoing one by a stagger step. Pure
|
|
8
|
-
* transform/transition, GPU-composited, with a springy overshoot easing, so
|
|
9
|
-
* every letter lands with a little bounce.
|
|
10
|
-
*
|
|
11
|
-
* buildSlotText(el, "Copy"); // initialise
|
|
12
|
-
* animateSlotText(el, "Copied", { direction: "up" }); // animate to new text
|
|
2
|
+
* Browser-only text-roll animation. Each grapheme owns one clipped slot; the
|
|
3
|
+
* previous face rolls out while the next face rolls in.
|
|
13
4
|
*/
|
|
5
|
+
/** Options shared by the low-level animation and all framework adapters. */
|
|
14
6
|
export interface SlotOptions {
|
|
15
7
|
/** "down" rolls glyphs downward (enter from top); "up" rolls upward. */
|
|
16
8
|
direction?: "up" | "down";
|
|
@@ -23,49 +15,43 @@ export interface SlotOptions {
|
|
|
23
15
|
/** Easing โ defaults to a springy, overshooting "back" curve. */
|
|
24
16
|
easing?: string;
|
|
25
17
|
/**
|
|
26
|
-
* Per-letter personality: 0
|
|
27
|
-
*
|
|
28
|
-
* Default 0.6.
|
|
18
|
+
* Per-letter personality: 0 makes every glyph move identically; 1 adds the
|
|
19
|
+
* strongest timing variation and tilt. Default 0.6.
|
|
29
20
|
*/
|
|
30
21
|
bounce?: number;
|
|
31
22
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* function `(index, total) => color` to give every glyph its own hue. That's
|
|
35
|
-
* what produces the spectrum/rainbow sweep across the line. Omit for no flash.
|
|
23
|
+
* Incoming glyph tint. Pass one CSS color, or return a color for each
|
|
24
|
+
* `(segmentIndex, segmentCount)` pair.
|
|
36
25
|
*/
|
|
37
|
-
color?: string | ((
|
|
38
|
-
/**
|
|
26
|
+
color?: string | ((segmentIndex: number, segmentCount: number) => string);
|
|
27
|
+
/** Tint fade duration in ms (default 280). */
|
|
39
28
|
colorFade?: number;
|
|
40
29
|
/**
|
|
41
|
-
* Keep
|
|
42
|
-
*
|
|
43
|
-
* two strings are misaligned (different lengths) so the whole line rolls
|
|
44
|
-
* uniformly instead of leaving stray letters frozen.
|
|
30
|
+
* Keep graphemes that are identical at the same index static. Disable this
|
|
31
|
+
* when differently sized strings are not positionally aligned.
|
|
45
32
|
*/
|
|
46
33
|
skipUnchanged?: boolean;
|
|
47
34
|
/**
|
|
48
|
-
* true
|
|
49
|
-
*
|
|
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.
|
|
35
|
+
* true interrupts the running roll. false lets it finish, then plays only
|
|
36
|
+
* the latest queued target. Default true.
|
|
52
37
|
*/
|
|
53
38
|
interrupt?: boolean;
|
|
54
39
|
}
|
|
55
40
|
export interface ChromaticOptions {
|
|
41
|
+
/** Starting hue in degrees. Default 0. */
|
|
56
42
|
from?: number;
|
|
43
|
+
/** Hue distance from first to last grapheme. Default 320 degrees. */
|
|
57
44
|
spread?: number;
|
|
45
|
+
/** HSL saturation percentage. Default 92. */
|
|
58
46
|
saturation?: number;
|
|
47
|
+
/** HSL lightness percentage. Default 60. */
|
|
59
48
|
lightness?: number;
|
|
60
49
|
}
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* animateSlotText(el, txt, { color: chromatic({ from: 18 }) }); // start gold
|
|
67
|
-
*/
|
|
68
|
-
export declare function chromatic({ from, spread, saturation, lightness, }?: ChromaticOptions): (index: number, total: number) => string;
|
|
50
|
+
/** Build a color function that sweeps a hue range across the text. */
|
|
51
|
+
export declare function chromatic({ from, spread, saturation, lightness, }?: ChromaticOptions): (segmentIndex: number, segmentCount: number) => string;
|
|
52
|
+
/** Render settled text, falling back to plain text until the CSS is ready. */
|
|
53
|
+
export declare function renderTextWithCssFallback(container: HTMLElement, text: string): void;
|
|
54
|
+
/** Build slot markup immediately and cancel any animation that owns it. */
|
|
69
55
|
export declare function buildSlotText(container: HTMLElement, text: string): void;
|
|
70
|
-
export declare function animateSlotText(container: HTMLElement,
|
|
56
|
+
export declare function animateSlotText(container: HTMLElement, targetText: string, options?: SlotOptions): void;
|
|
71
57
|
export declare function clearSlotText(container: HTMLElement, text?: string): void;
|
package/dist/slotText.js
CHANGED
|
@@ -1,266 +1,118 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
-
const DEFAULTS = {
|
|
15
|
-
direction: "down",
|
|
16
|
-
stagger: 45,
|
|
17
|
-
duration: 300,
|
|
18
|
-
exitOffset: 50,
|
|
19
|
-
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
|
|
20
|
-
bounce: 0.6,
|
|
21
|
-
colorFade: 280,
|
|
22
|
-
skipUnchanged: true,
|
|
23
|
-
interrupt: true,
|
|
24
|
-
};
|
|
25
|
-
const NBSP = "\u00A0";
|
|
26
|
-
const glyph = (char) => (char === " " ? NBSP : char);
|
|
27
|
-
/**
|
|
28
|
-
* Build a `color` function that sweeps the hue across the line, giving every
|
|
29
|
-
* glyph its own color so the roll lands as a chromatic spectrum.
|
|
30
|
-
*
|
|
31
|
-
* animateSlotText(el, txt, { color: chromatic() }); // full rainbow
|
|
32
|
-
* animateSlotText(el, txt, { color: chromatic({ from: 18 }) }); // start gold
|
|
33
|
-
*/
|
|
34
|
-
export function chromatic({ from = 0, spread = 320, saturation = 92, lightness = 60, } = {}) {
|
|
35
|
-
return (index, total) => {
|
|
36
|
-
const t = total <= 1 ? 0 : index / (total - 1);
|
|
37
|
-
return `hsl(${(from + t * spread) % 360} ${saturation}% ${lightness}%)`;
|
|
1
|
+
import { TUNING } from "./constants.js";
|
|
2
|
+
import { addMissingCharacterSlots, canRenderSlotLayout, getCharacterSlots, isSlotLayoutReady, measureChangedSlots, measureCharacterHeight, prepareSlotAnimation, renderPlainText, renderCharacterSlots, scheduleSlotAnimation, } from "./dom.js";
|
|
3
|
+
import { segmentTextIntoGraphemes } from "./text.js";
|
|
4
|
+
import { resolveAnimationOptions } from "./timing.js";
|
|
5
|
+
const { chromatic: chromaticTuning, lifecycle } = TUNING;
|
|
6
|
+
/** Build a color function that sweeps a hue range across the text. */
|
|
7
|
+
export function chromatic({ from = chromaticTuning.hueStartDegrees, spread = chromaticTuning.hueSpreadDegrees, saturation = chromaticTuning.saturationPercent, lightness = chromaticTuning.lightnessPercent, } = {}) {
|
|
8
|
+
return (segmentIndex, segmentCount) => {
|
|
9
|
+
const lastSegmentIndex = segmentCount - 1;
|
|
10
|
+
const progress = segmentCount <= 1 ? 0 : segmentIndex / lastSegmentIndex;
|
|
11
|
+
const hueDegrees = (from + progress * spread) % chromaticTuning.fullHueRotationDegrees;
|
|
12
|
+
return `hsl(${hueDegrees} ${saturation}% ${lightness}%)`;
|
|
38
13
|
};
|
|
39
14
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
function
|
|
43
|
-
const
|
|
44
|
-
if (!
|
|
45
|
-
return;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// so the next animation starts from a clean, non-overlapping baseline.
|
|
50
|
-
buildSlotText(container, state.target);
|
|
15
|
+
const animationStates = new WeakMap();
|
|
16
|
+
const initializedContainers = new WeakSet();
|
|
17
|
+
function cancelRunningAnimation(container) {
|
|
18
|
+
const animationState = animationStates.get(container);
|
|
19
|
+
if (!animationState)
|
|
20
|
+
return undefined;
|
|
21
|
+
animationState.timerIds.forEach((timerId) => window.clearTimeout(timerId));
|
|
22
|
+
animationStates.delete(container);
|
|
23
|
+
return animationState;
|
|
51
24
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
25
|
+
/** Render settled text, falling back to plain text until the CSS is ready. */
|
|
26
|
+
export function renderTextWithCssFallback(container, text) {
|
|
27
|
+
initializedContainers.add(container);
|
|
28
|
+
if (canRenderSlotLayout())
|
|
29
|
+
renderCharacterSlots(container, text);
|
|
30
|
+
else
|
|
31
|
+
renderPlainText(container, text);
|
|
57
32
|
}
|
|
58
|
-
function
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// the absolutely-positioned animating faces never reflow the line.
|
|
64
|
-
const sizer = document.createElement("span");
|
|
65
|
-
sizer.className = "char-sizer";
|
|
66
|
-
sizer.textContent = glyph(char);
|
|
67
|
-
slot.append(sizer, makeFace(char));
|
|
68
|
-
return slot;
|
|
33
|
+
function finishRunningAnimationImmediately(container) {
|
|
34
|
+
const animationState = cancelRunningAnimation(container);
|
|
35
|
+
if (animationState) {
|
|
36
|
+
renderTextWithCssFallback(container, animationState.targetText);
|
|
37
|
+
}
|
|
69
38
|
}
|
|
39
|
+
/** Build slot markup immediately and cancel any animation that owns it. */
|
|
70
40
|
export function buildSlotText(container, text) {
|
|
71
|
-
container
|
|
72
|
-
|
|
41
|
+
cancelRunningAnimation(container);
|
|
42
|
+
initializedContainers.add(container);
|
|
43
|
+
renderCharacterSlots(container, text);
|
|
73
44
|
}
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
45
|
+
function scheduleAnimationTask(animationState, callback, delayMs) {
|
|
46
|
+
const timerId = window.setTimeout(callback, delayMs);
|
|
47
|
+
animationState.timerIds.push(timerId);
|
|
48
|
+
}
|
|
49
|
+
export function animateSlotText(container, targetText, options = {}) {
|
|
50
|
+
const resolvedOptions = resolveAnimationOptions(options);
|
|
51
|
+
const runningAnimation = animationStates.get(container);
|
|
52
|
+
if (runningAnimation && !resolvedOptions.interrupt) {
|
|
53
|
+
runningAnimation.pendingAnimation =
|
|
54
|
+
targetText === runningAnimation.targetText
|
|
55
|
+
? undefined
|
|
56
|
+
: { text: targetText, options };
|
|
87
57
|
return;
|
|
88
58
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
59
|
+
finishRunningAnimationImmediately(container);
|
|
60
|
+
let characterSlots = getCharacterSlots(container);
|
|
61
|
+
if (characterSlots.length === 0) {
|
|
62
|
+
if (!initializedContainers.has(container)) {
|
|
63
|
+
renderTextWithCssFallback(container, targetText);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!canRenderSlotLayout()) {
|
|
67
|
+
renderPlainText(container, targetText);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
renderCharacterSlots(container, container.textContent ?? "");
|
|
71
|
+
characterSlots = getCharacterSlots(container);
|
|
72
|
+
}
|
|
73
|
+
const firstCharacterSlot = characterSlots[0];
|
|
74
|
+
if (firstCharacterSlot && !isSlotLayoutReady(firstCharacterSlot)) {
|
|
75
|
+
renderPlainText(container, targetText);
|
|
96
76
|
return;
|
|
97
77
|
}
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
78
|
+
const currentSegments = characterSlots.map((slot) => slot.dataset.char ?? "");
|
|
79
|
+
const targetSegments = segmentTextIntoGraphemes(targetText);
|
|
80
|
+
if (!resolvedOptions.interrupt &&
|
|
81
|
+
currentSegments.length === targetSegments.length &&
|
|
82
|
+
currentSegments.every((segment, index) => segment === targetSegments[index])) {
|
|
103
83
|
return;
|
|
104
|
-
const maxLen = Math.max(fromText.length, toText.length);
|
|
105
|
-
// Whole-pixel slide distance = one cell height, so glyphs clip cleanly.
|
|
106
|
-
// If layout has not produced dimensions yet, fall back to line-height/font-size
|
|
107
|
-
// so the text still rolls instead of swapping in place.
|
|
108
|
-
const sample = slots.find((s) => (s.dataset.char ?? "") !== "") ?? slots[0];
|
|
109
|
-
const cs = getComputedStyle(container);
|
|
110
|
-
// Ceil, not round: if the slide distance is even half a pixel short of the
|
|
111
|
-
// cell height, a sliver of the outgoing glyph stays visible at the clip edge.
|
|
112
|
-
const H = Math.ceil(sample?.getBoundingClientRect().height ||
|
|
113
|
-
sample?.offsetHeight ||
|
|
114
|
-
container.getBoundingClientRect().height ||
|
|
115
|
-
parseFloat(cs.lineHeight) ||
|
|
116
|
-
0) ||
|
|
117
|
-
Math.ceil(parseFloat(cs.fontSize) * 1.3) ||
|
|
118
|
-
18;
|
|
119
|
-
// Resting color to settle the chromatic flash back to.
|
|
120
|
-
const restColor = color ? cs.color : "";
|
|
121
|
-
// Pre-create any extra cells up front so the row never reflows mid-roll.
|
|
122
|
-
for (let i = slots.length; i < maxLen; i++) {
|
|
123
|
-
const slot = buildSlot("");
|
|
124
|
-
container.appendChild(slot);
|
|
125
|
-
slots.push(slot);
|
|
126
84
|
}
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// line does not land as one rigid block. Every letter has some personality.
|
|
137
|
-
const wobble = (i, salt) => {
|
|
138
|
-
const n = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453;
|
|
139
|
-
return (n - Math.floor(n)) * 2 - 1;
|
|
140
|
-
};
|
|
141
|
-
// Track the slowest letter so the safety-net snap waits for everyone.
|
|
142
|
-
let maxEnd = 0;
|
|
143
|
-
for (let i = 0; i < maxLen; i++) {
|
|
144
|
-
const fromChar = fromText[i] || "";
|
|
145
|
-
const toChar = toText[i] || "";
|
|
146
|
-
if (fromChar === toChar && (skipUnchanged || fromChar === ""))
|
|
147
|
-
continue;
|
|
148
|
-
const slot = slots[i];
|
|
149
|
-
const sizer = slot.querySelector(".char-sizer");
|
|
150
|
-
const oldFace = slot.querySelector(".char-face");
|
|
151
|
-
// Resize the cell to the new glyph โ but ease the width instead of
|
|
152
|
-
// snapping it, so a wide outgoing glyph (W โ i) is never cropped by a
|
|
153
|
-
// suddenly-narrow cell and neighbouring letters glide rather than jump.
|
|
154
|
-
const oldW = slot.getBoundingClientRect().width;
|
|
155
|
-
sizer.textContent = glyph(toChar);
|
|
156
|
-
const newW = sizer.getBoundingClientRect().width;
|
|
157
|
-
const widthChanges = Math.abs(newW - oldW) > 0.5;
|
|
158
|
-
if (widthChanges)
|
|
159
|
-
slot.style.width = `${oldW}px`;
|
|
160
|
-
// A cell growing from or collapsing to empty changes width drastically โ
|
|
161
|
-
// clip it horizontally while it resizes so its glyph wipes in/out with the
|
|
162
|
-
// cell instead of spilling over and stacking onto the neighbours.
|
|
163
|
-
if (fromChar === "" || toChar === "")
|
|
164
|
-
slot.classList.add("is-resizing");
|
|
165
|
-
const tint = typeof color === "function" ? color(i, maxLen) : color;
|
|
166
|
-
// Per-letter personality: vary the speed, the stagger and a starting tilt
|
|
167
|
-
// that springs back to upright as the glyph settles. Tilt is kept small so
|
|
168
|
-
// rotated corners never swing into the neighbouring cells.
|
|
169
|
-
// Tail cells (rolling out to nothing) join the same wave instead of
|
|
170
|
-
// queuing behind it: they start mid-wave, roll a little faster, and are
|
|
171
|
-
// gone before the new word finishes landing โ so nothing trails.
|
|
172
|
-
const isTail = toChar === "";
|
|
173
|
-
const d = Math.round(duration * (isTail ? 0.75 : 1) * (1 + bounce * 0.45 * wobble(i, 1)));
|
|
174
|
-
const staggerIndex = isTail
|
|
175
|
-
? toText.length * 0.5 + (i - toText.length) * 0.25
|
|
176
|
-
: i;
|
|
177
|
-
const base = Math.round(staggerIndex * stagger * (1 + bounce * 0.25 * wobble(i, 2)));
|
|
178
|
-
const tilt = (bounce * 5 * wobble(i, 3)).toFixed(2);
|
|
179
|
-
const rollTrans = `transform ${d}ms ${easing}`;
|
|
180
|
-
const trans = color
|
|
181
|
-
? `${rollTrans}, color ${colorFade}ms linear ${d}ms`
|
|
182
|
-
: rollTrans;
|
|
183
|
-
const newFace = makeFace(toChar);
|
|
184
|
-
newFace.style.transformOrigin = "50% 50%";
|
|
185
|
-
newFace.style.transform = `translateY(${inStart}px) rotate(${tilt}deg)`;
|
|
186
|
-
if (tint)
|
|
187
|
-
newFace.style.color = tint;
|
|
188
|
-
slot.appendChild(newFace);
|
|
189
|
-
void slot.offsetWidth; // commit start transforms
|
|
190
|
-
// Glide the cell to its new width with a clean ease-out (no overshoot) so
|
|
191
|
-
// it never pinches narrower than either glyph. Timing depends on the kind
|
|
192
|
-
// of change:
|
|
193
|
-
// - glyph โ glyph: resize alongside the roll.
|
|
194
|
-
// - glyph โ empty: let the glyph roll out vertically at full width FIRST,
|
|
195
|
-
// then snap the empty cell closed quickly โ so the exit reads as a roll,
|
|
196
|
-
// not a horizontal crush.
|
|
197
|
-
// - empty โ glyph: open the cell quickly BEFORE the glyph rolls in, so it
|
|
198
|
-
// arrives into a full-width cell.
|
|
199
|
-
if (widthChanges) {
|
|
200
|
-
let wDelay = base;
|
|
201
|
-
let wDur = d;
|
|
202
|
-
if (isTail) {
|
|
203
|
-
// Keep full width while the glyph is visibly rolling (first ~55%),
|
|
204
|
-
// then close just behind it โ the exit reads as a roll, and the line
|
|
205
|
-
// has fully contracted by the time the new word lands.
|
|
206
|
-
wDelay = base + Math.round(d * 0.55);
|
|
207
|
-
wDur = Math.max(140, Math.round(d * 0.6));
|
|
208
|
-
}
|
|
209
|
-
else if (fromChar === "") {
|
|
210
|
-
wDur = Math.max(140, Math.round(d * 0.45));
|
|
211
|
-
}
|
|
212
|
-
timers.push(window.setTimeout(() => {
|
|
213
|
-
slot.style.transition = `width ${wDur}ms cubic-bezier(0.2, 0, 0, 1)`;
|
|
214
|
-
slot.style.width = `${newW}px`;
|
|
215
|
-
}, wDelay));
|
|
216
|
-
maxEnd = Math.max(maxEnd, wDelay + wDur);
|
|
217
|
-
}
|
|
218
|
-
maxEnd = Math.max(maxEnd, base + exitOffset + d + (color ? colorFade : 0));
|
|
219
|
-
// Outgoing glyph slides away first (with its own little counter-tilt).
|
|
220
|
-
if (oldFace) {
|
|
221
|
-
timers.push(window.setTimeout(() => {
|
|
222
|
-
oldFace.style.transition = rollTrans;
|
|
223
|
-
oldFace.style.transform = `translateY(${outY}px) rotate(${-Number(tilt)}deg)`;
|
|
224
|
-
}, base));
|
|
225
|
-
}
|
|
226
|
-
// Incoming glyph chases it in (and, if tinted, fades to rest afterwards).
|
|
227
|
-
timers.push(window.setTimeout(() => {
|
|
228
|
-
newFace.style.transition = trans;
|
|
229
|
-
newFace.style.transform = "translateY(0) rotate(0deg)";
|
|
230
|
-
if (color)
|
|
231
|
-
newFace.style.color = restColor;
|
|
232
|
-
const done = (e) => {
|
|
233
|
-
if (e.propertyName !== "transform")
|
|
234
|
-
return; // ignore the colour fade
|
|
235
|
-
newFace.removeEventListener("transitionend", done);
|
|
236
|
-
slot.dataset.char = toChar;
|
|
237
|
-
// Hand sizing back to the sizer (same px, so nothing visibly moves).
|
|
238
|
-
slot.style.removeProperty("transition");
|
|
239
|
-
slot.style.removeProperty("width");
|
|
240
|
-
slot.classList.remove("is-resizing");
|
|
241
|
-
slot.querySelectorAll(".char-face").forEach((f) => {
|
|
242
|
-
if (f !== newFace)
|
|
243
|
-
f.remove();
|
|
244
|
-
});
|
|
245
|
-
};
|
|
246
|
-
newFace.addEventListener("transitionend", done);
|
|
247
|
-
}, base + exitOffset));
|
|
85
|
+
const requiredSlotCount = Math.max(currentSegments.length, targetSegments.length);
|
|
86
|
+
addMissingCharacterSlots(container, characterSlots, requiredSlotCount);
|
|
87
|
+
const containerStyle = getComputedStyle(container);
|
|
88
|
+
const characterHeight = measureCharacterHeight(container, characterSlots, containerStyle);
|
|
89
|
+
const restingColor = resolvedOptions.color ? containerStyle.color : "";
|
|
90
|
+
const changedSlotMeasurements = measureChangedSlots(characterSlots, currentSegments, targetSegments, resolvedOptions.skipUnchanged);
|
|
91
|
+
if (changedSlotMeasurements.length === 0) {
|
|
92
|
+
renderCharacterSlots(container, targetText);
|
|
93
|
+
return;
|
|
248
94
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
95
|
+
const animationState = { timerIds: [], targetText };
|
|
96
|
+
animationStates.set(container, animationState);
|
|
97
|
+
const preparedSlotAnimations = changedSlotMeasurements.map((measurement) => prepareSlotAnimation(measurement, targetSegments.length, characterHeight, resolvedOptions));
|
|
98
|
+
// Commit every start style with one layout flush instead of one per glyph.
|
|
99
|
+
void container.offsetWidth;
|
|
100
|
+
preparedSlotAnimations.forEach((preparedSlotAnimation) => scheduleSlotAnimation(preparedSlotAnimation, characterHeight, restingColor, resolvedOptions, (callback, delayMs) => scheduleAnimationTask(animationState, callback, delayMs)));
|
|
101
|
+
const completionDelayMs = Math.max(...preparedSlotAnimations.map(({ completionTimeMs }) => completionTimeMs)) +
|
|
102
|
+
lifecycle.completionBufferMs;
|
|
103
|
+
scheduleAnimationTask(animationState, () => {
|
|
104
|
+
if (animationStates.get(container) !== animationState)
|
|
105
|
+
return;
|
|
106
|
+
const pendingAnimation = animationState.pendingAnimation;
|
|
107
|
+
animationStates.delete(container);
|
|
108
|
+
renderTextWithCssFallback(container, targetText);
|
|
109
|
+
if (pendingAnimation) {
|
|
110
|
+
animateSlotText(container, pendingAnimation.text, pendingAnimation.options);
|
|
259
111
|
}
|
|
260
|
-
},
|
|
112
|
+
}, completionDelayMs);
|
|
261
113
|
}
|
|
262
114
|
export function clearSlotText(container, text = "") {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
container
|
|
115
|
+
cancelRunningAnimation(container);
|
|
116
|
+
initializedContainers.delete(container);
|
|
117
|
+
renderPlainText(container, text);
|
|
266
118
|
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Split text into user-perceived characters, with code-point fallback. */
|
|
2
|
+
export declare function segmentTextIntoGraphemes(text: string): string[];
|
|
3
|
+
/** Preserve regular spaces inside individually measured glyph cells. */
|
|
4
|
+
export declare function getVisibleSegmentText(segment: string): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const NBSP = "\u00A0";
|
|
2
|
+
const graphemeSegmenter = Intl.Segmenter
|
|
3
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
4
|
+
: undefined;
|
|
5
|
+
/** Split text into user-perceived characters, with code-point fallback. */
|
|
6
|
+
export function segmentTextIntoGraphemes(text) {
|
|
7
|
+
if (!graphemeSegmenter)
|
|
8
|
+
return Array.from(text);
|
|
9
|
+
return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
|
|
10
|
+
}
|
|
11
|
+
/** Preserve regular spaces inside individually measured glyph cells. */
|
|
12
|
+
export function getVisibleSegmentText(segment) {
|
|
13
|
+
return segment === " " ? NBSP : segment;
|
|
14
|
+
}
|
package/dist/timing.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SlotOptions } from "./slotText.js";
|
|
2
|
+
export interface ResolvedSlotOptions {
|
|
3
|
+
direction: "up" | "down";
|
|
4
|
+
stagger: number;
|
|
5
|
+
duration: number;
|
|
6
|
+
exitOffset: number;
|
|
7
|
+
easing: string;
|
|
8
|
+
bounce: number;
|
|
9
|
+
color?: string | ((segmentIndex: number, segmentCount: number) => string);
|
|
10
|
+
colorFade: number;
|
|
11
|
+
skipUnchanged: boolean;
|
|
12
|
+
interrupt: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface GlyphTiming {
|
|
15
|
+
durationMs: number;
|
|
16
|
+
startDelayMs: number;
|
|
17
|
+
startingTiltDegrees: number;
|
|
18
|
+
}
|
|
19
|
+
export interface WidthTiming {
|
|
20
|
+
durationMs: number;
|
|
21
|
+
startDelayMs: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function resolveAnimationOptions(options: SlotOptions): ResolvedSlotOptions;
|
|
24
|
+
export declare function calculateGlyphTiming(segmentIndex: number, targetSegmentCount: number, isTrailingSlot: boolean, options: Pick<ResolvedSlotOptions, "duration" | "stagger" | "bounce">): GlyphTiming;
|
|
25
|
+
export declare function calculateWidthTiming(currentSegment: string, targetSegment: string, timing: GlyphTiming): WidthTiming;
|
package/dist/timing.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { TUNING } from "./constants.js";
|
|
2
|
+
const { animation, glyph, variation, width } = TUNING;
|
|
3
|
+
export function resolveAnimationOptions(options) {
|
|
4
|
+
return {
|
|
5
|
+
direction: options.direction ?? animation.direction,
|
|
6
|
+
stagger: options.stagger ?? animation.stagger,
|
|
7
|
+
duration: options.duration ?? animation.duration,
|
|
8
|
+
exitOffset: options.exitOffset ?? animation.exitOffset,
|
|
9
|
+
easing: options.easing ?? animation.easing,
|
|
10
|
+
bounce: options.bounce ?? animation.bounce,
|
|
11
|
+
color: options.color,
|
|
12
|
+
colorFade: options.colorFade ?? animation.colorFade,
|
|
13
|
+
skipUnchanged: options.skipUnchanged ?? animation.skipUnchanged,
|
|
14
|
+
interrupt: options.interrupt ?? animation.interrupt,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Return stable pseudo-random variation in the range -1 to 1. */
|
|
18
|
+
function getDeterministicVariation(segmentIndex, variationChannel) {
|
|
19
|
+
// Start at one so the first grapheme never uses a zero seed.
|
|
20
|
+
const oneBasedSegmentIndex = segmentIndex + 1;
|
|
21
|
+
const pseudoRandomSeed = Math.sin(oneBasedSegmentIndex * variation.indexFrequency +
|
|
22
|
+
variationChannel * variation.channelFrequency) * variation.seedScale;
|
|
23
|
+
const zeroToOne = pseudoRandomSeed - Math.floor(pseudoRandomSeed);
|
|
24
|
+
// Stretch 0..1 to 0..2, then center it around zero to get -1..1.
|
|
25
|
+
return zeroToOne * 2 - 1;
|
|
26
|
+
}
|
|
27
|
+
export function calculateGlyphTiming(segmentIndex, targetSegmentCount, isTrailingSlot, options) {
|
|
28
|
+
const { duration, stagger, bounce } = options;
|
|
29
|
+
const variationMultiplier = (strength, channel) => 1 + bounce * strength * getDeterministicVariation(segmentIndex, channel);
|
|
30
|
+
const durationMs = Math.round(duration *
|
|
31
|
+
(isTrailingSlot ? glyph.tailDurationMultiplier : 1) *
|
|
32
|
+
variationMultiplier(glyph.durationVariationStrength, variation.durationChannel));
|
|
33
|
+
const staggerPosition = isTrailingSlot
|
|
34
|
+
? targetSegmentCount * glyph.tailStaggerStartMultiplier +
|
|
35
|
+
(segmentIndex - targetSegmentCount) *
|
|
36
|
+
glyph.tailStaggerStepMultiplier
|
|
37
|
+
: segmentIndex;
|
|
38
|
+
const startingTiltDegrees = Number((bounce *
|
|
39
|
+
glyph.maximumTiltDegrees *
|
|
40
|
+
getDeterministicVariation(segmentIndex, variation.tiltChannel)).toFixed(glyph.tiltDecimalPlaces));
|
|
41
|
+
return {
|
|
42
|
+
durationMs,
|
|
43
|
+
startDelayMs: Math.round(staggerPosition *
|
|
44
|
+
stagger *
|
|
45
|
+
variationMultiplier(glyph.staggerVariationStrength, variation.staggerChannel)),
|
|
46
|
+
startingTiltDegrees,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function calculateWidthTiming(currentSegment, targetSegment, timing) {
|
|
50
|
+
const scaledDuration = (multiplier) => Math.max(width.minimumTransitionMs, Math.round(timing.durationMs * multiplier));
|
|
51
|
+
if (targetSegment === "") {
|
|
52
|
+
return {
|
|
53
|
+
startDelayMs: timing.startDelayMs +
|
|
54
|
+
Math.round(timing.durationMs * width.tailCollapseDelayMultiplier),
|
|
55
|
+
durationMs: scaledDuration(width.tailCollapseDurationMultiplier),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (currentSegment === "") {
|
|
59
|
+
return {
|
|
60
|
+
startDelayMs: timing.startDelayMs,
|
|
61
|
+
durationMs: scaledDuration(width.newSlotExpansionDurationMultiplier),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
startDelayMs: timing.startDelayMs,
|
|
66
|
+
durationMs: timing.durationMs,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/vue.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, } from "vue";
|
|
2
|
-
import { animateSlotText,
|
|
2
|
+
import { animateSlotText, clearSlotText, renderTextWithCssFallback, } from "./slotText.js";
|
|
3
3
|
export const SlotText = defineComponent({
|
|
4
4
|
name: "SlotText",
|
|
5
5
|
inheritAttrs: false,
|
|
@@ -19,7 +19,7 @@ export const SlotText = defineComponent({
|
|
|
19
19
|
onMounted(() => {
|
|
20
20
|
if (!element.value)
|
|
21
21
|
return;
|
|
22
|
-
|
|
22
|
+
renderTextWithCssFallback(element.value, props.text);
|
|
23
23
|
mounted = true;
|
|
24
24
|
});
|
|
25
25
|
watch(() => props.text, (text) => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>slot-text
|
|
6
|
+
<title>slot-text playground</title>
|
|
7
7
|
<link rel="stylesheet" href="../../style.css" />
|
|
8
8
|
<style>
|
|
9
9
|
body {
|
|
@@ -11,24 +11,63 @@
|
|
|
11
11
|
margin: 0;
|
|
12
12
|
display: grid;
|
|
13
13
|
place-items: center;
|
|
14
|
-
font:
|
|
14
|
+
font:
|
|
15
|
+
600 16px/1.2 ui-sans-serif,
|
|
16
|
+
system-ui,
|
|
17
|
+
sans-serif;
|
|
15
18
|
background: #0e1116;
|
|
16
19
|
color: #edf2f7;
|
|
20
|
+
-webkit-font-smoothing: antialiased;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
main {
|
|
24
|
+
display: grid;
|
|
25
|
+
gap: 16px;
|
|
26
|
+
justify-items: center;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
p {
|
|
30
|
+
margin: 0;
|
|
31
|
+
color: #9ca3af;
|
|
32
|
+
font-size: 13px;
|
|
33
|
+
font-weight: 500;
|
|
34
|
+
letter-spacing: 0.02em;
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
button {
|
|
20
|
-
|
|
21
|
-
|
|
38
|
+
--button-text-color: #0c50c6;
|
|
39
|
+
min-width: 112px;
|
|
40
|
+
min-height: 44px;
|
|
41
|
+
border: 0;
|
|
42
|
+
border-radius: 12px;
|
|
22
43
|
padding: 12px 18px;
|
|
23
|
-
background: #
|
|
24
|
-
color:
|
|
44
|
+
background: #edf2f7;
|
|
45
|
+
color: var(--button-text-color);
|
|
25
46
|
font: inherit;
|
|
26
47
|
cursor: pointer;
|
|
48
|
+
box-shadow: 0 1px 1px rgb(0 0 0 / 20%);
|
|
49
|
+
transition: transform 160ms cubic-bezier(0.2, 0, 0, 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
button:hover {
|
|
53
|
+
transform: translateY(-1px);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
button:active {
|
|
57
|
+
transform: scale(0.96);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#copy-label,
|
|
61
|
+
#copy-label .char-face {
|
|
62
|
+
color: var(--button-text-color);
|
|
27
63
|
}
|
|
28
64
|
</style>
|
|
29
65
|
</head>
|
|
30
66
|
<body>
|
|
31
|
-
<
|
|
67
|
+
<main>
|
|
68
|
+
<p>Click the button to test slot-text.</p>
|
|
69
|
+
<button type="button"><span id="copy-label"></span></button>
|
|
70
|
+
</main>
|
|
32
71
|
|
|
33
72
|
<script type="module">
|
|
34
73
|
import { slotText, chromatic } from "../../dist/index.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>slot-text delayed CSS example</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
min-height: 100vh;
|
|
10
|
+
margin: 0;
|
|
11
|
+
display: grid;
|
|
12
|
+
place-items: center;
|
|
13
|
+
font: 800 48px/1.1 ui-sans-serif, system-ui, sans-serif;
|
|
14
|
+
}
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<button type="button"><span id="label">Take the next step</span></button>
|
|
19
|
+
|
|
20
|
+
<script type="module">
|
|
21
|
+
import { slotText } from "../../dist/index.js";
|
|
22
|
+
|
|
23
|
+
const STYLESHEET_DELAY_MS = 900;
|
|
24
|
+
|
|
25
|
+
const label = slotText(
|
|
26
|
+
document.querySelector("#label"),
|
|
27
|
+
"Take the next step",
|
|
28
|
+
);
|
|
29
|
+
label.set("Continue");
|
|
30
|
+
|
|
31
|
+
window.setTimeout(() => {
|
|
32
|
+
const stylesheet = document.createElement("link");
|
|
33
|
+
stylesheet.rel = "stylesheet";
|
|
34
|
+
stylesheet.href = "../../style.css";
|
|
35
|
+
document.head.appendChild(stylesheet);
|
|
36
|
+
}, STYLESHEET_DELAY_MS);
|
|
37
|
+
</script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
package/package.json
CHANGED
package/style.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
.slot-text {
|
|
2
2
|
display: inline-flex;
|
|
3
3
|
white-space: pre;
|
|
4
|
+
align-items: baseline;
|
|
4
5
|
}
|
|
5
6
|
|
|
6
7
|
.char-slot {
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
instead of letting the row overflow like it does at rest. */
|
|
13
14
|
flex: none;
|
|
14
15
|
justify-content: center;
|
|
16
|
+
align-items: baseline;
|
|
15
17
|
/* Clip only vertically: the roll needs a top/bottom mask, but glyph side
|
|
16
18
|
bearings, kerning overhang and the settle tilt must stay visible so
|
|
17
19
|
letters never look cropped. (overflow: hidden is the legacy fallback.) */
|
|
@@ -19,7 +21,6 @@
|
|
|
19
21
|
overflow-x: visible;
|
|
20
22
|
overflow-y: clip;
|
|
21
23
|
line-height: 1.3;
|
|
22
|
-
vertical-align: bottom;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/* Cells appearing from or collapsing to empty change width drastically, so
|