@moonarc/core 0.1.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/LICENSE +21 -0
- package/README.md +39 -0
- package/package.json +228 -0
- package/src/components/Accordion.astro +110 -0
- package/src/components/Accordion.meta.ts +33 -0
- package/src/components/AnimatedBeam.astro +217 -0
- package/src/components/AnimatedBeam.meta.ts +39 -0
- package/src/components/AnimatedIcons.astro +193 -0
- package/src/components/AnimatedIcons.meta.ts +34 -0
- package/src/components/AnimatedList.astro +305 -0
- package/src/components/AnimatedList.meta.ts +36 -0
- package/src/components/AnimatedSelect.astro +227 -0
- package/src/components/AnimatedSelect.meta.ts +38 -0
- package/src/components/ArrowButton.astro +133 -0
- package/src/components/ArrowButton.meta.ts +32 -0
- package/src/components/Aurora.astro +188 -0
- package/src/components/Aurora.meta.ts +36 -0
- package/src/components/AvatarStack.astro +205 -0
- package/src/components/AvatarStack.meta.ts +37 -0
- package/src/components/BarGrow.astro +123 -0
- package/src/components/BarGrow.meta.ts +42 -0
- package/src/components/BorderBeam.astro +75 -0
- package/src/components/BorderBeam.meta.ts +33 -0
- package/src/components/BrowserFrame.astro +172 -0
- package/src/components/BrowserFrame.meta.ts +36 -0
- package/src/components/BubbleText.astro +90 -0
- package/src/components/BubbleText.meta.ts +38 -0
- package/src/components/CardStack.astro +79 -0
- package/src/components/CardStack.meta.ts +33 -0
- package/src/components/Carousel.astro +147 -0
- package/src/components/Carousel.meta.ts +35 -0
- package/src/components/CheckDraw.astro +159 -0
- package/src/components/CheckDraw.meta.ts +37 -0
- package/src/components/CircularText.astro +103 -0
- package/src/components/CircularText.meta.ts +38 -0
- package/src/components/CodeTabs.astro +213 -0
- package/src/components/CodeTabs.meta.ts +36 -0
- package/src/components/CommandPalette.astro +363 -0
- package/src/components/CommandPalette.meta.ts +40 -0
- package/src/components/Confetti.astro +143 -0
- package/src/components/Confetti.meta.ts +40 -0
- package/src/components/CopyButton.astro +189 -0
- package/src/components/CopyButton.meta.ts +41 -0
- package/src/components/CountUp.astro +134 -0
- package/src/components/CountUp.meta.ts +40 -0
- package/src/components/CountdownFlip.astro +244 -0
- package/src/components/CountdownFlip.meta.ts +39 -0
- package/src/components/CounterPreloader.astro +122 -0
- package/src/components/CounterPreloader.meta.ts +34 -0
- package/src/components/Cursor.astro +177 -0
- package/src/components/Cursor.meta.ts +37 -0
- package/src/components/CursorGrid.astro +113 -0
- package/src/components/CursorGrid.meta.ts +37 -0
- package/src/components/Curtain.astro +305 -0
- package/src/components/Curtain.meta.ts +38 -0
- package/src/components/Dialog.astro +156 -0
- package/src/components/Dialog.meta.ts +37 -0
- package/src/components/Dock.astro +193 -0
- package/src/components/Dock.meta.ts +35 -0
- package/src/components/DotPattern.astro +58 -0
- package/src/components/DotPattern.meta.ts +32 -0
- package/src/components/DottedMap.astro +153 -0
- package/src/components/DottedMap.meta.ts +38 -0
- package/src/components/Drawer.astro +196 -0
- package/src/components/Drawer.meta.ts +35 -0
- package/src/components/DynamicIsland.astro +122 -0
- package/src/components/DynamicIsland.meta.ts +35 -0
- package/src/components/FileTree.astro +207 -0
- package/src/components/FileTree.meta.ts +34 -0
- package/src/components/FilterGrid.astro +170 -0
- package/src/components/FilterGrid.meta.ts +38 -0
- package/src/components/FooterReveal.astro +54 -0
- package/src/components/FooterReveal.meta.ts +31 -0
- package/src/components/GlitchText.astro +145 -0
- package/src/components/GlitchText.meta.ts +39 -0
- package/src/components/Globe.astro +304 -0
- package/src/components/Globe.meta.ts +41 -0
- package/src/components/GlyphField.astro +363 -0
- package/src/components/GlyphField.meta.ts +53 -0
- package/src/components/GradientText.astro +63 -0
- package/src/components/GradientText.meta.ts +35 -0
- package/src/components/GridPattern.astro +55 -0
- package/src/components/GridPattern.meta.ts +32 -0
- package/src/components/HighlightMarker.astro +68 -0
- package/src/components/HighlightMarker.meta.ts +37 -0
- package/src/components/HorizontalScroll.astro +112 -0
- package/src/components/HorizontalScroll.meta.ts +34 -0
- package/src/components/HoverImageLinks.astro +219 -0
- package/src/components/HoverImageLinks.meta.ts +34 -0
- package/src/components/HoverVideo.astro +179 -0
- package/src/components/HoverVideo.meta.ts +37 -0
- package/src/components/ImageCompare.astro +212 -0
- package/src/components/ImageCompare.meta.ts +39 -0
- package/src/components/ImageTrail.astro +169 -0
- package/src/components/ImageTrail.meta.ts +36 -0
- package/src/components/Lightbox.astro +236 -0
- package/src/components/Lightbox.meta.ts +40 -0
- package/src/components/LinkPreview.astro +234 -0
- package/src/components/LinkPreview.meta.ts +39 -0
- package/src/components/LiquidGlass.astro +148 -0
- package/src/components/LiquidGlass.meta.ts +37 -0
- package/src/components/Magnetic.astro +92 -0
- package/src/components/Magnetic.meta.ts +31 -0
- package/src/components/Marquee.astro +165 -0
- package/src/components/Marquee.meta.ts +38 -0
- package/src/components/Meteors.astro +156 -0
- package/src/components/Meteors.meta.ts +36 -0
- package/src/components/Noise.astro +61 -0
- package/src/components/Noise.meta.ts +33 -0
- package/src/components/NumberFlow.astro +143 -0
- package/src/components/NumberFlow.meta.ts +42 -0
- package/src/components/Orb.astro +130 -0
- package/src/components/Orb.meta.ts +39 -0
- package/src/components/OrbitingCircles.astro +119 -0
- package/src/components/OrbitingCircles.meta.ts +36 -0
- package/src/components/OtpInput.astro +215 -0
- package/src/components/OtpInput.meta.ts +42 -0
- package/src/components/PageTransition.astro +121 -0
- package/src/components/PageTransition.meta.ts +33 -0
- package/src/components/Parallax.astro +69 -0
- package/src/components/Parallax.meta.ts +32 -0
- package/src/components/Particles.astro +210 -0
- package/src/components/Particles.meta.ts +37 -0
- package/src/components/PersistPlayer.astro +126 -0
- package/src/components/PersistPlayer.meta.ts +35 -0
- package/src/components/PhoneFrame.astro +161 -0
- package/src/components/PhoneFrame.meta.ts +35 -0
- package/src/components/PillNav.astro +170 -0
- package/src/components/PillNav.meta.ts +37 -0
- package/src/components/Popover.astro +95 -0
- package/src/components/Popover.meta.ts +34 -0
- package/src/components/PriceSwitch.astro +251 -0
- package/src/components/PriceSwitch.meta.ts +41 -0
- package/src/components/Probe.astro +84 -0
- package/src/components/Progress.astro +165 -0
- package/src/components/Progress.meta.ts +40 -0
- package/src/components/ProgressiveBlur.astro +95 -0
- package/src/components/ProgressiveBlur.meta.ts +36 -0
- package/src/components/PromptInput.astro +273 -0
- package/src/components/PromptInput.meta.ts +46 -0
- package/src/components/ReadingToc.astro +153 -0
- package/src/components/ReadingToc.meta.ts +34 -0
- package/src/components/RetroGrid.astro +84 -0
- package/src/components/RetroGrid.meta.ts +36 -0
- package/src/components/Reveal.astro +292 -0
- package/src/components/Reveal.meta.ts +48 -0
- package/src/components/Ripple.astro +94 -0
- package/src/components/Ripple.meta.ts +34 -0
- package/src/components/RotatingText.astro +170 -0
- package/src/components/RotatingText.meta.ts +35 -0
- package/src/components/ScrollExpand.astro +90 -0
- package/src/components/ScrollExpand.meta.ts +35 -0
- package/src/components/ScrollProgress.astro +73 -0
- package/src/components/ScrollProgress.meta.ts +32 -0
- package/src/components/ScrollScrub.astro +96 -0
- package/src/components/ScrollScrub.meta.ts +37 -0
- package/src/components/ShimmerButton.astro +109 -0
- package/src/components/ShimmerButton.meta.ts +37 -0
- package/src/components/ShinyText.astro +72 -0
- package/src/components/ShinyText.meta.ts +35 -0
- package/src/components/Skeleton.astro +76 -0
- package/src/components/Skeleton.meta.ts +32 -0
- package/src/components/Sparkline.astro +168 -0
- package/src/components/Sparkline.meta.ts +43 -0
- package/src/components/Spinner.astro +79 -0
- package/src/components/Spinner.meta.ts +33 -0
- package/src/components/SplitFlap.astro +157 -0
- package/src/components/SplitFlap.meta.ts +39 -0
- package/src/components/SplitText.astro +208 -0
- package/src/components/SplitText.meta.ts +50 -0
- package/src/components/Spotlight.astro +92 -0
- package/src/components/Spotlight.meta.ts +34 -0
- package/src/components/StaggerMenu.astro +301 -0
- package/src/components/StaggerMenu.meta.ts +39 -0
- package/src/components/StatefulButton.astro +148 -0
- package/src/components/StatefulButton.meta.ts +35 -0
- package/src/components/StickyBanner.astro +168 -0
- package/src/components/StickyBanner.meta.ts +45 -0
- package/src/components/StickyReveal.astro +256 -0
- package/src/components/StickyReveal.meta.ts +33 -0
- package/src/components/StrokeDraw.astro +110 -0
- package/src/components/StrokeDraw.meta.ts +43 -0
- package/src/components/Switch.astro +165 -0
- package/src/components/Switch.meta.ts +37 -0
- package/src/components/Tabs.astro +194 -0
- package/src/components/Tabs.meta.ts +47 -0
- package/src/components/TextRoll.astro +78 -0
- package/src/components/TextRoll.meta.ts +36 -0
- package/src/components/TextScramble.astro +107 -0
- package/src/components/TextScramble.meta.ts +35 -0
- package/src/components/ThemeScript.astro +17 -0
- package/src/components/ThemeToggle.astro +182 -0
- package/src/components/ThemeToggle.meta.ts +34 -0
- package/src/components/Tilt.astro +97 -0
- package/src/components/Tilt.meta.ts +34 -0
- package/src/components/Toast.astro +184 -0
- package/src/components/Toast.meta.ts +33 -0
- package/src/components/TracingBeam.astro +113 -0
- package/src/components/TracingBeam.meta.ts +34 -0
- package/src/components/Typewriter.astro +124 -0
- package/src/components/Typewriter.meta.ts +39 -0
- package/src/components/UnderlineHover.astro +61 -0
- package/src/components/UnderlineHover.meta.ts +34 -0
- package/src/components/VanishInput.astro +240 -0
- package/src/components/VanishInput.meta.ts +41 -0
- package/src/components/VideoDialog.astro +230 -0
- package/src/components/VideoDialog.meta.ts +40 -0
- package/src/components/WeightWave.astro +70 -0
- package/src/components/WeightWave.meta.ts +36 -0
- package/src/components/internal/DialogMorph.astro +97 -0
- package/src/components/internal/PromptInputSubmitOnEnter.astro +32 -0
- package/src/index.d.ts +43 -0
- package/src/index.js +60 -0
- package/src/lib/canvas.ts +124 -0
- package/src/lib/confetti.ts +127 -0
- package/src/lib/contrast.ts +116 -0
- package/src/lib/form.ts +231 -0
- package/src/lib/geo.ts +77 -0
- package/src/lib/gsap.ts +58 -0
- package/src/lib/land.ts +11 -0
- package/src/lib/lenis.ts +112 -0
- package/src/lib/meta.ts +79 -0
- package/src/lib/morph.ts +74 -0
- package/src/lib/reduced-motion-plugin.js +299 -0
- package/src/lib/runtime.ts +215 -0
- package/src/lib/support.ts +102 -0
- package/src/lib/theme.ts +18 -0
- package/src/lib/toast.ts +17 -0
- package/src/styles/base.css +218 -0
- package/src/styles/motion.css +43 -0
- package/src/styles/theme.css +397 -0
- package/src/styles/theme.tailwind.css +75 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CopyButton — click copies, the icon turns into a check, the label says so,
|
|
4
|
+
* and both come back. The one thing that needs a script is the clipboard
|
|
5
|
+
* (navigator.clipboard.writeText): the button carries the value or a
|
|
6
|
+
* selector for the element whose text to copy, and the script writes one
|
|
7
|
+
* attribute, data-state, and one line of status text. The icon is
|
|
8
|
+
* AnimatedIcons reading that attribute;
|
|
9
|
+
* the label faces are CSS. A refused clipboard (permission, insecure
|
|
10
|
+
* context) is said out loud — "Copy failed" — not swallowed. The done and
|
|
11
|
+
* error faces hold for the ambient duration token, then return to idle.
|
|
12
|
+
* Two nodes, not one: the button, named by its visible face only, and right
|
|
13
|
+
* after it a visually hidden role="status" span the script writes the done
|
|
14
|
+
* or error label into — the announcement, which a face that only changes
|
|
15
|
+
* opacity never was.
|
|
16
|
+
*/
|
|
17
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
18
|
+
import AnimatedIcons from './AnimatedIcons.astro';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'button'> {
|
|
21
|
+
/** Text to copy. */
|
|
22
|
+
value?: string;
|
|
23
|
+
/** Or: selector of the element whose textContent to copy. */
|
|
24
|
+
target?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
doneLabel?: string;
|
|
27
|
+
errorLabel?: string;
|
|
28
|
+
/** Icon only; the label stays as the accessible name. */
|
|
29
|
+
compact?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { value, target, label = 'Copy', doneLabel = 'Copied', errorLabel = 'Copy failed', compact = false, class: className, ...rest } = Astro.props;
|
|
33
|
+
// unique per render: two CopyButtons on a page (CodeTabs renders one per panel) each announce into their own node
|
|
34
|
+
const statusId = `ma-copy-status-${Math.random().toString(36).slice(2, 7)}`;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<button
|
|
38
|
+
class:list={['ma-copy', compact && 'ma-copy--compact', className]}
|
|
39
|
+
type="button"
|
|
40
|
+
data-ma-copy={statusId}
|
|
41
|
+
data-value={value}
|
|
42
|
+
data-target={target}
|
|
43
|
+
data-state="idle"
|
|
44
|
+
aria-label={compact ? label : undefined}
|
|
45
|
+
{...rest}
|
|
46
|
+
>
|
|
47
|
+
<AnimatedIcons icon="copy" />
|
|
48
|
+
<span class="ma-copy__label" aria-hidden={compact ? 'true' : undefined}>
|
|
49
|
+
<span class="ma-copy__face" data-face="idle">{label}</span>
|
|
50
|
+
<span class="ma-copy__face" data-face="done">{doneLabel}</span>
|
|
51
|
+
<span class="ma-copy__face" data-face="error">{errorLabel}</span>
|
|
52
|
+
</span>
|
|
53
|
+
</button>
|
|
54
|
+
<span class="ma-sr" role="status" id={statusId}></span>
|
|
55
|
+
|
|
56
|
+
<style is:global>
|
|
57
|
+
@layer components {
|
|
58
|
+
:where(.ma-copy) {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 0.5em;
|
|
62
|
+
padding: 0.45em 0.8em;
|
|
63
|
+
border: 1px solid var(--ma-edge);
|
|
64
|
+
border-radius: 0.5em;
|
|
65
|
+
background: var(--ma-panel);
|
|
66
|
+
color: var(--ma-ink);
|
|
67
|
+
font: inherit;
|
|
68
|
+
font-size: 0.875em;
|
|
69
|
+
line-height: 1.2;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
cursor: copy;
|
|
72
|
+
transition:
|
|
73
|
+
border-color var(--ma-duration-fast) var(--ma-ease-out),
|
|
74
|
+
color var(--ma-duration-fast) var(--ma-ease-out);
|
|
75
|
+
}
|
|
76
|
+
:where(.ma-copy:hover) {
|
|
77
|
+
border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
|
|
78
|
+
}
|
|
79
|
+
:where(.ma-copy:focus-visible) {
|
|
80
|
+
outline: 2px solid currentColor;
|
|
81
|
+
outline-offset: 2px;
|
|
82
|
+
}
|
|
83
|
+
:where(.ma-copy--compact) {
|
|
84
|
+
padding: 0.45em;
|
|
85
|
+
}
|
|
86
|
+
:where(.ma-copy--compact .ma-copy__label) {
|
|
87
|
+
display: none;
|
|
88
|
+
}
|
|
89
|
+
:where(.ma-copy__label) {
|
|
90
|
+
display: inline-grid;
|
|
91
|
+
}
|
|
92
|
+
/* inactive faces leave the accessibility tree, so the button is named by the one that shows. visibility transitions
|
|
93
|
+
with the fade: a face that arrives is visible at once, one that leaves stays until it has faded. It runs on
|
|
94
|
+
ease-out, not the preset curve — an overshooting spring would flip it hidden and back mid-fade. Hidden faces keep
|
|
95
|
+
their grid cell, so the width stays. */
|
|
96
|
+
:where(.ma-copy__face) {
|
|
97
|
+
grid-area: 1 / 1;
|
|
98
|
+
opacity: 0;
|
|
99
|
+
visibility: hidden;
|
|
100
|
+
translate: 0 var(--ma-travel-hover);
|
|
101
|
+
transition:
|
|
102
|
+
opacity var(--ma-duration) var(--ma-ease),
|
|
103
|
+
translate var(--ma-duration) var(--ma-ease),
|
|
104
|
+
visibility var(--ma-duration) var(--ma-ease-out);
|
|
105
|
+
}
|
|
106
|
+
:where(.ma-copy[data-state='idle'] [data-face='idle']),
|
|
107
|
+
:where(.ma-copy[data-state='done'] [data-face='done']),
|
|
108
|
+
:where(.ma-copy[data-state='error'] [data-face='error']) {
|
|
109
|
+
opacity: 1;
|
|
110
|
+
visibility: visible;
|
|
111
|
+
translate: 0 0;
|
|
112
|
+
}
|
|
113
|
+
/* the state tints are hooks: on a coloured ground a caller points --ma-copy-done / --ma-copy-error at a colour that
|
|
114
|
+
keeps 4.5 : 1 there (the words carry the state either way) */
|
|
115
|
+
:where(.ma-copy[data-state='done']) {
|
|
116
|
+
color: var(--ma-copy-done, color-mix(in srgb, var(--ma-ink) 55%, #3a9a5b));
|
|
117
|
+
}
|
|
118
|
+
:where(.ma-copy[data-state='error']) {
|
|
119
|
+
color: var(--ma-copy-error, color-mix(in srgb, var(--ma-ink) 50%, #d64a2e));
|
|
120
|
+
}
|
|
121
|
+
/* JavaScript off: nothing can reach the clipboard, so the button would do nothing. The text it copies is still on the
|
|
122
|
+
page to select */
|
|
123
|
+
@media (scripting: none) {
|
|
124
|
+
:where(.ma-copy) {
|
|
125
|
+
display: none;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
@media (prefers-reduced-motion: reduce) {
|
|
129
|
+
:where(.ma-copy__face) {
|
|
130
|
+
transition: none;
|
|
131
|
+
translate: 0 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
136
|
+
|
|
137
|
+
<script>
|
|
138
|
+
import { onMount } from '../lib/runtime';
|
|
139
|
+
|
|
140
|
+
onMount<HTMLButtonElement>('[data-ma-copy]', (btn, { signal }) => {
|
|
141
|
+
let timer: number | undefined;
|
|
142
|
+
// data-ma-copy holds the id of this instance's status node, the sibling rendered after the button
|
|
143
|
+
const status = document.getElementById(btn.dataset.maCopy!);
|
|
144
|
+
// the hold time is the ambient duration token, read from the element so a preset or an override applies
|
|
145
|
+
const hold = () => {
|
|
146
|
+
const v = getComputedStyle(btn).getPropertyValue('--ma-dur-ambient').trim();
|
|
147
|
+
const n = parseFloat(v);
|
|
148
|
+
return n ? (v.endsWith('ms') ? n : n * 1000) : 1400;
|
|
149
|
+
};
|
|
150
|
+
const say = (text: string) => {
|
|
151
|
+
if (!status) return;
|
|
152
|
+
if (text && status.textContent === text) {
|
|
153
|
+
// the same text again is no change to assistive tech: empty the node, let a frame render it empty, then write
|
|
154
|
+
status.textContent = '';
|
|
155
|
+
requestAnimationFrame(() => requestAnimationFrame(() => (status.textContent = text)));
|
|
156
|
+
} else status.textContent = text;
|
|
157
|
+
};
|
|
158
|
+
// back to idle, and the status empties: a later copy is a change again, and no stale "Copied" is left to browse to
|
|
159
|
+
const idle = () => {
|
|
160
|
+
btn.dataset.state = 'idle';
|
|
161
|
+
say('');
|
|
162
|
+
};
|
|
163
|
+
const settle = (state: 'done' | 'error') => {
|
|
164
|
+
btn.dataset.state = state;
|
|
165
|
+
say(btn.querySelector(`[data-face='${state}']`)!.textContent!);
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
timer = window.setTimeout(idle, hold());
|
|
168
|
+
};
|
|
169
|
+
btn.addEventListener(
|
|
170
|
+
'click',
|
|
171
|
+
async () => {
|
|
172
|
+
const text = btn.dataset.value ?? (btn.dataset.target ? document.querySelector(btn.dataset.target)?.textContent : '') ?? '';
|
|
173
|
+
try {
|
|
174
|
+
await navigator.clipboard.writeText(text);
|
|
175
|
+
settle('done');
|
|
176
|
+
} catch {
|
|
177
|
+
settle('error');
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{ signal },
|
|
181
|
+
);
|
|
182
|
+
// torn down before a swap: a button that survives it (transition:persist) goes back to idle rather than keeping the
|
|
183
|
+
// "Copied" whose reset timer this just cleared
|
|
184
|
+
signal.addEventListener('abort', () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
idle();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CopyButton',
|
|
5
|
+
slug: 'copy-button',
|
|
6
|
+
title: 'Copy Button',
|
|
7
|
+
description: 'A button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration. The clipboard write is the only script; it sets one attribute, data-state, and writes the outcome into a visually hidden status node rendered after the button, so a screen reader hears it; the icon (AnimatedIcons) and the label faces are CSS. When the clipboard refuses, the button says and announces "Copy failed".',
|
|
8
|
+
summary: 'A copy-to-clipboard button whose icon turns into a check and whose label says Copied, read out to screen readers.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['clipboard'], fallback: 'the error face, because the clipboard API needs a secure context' },
|
|
13
|
+
readout: '<CopyButton value="npx astro add moonarc">',
|
|
14
|
+
usage: `<CopyButton value="npx astro add moonarc" />\n<pre id="snippet">pnpm add @moonarc/core</pre>\n<CopyButton target="#snippet" compact />`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'value', type: 'string', description: 'Text to copy.' },
|
|
17
|
+
{ name: 'target', type: 'string', description: 'Or a selector; the element\'s textContent is copied.' },
|
|
18
|
+
{ name: 'label', type: 'string', default: "'Copy'", description: 'Idle label; the accessible name in compact mode.' },
|
|
19
|
+
{ name: 'doneLabel', type: 'string', default: "'Copied'", description: 'Shown after a successful write.' },
|
|
20
|
+
{ name: 'errorLabel', type: 'string', default: "'Copy failed'", description: 'Shown when the clipboard refuses.' },
|
|
21
|
+
{ name: 'compact', type: 'boolean', default: 'false', description: 'Icon only; the label stays as aria-label, and the status node still announces done and failed.' },
|
|
22
|
+
{ name: '--ma-copy-done', type: 'CSS colour (custom property)', default: '55 % ink, 45 % green (srgb)', description: 'The button\'s colour (label, icon, focus ring) while it says it copied. Set it in a rule or style on the button or any ancestor that knows its ground. Use a colour that keeps 4.5 : 1 against it, for example the ground\'s own text colour on a coloured panel, where the green mix falls short. The words say the state either way.' },
|
|
23
|
+
{ name: '--ma-copy-error', type: 'CSS colour (custom property)', default: '50 % ink, 50 % red (srgb)', description: 'The same for the failed state.' },
|
|
24
|
+
],
|
|
25
|
+
reducedMotion: 'The faces switch at once and the check is drawn without its stroke animation.',
|
|
26
|
+
clientRouter: 'Bound through the shared runtime: the listener is attached after every navigation and aborted before the swap. The abort clears a pending reset and puts the button back to idle, so one kept across the swap by transition:persist does not stay on "Copied".',
|
|
27
|
+
craft: [
|
|
28
|
+
'The hold time is the ambient duration token, read from the element at click time. It is long enough to be read and short enough that a second copy is not blocked by the first.',
|
|
29
|
+
'The icon needs no script of its own: AnimatedIcons reads data-state="done" on the button.',
|
|
30
|
+
'Failure is a visible state: a page served over http, or a browser that asks, gets "Copy failed" and the text stays selectable.',
|
|
31
|
+
'With JavaScript off the button is display: none (@media (scripting: none)): it could only be a button that does nothing, and the text it would copy is still on the page to select. Engines without the scripting media feature keep showing it.',
|
|
32
|
+
'Faces stack in one grid cell and cross-fade with a 4 px rise, so the button never changes width when the label does.',
|
|
33
|
+
'The button is named by the face that shows: inactive faces are visibility: hidden, which takes them out of the accessibility tree but not out of their grid cell. visibility transitions with the fade on ease-out, so an arriving face counts at once and a leaving one until it has faded; an overshooting preset curve would flip it mid-fade.',
|
|
34
|
+
'The outcome is announced by a role="status" span (.ma-sr) rendered after the button, not aria-live on the button: a live button whose faces only change opacity announces nothing. The script writes the done or error label into it, empties it when the button returns to idle, and empties it for a frame before writing the same label again, so a second copy is heard too.',
|
|
35
|
+
'The component renders two nodes: the button, then its status span. class and every other attribute go to the button. The span is absolutely positioned and clipped, so flex and grid gaps do not see it, but child selectors do. The button is no longer :last-child (a Tailwind space-x-* parent gives it a trailing margin), and a + sibling rule after the button meets the span first. Wrap the component in an element of its own to place it, as CodeTabs does.',
|
|
36
|
+
],
|
|
37
|
+
related: ['animated-icons', 'code-tabs', 'stateful-button'],
|
|
38
|
+
intents: ['copy to clipboard button', 'copy button with check icon', 'copy code button', 'clipboard button astro', 'copied feedback'],
|
|
39
|
+
replaces: ['CopyButton (Magic UI)', 'CopyButton (Cult UI)', 'copy button (shadcn examples)'],
|
|
40
|
+
fixture: { props: { value: 'x', label: 'Fixture content for CopyButton' }, expect: 'Fixture content for CopyButton' },
|
|
41
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CountUp — a number counts from zero to its value when it scrolls into view.
|
|
4
|
+
* Zero script of its own: it composes Reveal for the trigger, and the digits
|
|
5
|
+
* are CSS counters driven by a registered custom property transitioning
|
|
6
|
+
* from 0 to the target. Thousands are separate counters so the separator is
|
|
7
|
+
* real text; the server HTML already holds the final number.
|
|
8
|
+
*/
|
|
9
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
10
|
+
import Reveal from './Reveal.astro';
|
|
11
|
+
|
|
12
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
13
|
+
/** Target value. */
|
|
14
|
+
to: number;
|
|
15
|
+
/** Element to render. */
|
|
16
|
+
as?: HTMLTag;
|
|
17
|
+
/** Duration in ms. */
|
|
18
|
+
duration?: number;
|
|
19
|
+
/** Decimal places, 0–4. */
|
|
20
|
+
decimals?: number;
|
|
21
|
+
/** Thousands separator. */
|
|
22
|
+
separator?: string;
|
|
23
|
+
/** Decimal mark. */
|
|
24
|
+
decimal?: string;
|
|
25
|
+
/** Text before the number. */
|
|
26
|
+
prefix?: string;
|
|
27
|
+
/** Text after the number. */
|
|
28
|
+
suffix?: string;
|
|
29
|
+
/** Delay in ms before counting starts. */
|
|
30
|
+
delay?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { to, as = 'span', duration = 1400, decimals = 0, separator = ',', decimal = '.', prefix = '', suffix = '', delay, class: className, style, ...rest } = Astro.props;
|
|
34
|
+
|
|
35
|
+
const places = Math.max(0, Math.min(decimals, 4));
|
|
36
|
+
const negative = to < 0;
|
|
37
|
+
const fixed = Math.abs(to).toFixed(places);
|
|
38
|
+
const [intPart, fracPart] = fixed.split('.');
|
|
39
|
+
const groups: number[] = [];
|
|
40
|
+
for (let i = intPart!.length; i > 0; i -= 3) groups.unshift(Number(intPart!.slice(Math.max(0, i - 3), i)));
|
|
41
|
+
const label = `${prefix}${negative ? '-' : ''}${groups.map((g, i) => (i === 0 ? String(g) : String(g).padStart(3, '0'))).join(separator)}${fracPart ? decimal + fracPart : ''}${suffix}`;
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<Reveal
|
|
45
|
+
as={as}
|
|
46
|
+
from="none"
|
|
47
|
+
duration={400}
|
|
48
|
+
delay={delay}
|
|
49
|
+
class={['ma-count', className].filter(Boolean).join(' ')}
|
|
50
|
+
style={[`--ma-count-dur:${duration}ms`, delay ? `--ma-count-delay:${delay}ms` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';')}
|
|
51
|
+
{...rest}
|
|
52
|
+
>
|
|
53
|
+
<span class="ma-count__value" aria-hidden="true">
|
|
54
|
+
{prefix}{negative && '-'}
|
|
55
|
+
{groups.map((g, i) => (
|
|
56
|
+
<>
|
|
57
|
+
{i > 0 && separator}
|
|
58
|
+
<i class="ma-count__n" data-pad={i > 0 ? '' : undefined} style={`--ma-count-to:${g}`}></i>
|
|
59
|
+
</>
|
|
60
|
+
))}
|
|
61
|
+
{fracPart && (
|
|
62
|
+
<>
|
|
63
|
+
{decimal}
|
|
64
|
+
<i class="ma-count__n" data-pad={String(places)} style={`--ma-count-to:${Number(fracPart)}`}></i>
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
{suffix}
|
|
68
|
+
</span>
|
|
69
|
+
<span class="ma-sr">{label}</span>
|
|
70
|
+
</Reveal>
|
|
71
|
+
|
|
72
|
+
<style is:global>
|
|
73
|
+
@property --ma-count-n {
|
|
74
|
+
syntax: '<integer>';
|
|
75
|
+
inherits: false;
|
|
76
|
+
initial-value: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@layer components {
|
|
80
|
+
@counter-style ma-pad2 {
|
|
81
|
+
system: extends decimal;
|
|
82
|
+
pad: 2 '0';
|
|
83
|
+
}
|
|
84
|
+
@counter-style ma-pad3 {
|
|
85
|
+
system: extends decimal;
|
|
86
|
+
pad: 3 '0';
|
|
87
|
+
}
|
|
88
|
+
@counter-style ma-pad4 {
|
|
89
|
+
system: extends decimal;
|
|
90
|
+
pad: 4 '0';
|
|
91
|
+
}
|
|
92
|
+
:where(.ma-count__value) {
|
|
93
|
+
font-variant-numeric: tabular-nums;
|
|
94
|
+
}
|
|
95
|
+
/* the count waits for the delay too: Reveal's delay holds back only the fade, so a number with a delay (StatsRow
|
|
96
|
+
staggers its stats) had mostly counted before it showed */
|
|
97
|
+
:where(.ma-count__n) {
|
|
98
|
+
font-style: inherit;
|
|
99
|
+
--ma-count-n: var(--ma-count-to);
|
|
100
|
+
transition: --ma-count-n var(--ma-count-dur, 1400ms) var(--ma-ease-out) var(--ma-count-delay, 0ms);
|
|
101
|
+
}
|
|
102
|
+
/* the counter is reset on the pseudo-element that prints it: reset on the <i>, WebKit printed the first group's value
|
|
103
|
+
in every later group ("2,002" for 2,847, "1.1" for 1.9). --ma-count-n is registered with inherits: false, so the
|
|
104
|
+
pseudo-element takes it explicitly */
|
|
105
|
+
:where(.ma-count__n)::before {
|
|
106
|
+
--ma-count-n: inherit;
|
|
107
|
+
counter-reset: ma-n var(--ma-count-n);
|
|
108
|
+
content: counter(ma-n);
|
|
109
|
+
}
|
|
110
|
+
:where(.ma-count__n[data-pad=''])::before,
|
|
111
|
+
:where(.ma-count__n[data-pad='3'])::before {
|
|
112
|
+
content: counter(ma-n, ma-pad3);
|
|
113
|
+
}
|
|
114
|
+
:where(.ma-count__n[data-pad='2'])::before {
|
|
115
|
+
content: counter(ma-n, ma-pad2);
|
|
116
|
+
}
|
|
117
|
+
:where(.ma-count__n[data-pad='4'])::before {
|
|
118
|
+
content: counter(ma-n, ma-pad4);
|
|
119
|
+
}
|
|
120
|
+
:where(.ma-count__n[data-pad='1'])::before {
|
|
121
|
+
content: counter(ma-n);
|
|
122
|
+
}
|
|
123
|
+
/* Under the JS gate the digits start at zero and transition to the value once Reveal marks the wrapper in view. */
|
|
124
|
+
:where([data-ma-js] .ma-count:not(.ma-in) .ma-count__n) {
|
|
125
|
+
--ma-count-n: 0;
|
|
126
|
+
transition: none;
|
|
127
|
+
}
|
|
128
|
+
@media (prefers-reduced-motion: reduce) {
|
|
129
|
+
:where(.ma-count__n) {
|
|
130
|
+
transition: none;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CountUp',
|
|
5
|
+
slug: 'count-up',
|
|
6
|
+
title: 'Count Up',
|
|
7
|
+
description:
|
|
8
|
+
'Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix. No script of its own: Reveal supplies the trigger, and the digits are CSS counters driven by a custom property transitioning from 0 to the target. The server HTML already shows the final number.',
|
|
9
|
+
summary: 'A number that counts up from zero when it scrolls into view, with separators, decimals, a prefix and a suffix.',
|
|
10
|
+
category: 'text',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['scroll'],
|
|
13
|
+
support: { baseline: 'newly', needs: ['property', 'counter-style'], fallback: 'the final number, shown at once' },
|
|
14
|
+
readout: '<CountUp to={2847}>',
|
|
15
|
+
usage: `<CountUp to={2847} />\n<CountUp to={99.5} decimals={1} suffix="%" duration={1800} />\n<CountUp to={1200000} prefix="$" as="strong" />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'to', type: 'number', description: 'Target value. Negative numbers keep their sign.' },
|
|
18
|
+
{ name: 'as', type: 'HTMLTag', default: "'span'", description: 'Element to render.' },
|
|
19
|
+
{ name: 'duration', type: 'number', default: '1400', description: 'Duration in ms. Ease-out, so most of the change happens early.' },
|
|
20
|
+
{ name: 'decimals', type: 'number', default: '0', description: 'Decimal places, 0–4.' },
|
|
21
|
+
{ name: 'separator', type: 'string', default: "','", description: 'Thousands separator.' },
|
|
22
|
+
{ name: 'decimal', type: 'string', default: "'.'", description: 'Decimal mark.' },
|
|
23
|
+
{ name: 'prefix', type: 'string', description: 'Text before the number, e.g. $.' },
|
|
24
|
+
{ name: 'suffix', type: 'string', description: 'Text after the number, e.g. %.' },
|
|
25
|
+
{ name: 'delay', type: 'number', default: '0', description: 'Delay in ms before the number fades in and starts counting.' },
|
|
26
|
+
],
|
|
27
|
+
reducedMotion: 'The final number appears without counting.',
|
|
28
|
+
clientRouter: 'Inherits Reveal: rebinds after every navigation, so the count replays on a fresh page.',
|
|
29
|
+
craft: [
|
|
30
|
+
'Each thousands group is its own counter, and every group runs on the same duration and ease-out curve, so the whole number follows that curve.',
|
|
31
|
+
'Groups after the first are padded to three digits with @counter-style, so 1,005 never renders as 1,5.',
|
|
32
|
+
'Ease-out: a count that lands slowly reads as arriving; one that lands fast reads as a glitch.',
|
|
33
|
+
'A visually hidden span (.ma-sr) carries the final formatted value; the animated counters are aria-hidden.',
|
|
34
|
+
'Tabular figures so the number does not jitter sideways while counting.',
|
|
35
|
+
],
|
|
36
|
+
related: ['reveal', 'progress'],
|
|
37
|
+
intents: ['count up', 'animated number', 'number counter on scroll', 'statistics counter', 'countup astro'],
|
|
38
|
+
replaces: ['CountUp (React Bits)', 'NumberTicker (Magic UI)', 'react-countup'],
|
|
39
|
+
fixture: { props: { to: 2847 }, expect: 'ma-sr">2,847</span>' },
|
|
40
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CountdownFlip — a flip clock counting down to a date. The one thing CSS
|
|
4
|
+
* cannot do is know the time, so one timer a second, set to the next whole
|
|
5
|
+
* second, writes the numbers and nothing else; the flip is CSS — the old top
|
|
6
|
+
* half folds down over the hinge, the new bottom half unfolds beneath it.
|
|
7
|
+
* The timer stops while the tab is hidden. The server renders the time left
|
|
8
|
+
* at build as the no-JS fallback, or the done state when the target has
|
|
9
|
+
* already passed.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes, HTMLTag } from 'astro/types';
|
|
12
|
+
|
|
13
|
+
type Unit = 'days' | 'hours' | 'minutes' | 'seconds';
|
|
14
|
+
/** A label, or a [one, other] pair: the card shows `other`; the spoken name says `one` when the number is exactly 1. */
|
|
15
|
+
type Label = string | readonly [one: string, other: string];
|
|
16
|
+
|
|
17
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
18
|
+
/** Target: an ISO date string or a timestamp in ms. */
|
|
19
|
+
to: string | number;
|
|
20
|
+
/** Element to render. */
|
|
21
|
+
as?: HTMLTag;
|
|
22
|
+
/** Units to show, in order. */
|
|
23
|
+
units?: Unit[];
|
|
24
|
+
/** Labels under the cards; default the unit names. A [one, other] pair gives the spoken name its singular ("1 day"). */
|
|
25
|
+
labels?: Partial<Record<Unit, Label>>;
|
|
26
|
+
/** Text shown once the countdown reaches zero, and the timer's accessible name from then on. */
|
|
27
|
+
done?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { to, as: Tag = 'div', units = ['days', 'hours', 'minutes', 'seconds'], labels = {}, done, class: className, ...rest } = Astro.props;
|
|
31
|
+
const target = typeof to === 'number' ? to : Date.parse(to);
|
|
32
|
+
const left = Math.max(0, Math.floor((target - Date.now()) / 1000));
|
|
33
|
+
// the target passed before the build: the page ships finished, script or not
|
|
34
|
+
const over = left === 0;
|
|
35
|
+
const value: Record<Unit, number> = { days: Math.floor(left / 86400), hours: Math.floor(left / 3600) % 24, minutes: Math.floor(left / 60) % 60, seconds: left % 60 };
|
|
36
|
+
const text = (u: Unit) => (u === 'days' && value[u] > 99 ? String(value[u]) : String(value[u]).padStart(2, '0'));
|
|
37
|
+
const forms = (u: Unit): readonly [string, string] => {
|
|
38
|
+
const l = labels[u];
|
|
39
|
+
return typeof l === 'string' ? [l, l] : (l ?? [u.slice(0, -1), u]);
|
|
40
|
+
};
|
|
41
|
+
const spoken = over && done ? done : units.map((u) => `${value[u]} ${forms(u)[value[u] === 1 ? 0 : 1]}`).join(', ');
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<Tag class:list={['ma-cd', className]} data-ma-cd data-to={new Date(target).toISOString()} data-done={over ? '' : undefined} role="timer" aria-label={spoken} {...rest}>
|
|
45
|
+
{units.map((u) => {
|
|
46
|
+
const [one, other] = forms(u);
|
|
47
|
+
return (
|
|
48
|
+
<span class="ma-cd__unit">
|
|
49
|
+
<span class="ma-cd__card" data-unit={u} data-one={one === other ? undefined : one} aria-hidden="true">
|
|
50
|
+
<b class="ma-cd__top">{text(u)}</b>
|
|
51
|
+
<b class="ma-cd__bot">{text(u)}</b>
|
|
52
|
+
<b class="ma-cd__flap-a">{text(u)}</b>
|
|
53
|
+
<b class="ma-cd__flap-b">{text(u)}</b>
|
|
54
|
+
</span>
|
|
55
|
+
<span class="ma-cd__label" aria-hidden="true">{other}</span>
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
{done && <span class="ma-cd__done" hidden={!over} aria-hidden="true">{done}</span>}
|
|
60
|
+
</Tag>
|
|
61
|
+
|
|
62
|
+
<style is:global>
|
|
63
|
+
@layer components {
|
|
64
|
+
:where(.ma-cd) {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
gap: 0.5em;
|
|
67
|
+
font-variant-numeric: tabular-nums;
|
|
68
|
+
}
|
|
69
|
+
:where(.ma-cd__unit) {
|
|
70
|
+
display: grid;
|
|
71
|
+
gap: 0.35em;
|
|
72
|
+
justify-items: center;
|
|
73
|
+
}
|
|
74
|
+
:where(.ma-cd__card) {
|
|
75
|
+
position: relative;
|
|
76
|
+
display: grid;
|
|
77
|
+
/* two digits fit 1.75em; a third (more than 99 days) widens the card through the padded digits instead of clipping */
|
|
78
|
+
min-width: 1.75em;
|
|
79
|
+
height: 1.5em;
|
|
80
|
+
line-height: 1.5em;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
text-align: center;
|
|
83
|
+
perspective: 8em;
|
|
84
|
+
}
|
|
85
|
+
:where(.ma-cd__card)::after {
|
|
86
|
+
content: '';
|
|
87
|
+
position: absolute;
|
|
88
|
+
inset: 50% 0 auto 0;
|
|
89
|
+
height: 1px;
|
|
90
|
+
background: var(--ma-glow);
|
|
91
|
+
}
|
|
92
|
+
:where(.ma-cd__top),
|
|
93
|
+
:where(.ma-cd__bot),
|
|
94
|
+
:where(.ma-cd__flap-a),
|
|
95
|
+
:where(.ma-cd__flap-b) {
|
|
96
|
+
grid-area: 1 / 1;
|
|
97
|
+
padding-inline: 0.2em;
|
|
98
|
+
font-weight: inherit;
|
|
99
|
+
border-radius: 0.15em;
|
|
100
|
+
background: var(--ma-edge);
|
|
101
|
+
}
|
|
102
|
+
:where(.ma-cd__top),
|
|
103
|
+
:where(.ma-cd__flap-a) {
|
|
104
|
+
clip-path: inset(0 0 50% 0);
|
|
105
|
+
}
|
|
106
|
+
:where(.ma-cd__bot),
|
|
107
|
+
:where(.ma-cd__flap-b) {
|
|
108
|
+
clip-path: inset(50% 0 0 0);
|
|
109
|
+
}
|
|
110
|
+
/* the flaps exist for a flip only: at rest each half shows one layer, so a translucent card (--ma-edge is a tint by
|
|
111
|
+
default) is one tint deep in both halves and no digit shows through another */
|
|
112
|
+
:where(.ma-cd__flap-a),
|
|
113
|
+
:where(.ma-cd__flap-b) {
|
|
114
|
+
transform-origin: 50% 50%;
|
|
115
|
+
backface-visibility: hidden;
|
|
116
|
+
visibility: hidden;
|
|
117
|
+
}
|
|
118
|
+
/* set by the script every second: the old top folds down, then the new bottom unfolds. The layers are tints, not
|
|
119
|
+
opaque cards, so a flap covers nothing: the new top waits under the falling flap until it is edge-on, and the old
|
|
120
|
+
bottom goes when the new one starts to unfold and stays gone (the flap is the bottom half from then on) */
|
|
121
|
+
:where(.ma-cd__card[data-flip] .ma-cd__flap-a) {
|
|
122
|
+
visibility: visible;
|
|
123
|
+
animation: ma-cd-fold calc(var(--ma-duration) / 2) var(--ma-ease-in) both;
|
|
124
|
+
}
|
|
125
|
+
:where(.ma-cd__card[data-flip] .ma-cd__flap-b) {
|
|
126
|
+
visibility: visible;
|
|
127
|
+
animation: ma-cd-unfold calc(var(--ma-duration) / 2) var(--ma-ease-out) calc(var(--ma-duration) / 2) both;
|
|
128
|
+
}
|
|
129
|
+
:where(.ma-cd__card[data-flip] .ma-cd__top) {
|
|
130
|
+
animation: ma-cd-hide calc(var(--ma-duration) / 2);
|
|
131
|
+
}
|
|
132
|
+
:where(.ma-cd__card[data-flip] .ma-cd__bot) {
|
|
133
|
+
animation: ma-cd-hide calc(var(--ma-duration) / 2) calc(var(--ma-duration) / 2) forwards;
|
|
134
|
+
}
|
|
135
|
+
:where(.ma-cd__label) {
|
|
136
|
+
font-size: 0.5em;
|
|
137
|
+
letter-spacing: 0.08em;
|
|
138
|
+
text-transform: uppercase;
|
|
139
|
+
opacity: var(--ma-dim, 0.7);
|
|
140
|
+
}
|
|
141
|
+
/* finished: the cards step back to secondary text, no further (the labels already sit at --ma-dim) */
|
|
142
|
+
:where(.ma-cd[data-done] .ma-cd__card) {
|
|
143
|
+
opacity: var(--ma-dim, 0.7);
|
|
144
|
+
}
|
|
145
|
+
:where(.ma-cd__done) {
|
|
146
|
+
align-self: center;
|
|
147
|
+
}
|
|
148
|
+
@media (prefers-reduced-motion: reduce) {
|
|
149
|
+
:where(.ma-cd__card[data-flip] .ma-cd__flap-a),
|
|
150
|
+
:where(.ma-cd__card[data-flip] .ma-cd__flap-b),
|
|
151
|
+
:where(.ma-cd__card[data-flip] .ma-cd__top),
|
|
152
|
+
:where(.ma-cd__card[data-flip] .ma-cd__bot) {
|
|
153
|
+
animation: none;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* a constant: hidden for as long as it runs (visibility between hidden and visible would step to visible at once) */
|
|
159
|
+
@keyframes ma-cd-hide {
|
|
160
|
+
from,
|
|
161
|
+
to {
|
|
162
|
+
visibility: hidden;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
@keyframes ma-cd-fold {
|
|
166
|
+
from {
|
|
167
|
+
transform: rotateX(0);
|
|
168
|
+
}
|
|
169
|
+
to {
|
|
170
|
+
transform: rotateX(-90deg);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
@keyframes ma-cd-unfold {
|
|
174
|
+
from {
|
|
175
|
+
transform: rotateX(90deg);
|
|
176
|
+
}
|
|
177
|
+
to {
|
|
178
|
+
transform: rotateX(0);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
182
|
+
|
|
183
|
+
<script>
|
|
184
|
+
import { onMount, prefersReducedMotion } from '../lib/runtime';
|
|
185
|
+
|
|
186
|
+
type Unit = 'days' | 'hours' | 'minutes' | 'seconds';
|
|
187
|
+
|
|
188
|
+
onMount<HTMLElement>('[data-ma-cd]', (el, { signal }) => {
|
|
189
|
+
const target = Date.parse(el.dataset.to ?? '');
|
|
190
|
+
const cards = Array.from(el.querySelectorAll<HTMLElement>('.ma-cd__card'));
|
|
191
|
+
const done = el.querySelector<HTMLElement>('.ma-cd__done');
|
|
192
|
+
let timer: number | undefined;
|
|
193
|
+
|
|
194
|
+
const tick = () => {
|
|
195
|
+
const left = Math.max(0, Math.floor((target - Date.now()) / 1000));
|
|
196
|
+
const value: Record<Unit, number> = { days: Math.floor(left / 86400), hours: Math.floor(left / 3600) % 24, minutes: Math.floor(left / 60) % 60, seconds: left % 60 };
|
|
197
|
+
const reduce = prefersReducedMotion();
|
|
198
|
+
const spoken: string[] = [];
|
|
199
|
+
for (const card of cards) {
|
|
200
|
+
const unit = card.dataset.unit as Unit;
|
|
201
|
+
const n = value[unit];
|
|
202
|
+
const text = unit === 'days' && n > 99 ? String(n) : String(n).padStart(2, '0');
|
|
203
|
+
const [top, bot, a, b] = Array.from(card.children) as HTMLElement[];
|
|
204
|
+
// the label under the card is the `other` form; the `one` form rides on the card when it differs
|
|
205
|
+
spoken.push(`${n} ${(n === 1 && card.dataset.one) || (card.nextElementSibling?.textContent ?? unit)}`);
|
|
206
|
+
if (top!.textContent === text) continue;
|
|
207
|
+
if (reduce) {
|
|
208
|
+
top!.textContent = bot!.textContent = a!.textContent = b!.textContent = text;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// the old value stays on the bottom half and the falling flap; the new one is under the flap and on the rising half
|
|
212
|
+
bot!.textContent = a!.textContent = top!.textContent;
|
|
213
|
+
top!.textContent = b!.textContent = text;
|
|
214
|
+
card.removeAttribute('data-flip');
|
|
215
|
+
void card.offsetWidth; // restart the flap animations
|
|
216
|
+
card.setAttribute('data-flip', '');
|
|
217
|
+
}
|
|
218
|
+
const over = left <= 0;
|
|
219
|
+
el.setAttribute('aria-label', over && done ? done.textContent! : spoken.join(', '));
|
|
220
|
+
if (over) {
|
|
221
|
+
stop();
|
|
222
|
+
el.dataset.done = '';
|
|
223
|
+
if (done) done.hidden = false;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const stop = () => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
timer = undefined;
|
|
229
|
+
};
|
|
230
|
+
// each tick is timed to the next whole second left, plus a margin for a timer that fires early. A flat 1000 ms
|
|
231
|
+
// interval kept the phase of the moment it started: the card turned up to a second late, and when that phase sat on
|
|
232
|
+
// a boundary, timer jitter skipped a second or showed one twice. An invalid target waits a second, never 0 ms. Armed
|
|
233
|
+
// before the tick, so the tick that reaches zero clears it through stop()
|
|
234
|
+
const start = () => {
|
|
235
|
+
stop();
|
|
236
|
+
timer = window.setTimeout(start, ((target - Date.now()) % 1000 || 1000) + 20);
|
|
237
|
+
tick();
|
|
238
|
+
};
|
|
239
|
+
// no ticking in a hidden tab; catch up when it is shown again
|
|
240
|
+
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()), { signal });
|
|
241
|
+
signal.addEventListener('abort', stop);
|
|
242
|
+
start();
|
|
243
|
+
});
|
|
244
|
+
</script>
|