fancy-ui-svelte 0.12.0 → 0.14.0
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/dist/fancy-ui/button/Button.svelte +196 -9
- package/dist/fancy-ui/button/Button.test.js +117 -3
- package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte +32 -0
- package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte.d.ts +3 -0
- package/dist/fancy-ui/button/README.md +13 -2
- package/dist/fancy-ui/compare/Compare.svelte +404 -47
- package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
- package/dist/fancy-ui/compare/Compare.test.js +82 -2
- package/dist/fancy-ui/compare/README.md +9 -2
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
- package/dist/fancy-ui/datamosh-transition/README.md +87 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
- package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
- package/dist/fancy-ui/datamosh-transition/index.js +3 -0
- package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
- package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
- package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
- package/dist/fancy-ui/flip-card/README.md +41 -14
- package/dist/fancy-ui/flip-card/index.d.ts +1 -1
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +97 -11
- package/dist/fancy-ui/fluid-cursor/README.md +3 -1
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +18 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.js +22 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +17 -1
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +6 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +32 -9
- package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
- package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
- package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
- package/dist/fancy-ui/glow-border/README.md +25 -33
- package/dist/fancy-ui/glow-border/index.d.ts +1 -1
- package/dist/fancy-ui/glow-border/index.js +1 -1
- package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
- package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
- package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
- package/dist/fancy-ui/gradient-button/README.md +14 -6
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
- package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
- package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
- package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
- package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
- package/dist/fancy-ui/meteors/README.md +21 -6
- package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
- package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
- package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
- package/dist/fancy-ui/neon-border/README.md +21 -11
- package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
- package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
- package/dist/fancy-ui/pagination/README.md +32 -22
- package/dist/fancy-ui/rainbow-button/README.md +3 -2
- package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
- package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
- package/dist/fancy-ui/registry.js +256 -41
- package/dist/fancy-ui/ripple-button/README.md +10 -7
- package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
- package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
- package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
- package/dist/fancy-ui/shimmer-button/README.md +14 -6
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
- package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
- package/package.json +1 -1
- package/dist/fancy-ui/themes.d.ts +0 -38
- package/dist/fancy-ui/themes.js +0 -356
|
@@ -48,8 +48,11 @@
|
|
|
48
48
|
</script>
|
|
49
49
|
|
|
50
50
|
<script lang="ts">
|
|
51
|
+
import { fade } from "svelte/transition";
|
|
51
52
|
import { cn } from "../../utils.js";
|
|
52
53
|
import { sound as soundFx } from "../sound/sound.svelte.js";
|
|
54
|
+
import { prefersReducedMotion } from "../_internals/motion/anchored.js";
|
|
55
|
+
import { DURATIONS } from "../_internals/motion/tokens.js";
|
|
53
56
|
|
|
54
57
|
let {
|
|
55
58
|
variant = "primary",
|
|
@@ -93,7 +96,14 @@
|
|
|
93
96
|
cn(
|
|
94
97
|
"ft-btn",
|
|
95
98
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium",
|
|
96
|
-
|
|
99
|
+
// No `transition-colors` here: the scoped style block below declares a
|
|
100
|
+
// `transition` shorthand on this same element (it has to, so the press
|
|
101
|
+
// scale can join the colour channel under `prefers-reduced-motion:
|
|
102
|
+
// no-preference`), and Svelte's scoped CSS is unlayered while Tailwind's
|
|
103
|
+
// utilities sit in `@layer utilities` — the utility would lose silently
|
|
104
|
+
// and read as a colour transition that never ran. The colour channel is
|
|
105
|
+
// re-declared by hand at exactly the values it resolved to.
|
|
106
|
+
"cursor-pointer",
|
|
97
107
|
"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--ft-btn-accent)]/35",
|
|
98
108
|
// `data-disabled` covers the anchor branch, which has no native `:disabled`
|
|
99
109
|
// pseudo-class to hang the same dimmed treatment off. It tracks `disabled`
|
|
@@ -132,6 +142,21 @@
|
|
|
132
142
|
// can't drift out of sync with each other.
|
|
133
143
|
const anchorInert = $derived(disabled || loading);
|
|
134
144
|
|
|
145
|
+
// The one duration the lead slot's cross-fade runs on: `DURATIONS.micro`, the
|
|
146
|
+
// glyph-scale rung, collapsed to 0 when the user has asked for less motion —
|
|
147
|
+
// at 0 Svelte skips `element.animate()` entirely and the swap is synchronous,
|
|
148
|
+
// which is the same behaviour this button had before the fade existed.
|
|
149
|
+
//
|
|
150
|
+
// Deliberately a plain function, never a `$derived`: `prefersReducedMotion()`
|
|
151
|
+
// resolves `window.matchMedia` fresh on every call and its own contract
|
|
152
|
+
// (`_internals/motion/anchored.ts`) forbids reading it from a `$derived`,
|
|
153
|
+
// where the answer would be computed once and then never revisited. A
|
|
154
|
+
// transition's params thunk runs once per transition, at the instant that
|
|
155
|
+
// transition starts — exactly the call site that contract sanctions.
|
|
156
|
+
function leadFade() {
|
|
157
|
+
return prefersReducedMotion() ? 0 : DURATIONS.micro;
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
// The single guard both branches funnel through. A native `disabled` button
|
|
136
161
|
// already refuses real pointer/keyboard input, but a synthetic `.click()` (or
|
|
137
162
|
// an anchor, which has no disabled state at all) walks straight past that —
|
|
@@ -160,10 +185,29 @@
|
|
|
160
185
|
tabindex={anchorInert ? -1 : undefined}
|
|
161
186
|
onclick={handleClick}
|
|
162
187
|
>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
188
|
+
<!--
|
|
189
|
+
The lead slot. Both branches of this component render it identically —
|
|
190
|
+
see the button branch below for why the split `in:` / `out:` pair is
|
|
191
|
+
the right shape here rather than one bidirectional `transition:`.
|
|
192
|
+
-->
|
|
193
|
+
{#if loading || iconStart}
|
|
194
|
+
<span class="ft-btn-lead" aria-hidden={loading ? "true" : undefined}>
|
|
195
|
+
{#if loading}
|
|
196
|
+
<span
|
|
197
|
+
class="ft-btn-spinner"
|
|
198
|
+
in:fade={{ duration: leadFade() }}
|
|
199
|
+
out:fade={{ duration: leadFade() }}
|
|
200
|
+
></span>
|
|
201
|
+
{:else}
|
|
202
|
+
<span
|
|
203
|
+
class="ft-btn-lead-icon"
|
|
204
|
+
in:fade={{ duration: leadFade() }}
|
|
205
|
+
out:fade={{ duration: leadFade() }}
|
|
206
|
+
>
|
|
207
|
+
{@render iconStart?.()}
|
|
208
|
+
</span>
|
|
209
|
+
{/if}
|
|
210
|
+
</span>
|
|
167
211
|
{/if}
|
|
168
212
|
{@render children?.()}
|
|
169
213
|
{#if iconEnd}
|
|
@@ -180,10 +224,46 @@
|
|
|
180
224
|
aria-busy={loading ? "true" : undefined}
|
|
181
225
|
onclick={handleClick}
|
|
182
226
|
>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
<!--
|
|
228
|
+
The lead slot: one fixed-size cell that holds the spinner and
|
|
229
|
+
`iconStart` at the same time, so `loading` swaps them by cross-fading
|
|
230
|
+
in place instead of cutting, and the label beside it never shifts.
|
|
231
|
+
|
|
232
|
+
A split `in:` / `out:` pair rather than one bidirectional
|
|
233
|
+
`transition:`, which is the shape the rest of this campaign uses. Two
|
|
234
|
+
reasons, both structural: the spinner and the icon are DIFFERENT
|
|
235
|
+
elements, so there is no single node whose transition could be
|
|
236
|
+
bidirectional; and a cross-fade wants both halves running at once
|
|
237
|
+
rather than one after the other. The outer `{#if loading || iconStart}`
|
|
238
|
+
keeps a button with neither from paying for an empty grid cell — and,
|
|
239
|
+
because a local transition never plays on the initial render of the
|
|
240
|
+
block that owns it, a button that starts out `loading` still renders
|
|
241
|
+
its spinner instantly, with no fade in from nothing.
|
|
242
|
+
|
|
243
|
+
`aria-hidden` sits on the slot rather than on the spinner so that it
|
|
244
|
+
covers the whole cell for the length of the fade: mid-swap the
|
|
245
|
+
outgoing icon is still mounted, and a screen reader has no business
|
|
246
|
+
reading a glyph that is on its way out of a control already marked
|
|
247
|
+
`aria-busy`.
|
|
248
|
+
-->
|
|
249
|
+
{#if loading || iconStart}
|
|
250
|
+
<span class="ft-btn-lead" aria-hidden={loading ? "true" : undefined}>
|
|
251
|
+
{#if loading}
|
|
252
|
+
<span
|
|
253
|
+
class="ft-btn-spinner"
|
|
254
|
+
in:fade={{ duration: leadFade() }}
|
|
255
|
+
out:fade={{ duration: leadFade() }}
|
|
256
|
+
></span>
|
|
257
|
+
{:else}
|
|
258
|
+
<span
|
|
259
|
+
class="ft-btn-lead-icon"
|
|
260
|
+
in:fade={{ duration: leadFade() }}
|
|
261
|
+
out:fade={{ duration: leadFade() }}
|
|
262
|
+
>
|
|
263
|
+
{@render iconStart?.()}
|
|
264
|
+
</span>
|
|
265
|
+
{/if}
|
|
266
|
+
</span>
|
|
187
267
|
{/if}
|
|
188
268
|
{@render children?.()}
|
|
189
269
|
{#if iconEnd}
|
|
@@ -198,6 +278,113 @@
|
|
|
198
278
|
--ft-accent,
|
|
199
279
|
light-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))
|
|
200
280
|
);
|
|
281
|
+
/* 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) =
|
|
282
|
+
tokens.EASINGS.inout — the reversible-state pair. It runs the colour
|
|
283
|
+
channel, and the release half of the press: letting go settles, it
|
|
284
|
+
does not snap back. */
|
|
285
|
+
--ft-btn-motion: var(--ft-duration-fast, 150ms)
|
|
286
|
+
var(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));
|
|
287
|
+
/* The down-stroke only. 150ms = tokens.DURATIONS.fast,
|
|
288
|
+
cubic-bezier(0.16, 1, 0.3, 1) = tokens.EASINGS.out — the arrival
|
|
289
|
+
curve, which spends most of the scale in the first frames. Same clock
|
|
290
|
+
as the release, but the button is already down by the time the eye
|
|
291
|
+
reads it, so the press bites instead of easing in. Pressing is an
|
|
292
|
+
arrival; releasing is what resolves either way. */
|
|
293
|
+
--ft-btn-press: var(--ft-duration-fast, 150ms) var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
|
|
294
|
+
/*
|
|
295
|
+
* Replaces the `transition-colors` utility removed from the class string
|
|
296
|
+
* above, at exactly the values that utility already resolved to. Colour is
|
|
297
|
+
* a state change, not motion, so it stays OUTSIDE the reduced-motion query
|
|
298
|
+
* — gating it would only make a theme flip look broken for the people who
|
|
299
|
+
* asked for less movement.
|
|
300
|
+
*
|
|
301
|
+
* `box-shadow` is deliberately absent, and must stay absent: this button's
|
|
302
|
+
* focus ring is `focus-visible:ring-*`, which compiles to a `box-shadow`,
|
|
303
|
+
* and a focus ring must never animate. `text-decoration-color`, `fill` and
|
|
304
|
+
* `stroke` never change on this control, so the three listed here are the
|
|
305
|
+
* faithful subset of what the utility covered.
|
|
306
|
+
*/
|
|
307
|
+
transition:
|
|
308
|
+
color var(--ft-btn-motion),
|
|
309
|
+
background-color var(--ft-btn-motion),
|
|
310
|
+
border-color var(--ft-btn-motion);
|
|
311
|
+
/* Kills the ~300ms tap delay without blocking scroll — the same rule, for
|
|
312
|
+
the same reason, as `.ft-pressable` and `.ft-toggle`. A press that
|
|
313
|
+
answers a third of a second late is not press feedback. */
|
|
314
|
+
touch-action: manipulation;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/*
|
|
318
|
+
* The press. Pressable's contract inlined as a plain rule on the native
|
|
319
|
+
* control rather than wrapping a `<button>` in a `<Pressable>` div — same
|
|
320
|
+
* `0.97`, same clock, one element instead of two.
|
|
321
|
+
*
|
|
322
|
+
* Only the property list is re-declared in here: `transform` joins the colour
|
|
323
|
+
* channel under `no-preference` and nowhere else, so with motion reduced the
|
|
324
|
+
* colours still cross and the button simply does not move. The resting state
|
|
325
|
+
* (no `transform` at all) is the ungated fallback.
|
|
326
|
+
*
|
|
327
|
+
* The two strokes are timed apart. The rule below carries the release curve,
|
|
328
|
+
* because that is the transition the button runs on its way back to rest;
|
|
329
|
+
* `:active` overrides `transform` with the arrival curve for the way down.
|
|
330
|
+
*/
|
|
331
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
332
|
+
.ft-btn {
|
|
333
|
+
transition:
|
|
334
|
+
color var(--ft-btn-motion),
|
|
335
|
+
background-color var(--ft-btn-motion),
|
|
336
|
+
border-color var(--ft-btn-motion),
|
|
337
|
+
transform var(--ft-btn-motion);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* Both inert states are excluded so a press that does nothing does not
|
|
341
|
+
pretend to. `[data-disabled]` is the anchor branch, which has no native
|
|
342
|
+
`:disabled` to hang this off; `[aria-busy]` is the loading one, on both
|
|
343
|
+
branches. A native disabled `<button>` never matches `:active` anyway —
|
|
344
|
+
the attribute selectors are what make the anchor behave like it. */
|
|
345
|
+
.ft-btn:not([data-disabled="true"]):not([aria-busy="true"]):active {
|
|
346
|
+
transform: scale(0.97);
|
|
347
|
+
/* The whole list is re-declared rather than just
|
|
348
|
+
`transition-timing-function`, which has no per-property form here:
|
|
349
|
+
a single value would retime the colour channel too, and a
|
|
350
|
+
four-value one would restate the same three curves to change the
|
|
351
|
+
fourth. Only `transform` differs from the rule above. */
|
|
352
|
+
transition:
|
|
353
|
+
color var(--ft-btn-motion),
|
|
354
|
+
background-color var(--ft-btn-motion),
|
|
355
|
+
border-color var(--ft-btn-motion),
|
|
356
|
+
transform var(--ft-btn-press);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/*
|
|
361
|
+
* The lead slot: one fixed cell the spinner and `iconStart` share, sized
|
|
362
|
+
* exactly like the spinner it holds (`calc(1em + 1px)`, read from the
|
|
363
|
+
* button's own font-size, so it follows the size variant for free). Fixed
|
|
364
|
+
* rather than content-sized on purpose — a slot that measured whichever
|
|
365
|
+
* child happened to be mounted would grow while both are cross-fading and
|
|
366
|
+
* snap back the frame the fade ended, moving the label twice for one swap.
|
|
367
|
+
*/
|
|
368
|
+
.ft-btn-lead {
|
|
369
|
+
display: grid;
|
|
370
|
+
place-items: center;
|
|
371
|
+
flex: none;
|
|
372
|
+
width: calc(1em + 1px);
|
|
373
|
+
height: calc(1em + 1px);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* Both children occupy the one cell at once — that overlap IS the
|
|
377
|
+
cross-fade. Named rather than `> *` so a caller's own `iconStart` markup,
|
|
378
|
+
which is one level further down, is never caught by it. */
|
|
379
|
+
.ft-btn-lead > .ft-btn-spinner,
|
|
380
|
+
.ft-btn-lead > .ft-btn-lead-icon {
|
|
381
|
+
grid-area: 1 / 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.ft-btn-lead-icon {
|
|
385
|
+
display: inline-flex;
|
|
386
|
+
align-items: center;
|
|
387
|
+
justify-content: center;
|
|
201
388
|
}
|
|
202
389
|
|
|
203
390
|
.ft-btn--accent {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { render, cleanup, fireEvent } from "@testing-library/svelte";
|
|
2
|
-
import { createRawSnippet } from "svelte";
|
|
1
|
+
import { render, cleanup, fireEvent, waitFor } from "@testing-library/svelte";
|
|
2
|
+
import { createRawSnippet, tick } from "svelte";
|
|
3
3
|
import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
4
|
import Button from "./Button.svelte";
|
|
5
|
+
import LeadHarness from "./ButtonLeadHarness.test.svelte";
|
|
5
6
|
import { sound } from "../sound/sound.svelte.js";
|
|
6
7
|
function snippet(html) {
|
|
7
8
|
return createRawSnippet(() => ({ render: () => html }));
|
|
@@ -9,8 +10,27 @@ function snippet(html) {
|
|
|
9
10
|
function root(container) {
|
|
10
11
|
return container.firstElementChild;
|
|
11
12
|
}
|
|
13
|
+
/** Replaces `window.matchMedia` wholesale — the pattern the rest of the repo
|
|
14
|
+
* uses. `prefersReducedMotion()` resolves it fresh on every call, and the
|
|
15
|
+
* lead fade reads it from its transition params thunk, so an override
|
|
16
|
+
* installed before a render is visible to the very next swap. */
|
|
17
|
+
function stubReducedMotion(matches) {
|
|
18
|
+
vi.stubGlobal("matchMedia", (query) => ({
|
|
19
|
+
matches,
|
|
20
|
+
media: query,
|
|
21
|
+
onchange: null,
|
|
22
|
+
addEventListener: () => { },
|
|
23
|
+
removeEventListener: () => { },
|
|
24
|
+
dispatchEvent: () => false,
|
|
25
|
+
addListener: () => { },
|
|
26
|
+
removeListener: () => { },
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
12
29
|
describe("Button", () => {
|
|
13
|
-
afterEach(
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
});
|
|
14
34
|
it("renders a real button with children as its label", () => {
|
|
15
35
|
const { container } = render(Button, { props: { children: snippet("<span>Save</span>") } });
|
|
16
36
|
const el = root(container);
|
|
@@ -229,6 +249,100 @@ describe("Button", () => {
|
|
|
229
249
|
// alone; only the base `bg-primary` is a real conflict with `bg-red-500`.
|
|
230
250
|
expect(classList).not.toContain("bg-primary");
|
|
231
251
|
});
|
|
252
|
+
// The scoped `<style>` now declares a `transition` shorthand on this same
|
|
253
|
+
// element, so the press scale can join the colour channel under
|
|
254
|
+
// `prefers-reduced-motion: no-preference`. Svelte's scoped CSS is unlayered
|
|
255
|
+
// and Tailwind's utilities sit in `@layer utilities`, so leaving
|
|
256
|
+
// `transition-colors` on the class string would read as a colour transition
|
|
257
|
+
// that silently never ran.
|
|
258
|
+
it("drops the transition-colors utility in favour of the hand-written channel", () => {
|
|
259
|
+
const { container } = render(Button, { props: { children: snippet("<span>Go</span>") } });
|
|
260
|
+
expect(root(container).className).not.toContain("transition-colors");
|
|
261
|
+
});
|
|
262
|
+
describe("lead slot", () => {
|
|
263
|
+
const leadProps = {
|
|
264
|
+
iconStart: snippet('<span class="my-icon-start">+</span>'),
|
|
265
|
+
children: snippet("<span>Save</span>"),
|
|
266
|
+
};
|
|
267
|
+
function toggleOf(container) {
|
|
268
|
+
return container.querySelector('[data-testid="toggle"]');
|
|
269
|
+
}
|
|
270
|
+
it("wraps iconStart in the shared lead cell, exposed to assistive tech", () => {
|
|
271
|
+
const { container } = render(Button, { props: leadProps });
|
|
272
|
+
const lead = container.querySelector(".ft-btn-lead");
|
|
273
|
+
expect(lead).toBeTruthy();
|
|
274
|
+
expect(lead?.querySelector(".my-icon-start")).toBeTruthy();
|
|
275
|
+
// Not loading: nothing here is decorative, so the caller's own icon
|
|
276
|
+
// markup keeps whatever accessible treatment the caller gave it.
|
|
277
|
+
expect(lead?.hasAttribute("aria-hidden")).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
it("renders no lead cell at all when there is neither a spinner nor an iconStart", () => {
|
|
280
|
+
const { container } = render(Button, {
|
|
281
|
+
props: { children: snippet("<span>Save</span>") },
|
|
282
|
+
});
|
|
283
|
+
expect(container.querySelector(".ft-btn-lead")).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
it("keeps aria-busy on the control and aria-hidden on the cell, never the other way round", () => {
|
|
286
|
+
const { container } = render(Button, { props: { ...leadProps, loading: true } });
|
|
287
|
+
const el = root(container);
|
|
288
|
+
const lead = container.querySelector(".ft-btn-lead");
|
|
289
|
+
expect(el.getAttribute("aria-busy")).toBe("true");
|
|
290
|
+
expect(el.hasAttribute("aria-hidden")).toBe(false);
|
|
291
|
+
expect(lead.getAttribute("aria-hidden")).toBe("true");
|
|
292
|
+
expect(lead.hasAttribute("aria-busy")).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
it("renders the same lead cell on the anchor branch", () => {
|
|
295
|
+
const { container } = render(Button, {
|
|
296
|
+
props: { ...leadProps, href: "https://example.com" },
|
|
297
|
+
});
|
|
298
|
+
const lead = container.querySelector(".ft-btn-lead");
|
|
299
|
+
expect(root(container).tagName).toBe("A");
|
|
300
|
+
expect(lead?.querySelector(".my-icon-start")).toBeTruthy();
|
|
301
|
+
});
|
|
302
|
+
// The proof the cross-fade exists at all. Every static-render assertion
|
|
303
|
+
// above and at `:101` stays green whether or not the fade was ever wired
|
|
304
|
+
// up, because a local transition never plays on the initial render of the
|
|
305
|
+
// block that owns it — only a live toggle can tell the two apart.
|
|
306
|
+
it("holds the icon and the spinner in the DOM together for the length of the cross-fade", async () => {
|
|
307
|
+
const { container } = render(LeadHarness);
|
|
308
|
+
const toggle = toggleOf(container);
|
|
309
|
+
expect(container.querySelector(".my-icon-start")).toBeTruthy();
|
|
310
|
+
expect(container.querySelector(".ft-btn-spinner")).toBeNull();
|
|
311
|
+
toggle.click();
|
|
312
|
+
await tick();
|
|
313
|
+
// Both mounted, sharing the one grid cell: that overlap IS the fade.
|
|
314
|
+
// Without it the icon would already be gone in this same flush.
|
|
315
|
+
expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
|
|
316
|
+
expect(container.querySelector(".my-icon-start")).toBeTruthy();
|
|
317
|
+
// And it settles on the spinner alone once the fade finishes.
|
|
318
|
+
await waitFor(() => expect(container.querySelector(".my-icon-start")).toBeNull());
|
|
319
|
+
expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
|
|
320
|
+
});
|
|
321
|
+
it("cross-fades back the other way when loading clears", async () => {
|
|
322
|
+
const { container } = render(LeadHarness);
|
|
323
|
+
const toggle = toggleOf(container);
|
|
324
|
+
toggle.click();
|
|
325
|
+
await waitFor(() => expect(container.querySelector(".my-icon-start")).toBeNull());
|
|
326
|
+
toggle.click();
|
|
327
|
+
await tick();
|
|
328
|
+
expect(container.querySelector(".my-icon-start")).toBeTruthy();
|
|
329
|
+
expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
|
|
330
|
+
await waitFor(() => expect(container.querySelector(".ft-btn-spinner")).toBeNull());
|
|
331
|
+
expect(container.querySelector(".my-icon-start")).toBeTruthy();
|
|
332
|
+
});
|
|
333
|
+
it("reduced motion: the swap is synchronous, with no window where both are mounted", async () => {
|
|
334
|
+
stubReducedMotion(true);
|
|
335
|
+
const { container } = render(LeadHarness);
|
|
336
|
+
const toggle = toggleOf(container);
|
|
337
|
+
toggle.click();
|
|
338
|
+
await tick();
|
|
339
|
+
// Duration 0 makes Svelte skip `element.animate()` entirely, so the
|
|
340
|
+
// outgoing icon is gone in the same flush that mounts the spinner —
|
|
341
|
+
// exactly the behaviour this button had before the fade existed.
|
|
342
|
+
expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
|
|
343
|
+
expect(container.querySelector(".my-icon-start")).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
232
346
|
describe("sound", () => {
|
|
233
347
|
let play;
|
|
234
348
|
beforeEach(() => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Test-only rig for the lead slot's spinner ↔ `iconStart` cross-fade.
|
|
3
|
+
|
|
4
|
+
`loading` is held as local `$state` and flipped from a button inside this
|
|
5
|
+
component, deliberately, rather than driven from the test through
|
|
6
|
+
`rerender()`: `rerender` awaits a tick of its own, so a test using it can
|
|
7
|
+
never observe the ONE flush the cross-fade lives in — by the time it returns,
|
|
8
|
+
the stubbed Web Animations chain (a dummy animation on one microtask, the
|
|
9
|
+
real one on the next) has already resolved and the outgoing node has left
|
|
10
|
+
either way. A raw `.click()` flips the state synchronously, so a single
|
|
11
|
+
`await tick()` after it lands inside the fade window.
|
|
12
|
+
|
|
13
|
+
Starts NOT loading on purpose: a local transition never plays on the initial
|
|
14
|
+
render of the block that owns it, so the first click is the first genuine
|
|
15
|
+
swap, and the second click the genuine swap back.
|
|
16
|
+
|
|
17
|
+
Not exported from index.ts, and not collected by Vitest directly (the run
|
|
18
|
+
includes `*.test.ts` only) — imported and rendered from Button.test.ts.
|
|
19
|
+
-->
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import Button from "./Button.svelte";
|
|
22
|
+
|
|
23
|
+
let isLoading = $state(false);
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<button type="button" data-testid="toggle" onclick={() => (isLoading = !isLoading)}>toggle</button>
|
|
27
|
+
|
|
28
|
+
{#snippet plus()}
|
|
29
|
+
<span class="my-icon-start">+</span>
|
|
30
|
+
{/snippet}
|
|
31
|
+
|
|
32
|
+
<Button loading={isLoading} iconStart={plus}>Save</Button>
|
|
@@ -20,7 +20,7 @@ The library's foundational push-button: six variants, three sizes, a loading sta
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
```svelte
|
|
23
|
-
<!-- Loading:
|
|
23
|
+
<!-- Loading: cross-fades the leading icon into a spinner, keeps the button's own colours -->
|
|
24
24
|
<Button loading={saving} onclick={save}>Save changes</Button>
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -93,10 +93,21 @@ Set `sound` to play the `press` cue on activation, through the shared sound cont
|
|
|
93
93
|
|
|
94
94
|
It is opt-in and silent by default: nothing plays unless both `sound` is set on the button **and** the user has turned sound on globally (through `SoundToggle` or `sound.enable()`). `disabled` and `loading` block the cue exactly like they block `onclick` — nothing plays for a click that never fires the callback either.
|
|
95
95
|
|
|
96
|
+
## Motion
|
|
97
|
+
|
|
98
|
+
- **The lead slot cross-fades.** `iconStart` and the spinner share one fixed-size cell — `calc(1em + 1px)`, the same box the spinner already drew itself in, so it follows the size variant for free. Flipping `loading` fades one out while the other fades in, over the same 80 ms, in place. Both are mounted at once for the length of that fade; the cell never resizes, so the label beside it never shifts. A button that starts out `loading` renders its spinner immediately, with no fade in from nothing, and a button given neither a spinner nor an `iconStart` renders no cell at all.
|
|
99
|
+
- **Pressing scales the button to `0.97`** for as long as it is held. The two strokes are timed apart: going down runs on `--ft-ease-out`, the arrival curve, which spends most of the scale in the first frames so the press bites rather than easing in; coming back up runs on `--ft-ease-inout`, the reversible-state curve, so letting go settles instead of snapping. Same 150 ms clock either way. A disabled button and a `loading` one are both excluded: a press that does nothing should not pretend to.
|
|
100
|
+
- The press transition lists `transform` and the three colour properties, and deliberately never `box-shadow`. This button's focus ring _is_ a `box-shadow` (`focus-visible:ring-*` compiles to one), and a focus ring must never animate.
|
|
101
|
+
- **Reduced motion.** The press scale is declared entirely inside `@media (prefers-reduced-motion: no-preference)`; without that preference the button simply does not move, and the resting state is the ungated fallback. The lead cross-fade collapses to a duration of `0`, which makes the swap synchronous — exactly the instant cut this button did before the fade existed. The spinner's own rotation is likewise gated, and with motion reduced the ring is still drawn, just still, so `aria-busy` keeps a visible companion. Colour is the one channel that stays ungated in both places: a colour change is not motion, and gating it would only make a theme flip look broken for the people who asked for less movement.
|
|
102
|
+
- **Touch and coarse pointers.** `:active` is exactly the affordance a finger gets, so the press feedback is not suppressed on touch. The button carries `touch-action: manipulation`, which removes the browser's ~300 ms tap delay without blocking scrolling — a button inside a scrollable list stays scrollable while it press-reacts.
|
|
103
|
+
- **Timing.** 80 ms, linear, for the lead cross-fade (a fade needs no curve); 150 ms everywhere else. The colour channel and the press release run on `--ft-ease-inout` (`cubic-bezier(0.4, 0, 0.2, 1)`), the press down-stroke on `--ft-ease-out` (`cubic-bezier(0.16, 1, 0.3, 1)`). The `:active` rule re-declares the whole transition list to retime that one property, because `transition-timing-function` has no per-property form that would not restate the other three curves anyway.
|
|
104
|
+
|
|
96
105
|
## Implementation notes
|
|
97
106
|
|
|
98
107
|
- `href` switches the rendered element from `<button>` to `<a>` — the two share one class list and one `onclick` guard, so behaviour does not fork with the markup.
|
|
99
108
|
- An anchor cannot be `disabled`, and half its activation paths — middle-click firing `auxclick`, "open link in new tab" from the context menu — act on `href`/`target` directly and never reach `onclick` at all. So `disabled` and `loading` are combined into one `anchorInert` flag that strips `href` and `target`, sets `aria-disabled="true"` and `tabindex="-1"`; the click handler still swallows the event too, as a second line of defence for whatever does reach it. `loading` alone must be just as inert as `disabled` — a caller can't tell those attack surfaces apart, so the fix can't either.
|
|
100
109
|
- `loading` is deliberately not `disabled` — the mockup keeps a loading button at full strength (no dimming), because "working" and "unavailable" read as different things. On the button branch this falls out naturally: `loading` never touches the native `disabled` attribute, so `disabled:opacity-50` never triggers, and the shared click guard is what actually stops the callback (proven by a synthetic dispatch that bypasses jsdom's own disabled handling in the test suite). On the anchor branch, `aria-disabled` goes true for `loading` too — inertness has to be visible to assistive tech regardless of _why_ the link is inert — but the dimmed look stays tied to `disabled` alone through a separate `data-disabled` attribute, so a loading anchor reads as busy, not unavailable, exactly like a loading button does.
|
|
101
110
|
- `target="_blank"` always widens `rel` to include `noopener noreferrer`, on top of whatever tokens the caller already passed, closing the `window.opener` reverse-tabnabbing hole even when a caller forgets.
|
|
102
|
-
- The spinner reads its own `font-size` (`calc(1em + 1px)`), so its diameter follows the size variant automatically rather than needing a per-size override; its top edge is `currentColor` and the rest of the ring is `currentColor` at 30% via `color-mix`, so it always matches whichever variant it is drawn on.
|
|
111
|
+
- The spinner reads its own `font-size` (`calc(1em + 1px)`), so its diameter follows the size variant automatically rather than needing a per-size override; its top edge is `currentColor` and the rest of the ring is `currentColor` at 30% via `color-mix`, so it always matches whichever variant it is drawn on. See [Motion](#motion) for the spin's reduced-motion gate.
|
|
112
|
+
- `aria-hidden` sits on the lead cell rather than on the spinner inside it, so it covers the whole cell for the length of the cross-fade: mid-swap the outgoing icon is still mounted, and a screen reader has no business reading a glyph on its way out of a control already marked `aria-busy`. With `loading` false the cell carries no `aria-hidden` at all, so a caller's own `iconStart` markup keeps whatever accessible treatment the caller gave it.
|
|
113
|
+
- The `transition-colors` utility is deliberately absent from the class string. Svelte's scoped `<style>` is unlayered and Tailwind's utilities live in `@layer utilities`, so the scoped `transition` shorthand this button needs (the press scale has to join the colour channel) would win over that utility silently and leave a colour transition that never ran. The colour channel is re-declared by hand instead, at exactly the values the utility resolved to.
|