@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,170 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PillNav — a floating pill of links whose active pill glides to the page
|
|
4
|
+
* you chose, zero JS.
|
|
5
|
+
*
|
|
6
|
+
* The active pill is decided on the server (`current`, or the request path:
|
|
7
|
+
* an exact match is the page, the longest prefix is the section, and no
|
|
8
|
+
* match marks nothing) and carries a view-transition-name, so under
|
|
9
|
+
* <ClientRouter /> the browser
|
|
10
|
+
* itself slides it from the old link to the new one across the swap — no
|
|
11
|
+
* script measures anything. A second, faint pill sits under whichever link
|
|
12
|
+
* is hovered or focused through anchor positioning (the hovered link names
|
|
13
|
+
* the anchor; the pill follows). Without anchor positioning there is no
|
|
14
|
+
* hover pill; without a router the active pill moves at once.
|
|
15
|
+
*/
|
|
16
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
17
|
+
|
|
18
|
+
interface Item {
|
|
19
|
+
href: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Props extends HTMLAttributes<'nav'> {
|
|
24
|
+
items: Item[];
|
|
25
|
+
/** href of the active item. Unset: the request path (exact, then the longest prefix). */
|
|
26
|
+
current?: string;
|
|
27
|
+
/** Suffix for the view-transition-name, unique per page; two PillNavs on one page need two. */
|
|
28
|
+
name?: string;
|
|
29
|
+
/** Pill colour and its text; default ink on panel. */
|
|
30
|
+
color?: string;
|
|
31
|
+
textColor?: string;
|
|
32
|
+
/** Accessible name of the nav. */
|
|
33
|
+
label?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { items, current, name = 'ma-pillnav', color, textColor, label = 'Primary', class: className, style, ...rest } = Astro.props;
|
|
37
|
+
const path = Astro.url.pathname;
|
|
38
|
+
// the page itself (aria-current="page"), else the section it sits in (aria-current="true"); no match marks nothing, so "/"
|
|
39
|
+
// is never claimed as the page you are on when it is not
|
|
40
|
+
const exact = current ?? items.find((i) => i.href === path)?.href;
|
|
41
|
+
const active = exact ?? items.filter((i) => i.href !== '/' && path.startsWith(i.href)).sort((a, b) => b.href.length - a.href.length)[0]?.href;
|
|
42
|
+
const vars = [color && `--ma-pillnav-bg:${color}`, textColor && `--ma-pillnav-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<nav class:list={['ma-pillnav', className]} aria-label={label} style={vars || undefined} {...rest}>
|
|
46
|
+
<ul class="ma-pillnav__list">
|
|
47
|
+
{
|
|
48
|
+
items.map((it) => (
|
|
49
|
+
<li class="ma-pillnav__item">
|
|
50
|
+
<a href={it.href} class="ma-pillnav__link" aria-current={it.href === active ? (exact ? 'page' : 'true') : undefined}>
|
|
51
|
+
{it.href === active && <span class="ma-pillnav__pill" style={`view-transition-name:${name};view-transition-class:ma-pillnav`} aria-hidden="true" />}
|
|
52
|
+
<span class="ma-pillnav__label">{it.label}</span>
|
|
53
|
+
</a>
|
|
54
|
+
</li>
|
|
55
|
+
))
|
|
56
|
+
}
|
|
57
|
+
<li class="ma-pillnav__hover" aria-hidden="true"></li>
|
|
58
|
+
</ul>
|
|
59
|
+
<slot />
|
|
60
|
+
</nav>
|
|
61
|
+
|
|
62
|
+
<style is:global>
|
|
63
|
+
@layer components {
|
|
64
|
+
:where(.ma-pillnav) {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 0.375rem;
|
|
68
|
+
padding: 0.25rem;
|
|
69
|
+
border: 1px solid var(--ma-edge);
|
|
70
|
+
border-radius: 999px;
|
|
71
|
+
background: var(--ma-panel);
|
|
72
|
+
backdrop-filter: blur(12px);
|
|
73
|
+
box-shadow: 0 12px 32px -20px rgb(0 0 0 / 0.45);
|
|
74
|
+
}
|
|
75
|
+
:where(.ma-pillnav__list) {
|
|
76
|
+
position: relative;
|
|
77
|
+
display: flex;
|
|
78
|
+
gap: 0.125rem;
|
|
79
|
+
margin: 0;
|
|
80
|
+
padding: 0;
|
|
81
|
+
list-style: none;
|
|
82
|
+
}
|
|
83
|
+
:where(.ma-pillnav__link) {
|
|
84
|
+
position: relative;
|
|
85
|
+
isolation: isolate;
|
|
86
|
+
display: inline-flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
padding: 0.45em 1em;
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
font-size: 0.875rem;
|
|
91
|
+
font-weight: 500;
|
|
92
|
+
line-height: 1.2;
|
|
93
|
+
color: inherit;
|
|
94
|
+
text-decoration: none;
|
|
95
|
+
white-space: nowrap;
|
|
96
|
+
opacity: 0.7;
|
|
97
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), color var(--ma-duration-fast) var(--ma-ease-out);
|
|
98
|
+
}
|
|
99
|
+
/* the hovered (or focused) link is the anchor; the hover pill reads it */
|
|
100
|
+
:where(.ma-pillnav__link:is(:hover, :focus-visible)) {
|
|
101
|
+
opacity: 1;
|
|
102
|
+
anchor-name: --ma-pillnav-h;
|
|
103
|
+
}
|
|
104
|
+
:where(.ma-pillnav__link:focus-visible) {
|
|
105
|
+
outline: 2px solid currentColor;
|
|
106
|
+
outline-offset: 2px;
|
|
107
|
+
}
|
|
108
|
+
:where(.ma-pillnav__link[aria-current]) {
|
|
109
|
+
opacity: 1;
|
|
110
|
+
color: var(--ma-pillnav-fg, var(--ma-panel));
|
|
111
|
+
}
|
|
112
|
+
/* the active pill: one element with a view-transition-name — the router slides it to the next page's active link */
|
|
113
|
+
:where(.ma-pillnav__pill) {
|
|
114
|
+
position: absolute;
|
|
115
|
+
inset: 0;
|
|
116
|
+
z-index: -1;
|
|
117
|
+
border-radius: inherit;
|
|
118
|
+
background: var(--ma-pillnav-bg, var(--ma-ink));
|
|
119
|
+
}
|
|
120
|
+
:where(.ma-pillnav__label) {
|
|
121
|
+
position: relative;
|
|
122
|
+
}
|
|
123
|
+
/* the hover pill: anchored to whichever link is hovered; it fades where the anchor is and leaves when nothing is */
|
|
124
|
+
:where(.ma-pillnav__hover) {
|
|
125
|
+
position: absolute;
|
|
126
|
+
position-anchor: --ma-pillnav-h;
|
|
127
|
+
top: anchor(top);
|
|
128
|
+
left: anchor(left);
|
|
129
|
+
width: anchor-size(width);
|
|
130
|
+
height: anchor-size(height);
|
|
131
|
+
border-radius: 999px;
|
|
132
|
+
background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
|
|
133
|
+
opacity: 0;
|
|
134
|
+
scale: 0.9;
|
|
135
|
+
pointer-events: none;
|
|
136
|
+
transition:
|
|
137
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
138
|
+
scale var(--ma-duration) var(--ma-ease);
|
|
139
|
+
}
|
|
140
|
+
:where(.ma-pillnav__list:has(.ma-pillnav__link:is(:hover, :focus-visible):not([aria-current])) .ma-pillnav__hover) {
|
|
141
|
+
opacity: 1;
|
|
142
|
+
scale: 1;
|
|
143
|
+
}
|
|
144
|
+
@supports not (anchor-name: --a) {
|
|
145
|
+
:where(.ma-pillnav__hover) {
|
|
146
|
+
display: none;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/* the slide, on the tokens (view-transition-class: every PillNav on the site shares it) */
|
|
150
|
+
:where(:root)::view-transition-group(.ma-pillnav) {
|
|
151
|
+
animation-duration: var(--ma-duration);
|
|
152
|
+
animation-timing-function: var(--ma-ease);
|
|
153
|
+
}
|
|
154
|
+
:where(:root)::view-transition-old(.ma-pillnav),
|
|
155
|
+
:where(:root)::view-transition-new(.ma-pillnav) {
|
|
156
|
+
animation-duration: var(--ma-duration-fast);
|
|
157
|
+
animation-timing-function: var(--ma-ease-out);
|
|
158
|
+
block-size: 100%;
|
|
159
|
+
}
|
|
160
|
+
@media (prefers-reduced-motion: reduce) {
|
|
161
|
+
:where(:root)::view-transition-group(.ma-pillnav) {
|
|
162
|
+
animation-duration: 0s;
|
|
163
|
+
}
|
|
164
|
+
:where(.ma-pillnav__hover) {
|
|
165
|
+
transition: none;
|
|
166
|
+
scale: 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'PillNav',
|
|
5
|
+
slug: 'pill-nav',
|
|
6
|
+
title: 'Pill Nav',
|
|
7
|
+
description: 'A floating pill of links whose active pill glides to the page you chose. Zero JavaScript: the server marks the current link, the pill carries a view-transition-name, and under ClientRouter the browser slides it across the swap; a faint pill follows hover and focus through anchor positioning.',
|
|
8
|
+
summary: "A floating navigation pill whose active marker glides to the chosen page across Astro's router.",
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['click', 'hover'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['view-transitions', 'vt-class', 'anchor', 'has'], fallback: 'without anchor positioning there is no hover pill; without view transitions, or without ClientRouter, the active pill moves at once; without view-transition-class it slides on the browser\'s default timing instead of the preset' },
|
|
13
|
+
readout: '<PillNav current="/work/">',
|
|
14
|
+
usage: `<PillNav\n items={[\n { href: '/', label: 'Home' },\n { href: '/work/', label: 'Work' },\n { href: '/about/', label: 'About' },\n { href: '/contact/', label: 'Contact' },\n ]}\n/>\n\n<!-- with a theme toggle at the end -->\n<PillNav items={items} current="/work/"><ThemeToggle /></PillNav>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'items', type: '{ href: string; label: string }[]', description: 'The links.' },
|
|
17
|
+
{ name: 'current', type: 'string', description: 'href of the active item, marked aria-current="page". Unset: the request path; an exact match is the page, else the longest prefix is the section (aria-current="true"), and a path that matches nothing marks no item.' },
|
|
18
|
+
{ name: 'name', type: 'string', default: "'ma-pillnav'", description: 'Suffix of the view-transition-name; two PillNavs on one page need two names.' },
|
|
19
|
+
{ name: 'color', type: 'string', description: 'Pill colour; default the ink token.' },
|
|
20
|
+
{ name: 'textColor', type: 'string', description: 'Text on the pill; default the panel token.' },
|
|
21
|
+
{ name: 'label', type: 'string', default: "'Primary'", description: 'Accessible name of the nav.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'The active pill jumps to the new link (the group animation is 0 s); the hover pill appears without the scale.',
|
|
24
|
+
clientRouter: 'The pill is a named element, so the router morphs it from the old page to the new one. Without the router each page renders the pill in place.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The server decides the active link, so the HTML is the end state and the pill never flashes from the first link to the right one.',
|
|
27
|
+
'One view-transition-name on the pill, and a view-transition-class shared by every PillNav, so the slide runs on the preset curve and duration, the same tokens as the Tabs indicator.',
|
|
28
|
+
'The hover pill is anchor-positioned: the hovered link names the anchor, the pill reads its box. It fades in where the anchor is; between two links it moves at once rather than gliding, because anchor() values are the same computed value on every link.',
|
|
29
|
+
'aria-current on the active link is both the accessibility and the styling hook; there is no class to keep in sync. It says "page" only for the page itself: inside a section it says "true", and on a page no link matches it is absent, so Home is never announced as the page you are on when it is not.',
|
|
30
|
+
'The hover pill is the list\'s last <li>, aria-hidden, so the list stays valid HTML and screen readers count only the links.',
|
|
31
|
+
'Inside the pill the link isolates its stacking context, so the pill can sit at z-index -1 without falling behind the nav background.',
|
|
32
|
+
],
|
|
33
|
+
related: ['tabs', 'dock', 'theme-toggle'],
|
|
34
|
+
intents: ['pill navigation', 'floating nav pill', 'sliding active indicator nav', 'nav with animated indicator', 'navbar pill astro', 'tab bar that slides between pages'],
|
|
35
|
+
replaces: ['PillNav (React Bits)', 'FloatingNav (Aceternity)', 'Tabs with layoutId (Framer Motion)', 'NavigationMenu indicator (shadcn / Radix)'],
|
|
36
|
+
fixture: { props: { items: [{ href: '/', label: 'Fixture content for PillNav' }, { href: '/two/', label: 'Two' }], current: '/' }, expect: 'Fixture content for PillNav' },
|
|
37
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Popover — a popover anchored to its trigger, zero JS.
|
|
4
|
+
*
|
|
5
|
+
* The popover attribute gives open/close, light dismiss, Escape and the top
|
|
6
|
+
* layer. Anchor positioning places it beside the trigger and flips it when
|
|
7
|
+
* there is no room; @starting-style animates it in and allow-discrete out.
|
|
8
|
+
* Browsers without anchor positioning show it centred at the bottom of the
|
|
9
|
+
* viewport, still fully functional.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
12
|
+
|
|
13
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
14
|
+
/** Popover id; the trigger points at it. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Trigger label; or use the `trigger` slot. */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** position-area value: top, bottom, left, right, or any two-keyword form. */
|
|
19
|
+
placement?: string;
|
|
20
|
+
/** Gap from the trigger, any CSS length. */
|
|
21
|
+
gap?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { id, label = 'Open', placement = 'bottom', gap = '0.5rem', class: className, style, ...rest } = Astro.props;
|
|
25
|
+
const anchor = `--ma-pop-${id}`;
|
|
26
|
+
const inline = [`position-anchor:${anchor}`, `--ma-pop-area:${placement}`, `--ma-pop-gap:${gap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
27
|
+
const triggerAttrs = { popovertarget: id, style: `anchor-name:${anchor}` };
|
|
28
|
+
const popAttrs = { popover: 'auto' };
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
{Astro.slots.has('trigger') ? <slot name="trigger" /> : <button type="button" class="ma-popover__trigger" {...triggerAttrs}>{label}</button>}
|
|
32
|
+
<div id={id} class:list={['ma-popover', className]} style={inline} {...popAttrs} {...rest}>
|
|
33
|
+
<slot />
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<style is:global>
|
|
37
|
+
@layer components {
|
|
38
|
+
:where(.ma-popover) {
|
|
39
|
+
position: fixed;
|
|
40
|
+
inset: auto;
|
|
41
|
+
position-area: var(--ma-pop-area, bottom);
|
|
42
|
+
position-try-fallbacks: flip-block, flip-inline;
|
|
43
|
+
margin: var(--ma-pop-gap, 0.5rem);
|
|
44
|
+
/* two hooks for a component that builds on Popover (MegaMenu): its own width and padding reach the popover even
|
|
45
|
+
where this rule comes later in the page's CSS than its own */
|
|
46
|
+
max-width: var(--ma-pop-max, min(20rem, calc(100vw - 2rem)));
|
|
47
|
+
border: 1px solid var(--ma-edge);
|
|
48
|
+
border-radius: 0.75rem;
|
|
49
|
+
padding: var(--ma-pop-pad, 0.875rem 1rem);
|
|
50
|
+
background: var(--ma-panel);
|
|
51
|
+
color: inherit;
|
|
52
|
+
box-shadow: 0 12px 32px -16px rgb(0 0 0 / 0.35);
|
|
53
|
+
opacity: 0;
|
|
54
|
+
scale: 0.96;
|
|
55
|
+
transition:
|
|
56
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
57
|
+
scale var(--ma-duration-fast) var(--ma-ease-out),
|
|
58
|
+
display var(--ma-duration-fast) allow-discrete,
|
|
59
|
+
overlay var(--ma-duration-fast) allow-discrete;
|
|
60
|
+
}
|
|
61
|
+
:where(.ma-popover:popover-open) {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
scale: 1;
|
|
64
|
+
transition-duration: var(--ma-duration);
|
|
65
|
+
transition-timing-function: var(--ma-ease);
|
|
66
|
+
}
|
|
67
|
+
@starting-style {
|
|
68
|
+
:where(.ma-popover:popover-open) {
|
|
69
|
+
opacity: 0;
|
|
70
|
+
scale: 0.96;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
:where(.ma-popover)::backdrop {
|
|
74
|
+
background: transparent;
|
|
75
|
+
}
|
|
76
|
+
:where(.ma-popover__trigger) {
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
/* no anchor positioning: a small sheet at the bottom of the viewport */
|
|
80
|
+
@supports not (position-area: bottom) {
|
|
81
|
+
:where(.ma-popover) {
|
|
82
|
+
inset: auto 0 1.5rem;
|
|
83
|
+
margin-inline: auto;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
@media (prefers-reduced-motion: reduce) {
|
|
87
|
+
:where(.ma-popover),
|
|
88
|
+
:where(.ma-popover:popover-open) {
|
|
89
|
+
scale: 1;
|
|
90
|
+
transition-duration: var(--ma-duration-fast);
|
|
91
|
+
transition-timing-function: linear;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Popover',
|
|
5
|
+
slug: 'popover',
|
|
6
|
+
title: 'Popover',
|
|
7
|
+
description: 'A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out. Zero JavaScript: the popover attribute, anchor positioning and @starting-style do all of it.',
|
|
8
|
+
summary: 'A popover that opens beside its trigger, flips when there is no room, and animates in and out.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['popover', 'anchor', 'starting-style', 'allow-discrete'], fallback: 'without anchor positioning it opens as a small sheet at the bottom of the viewport' },
|
|
13
|
+
readout: '<Popover placement="bottom">',
|
|
14
|
+
usage: `<Popover id="help" label="What is measured?" placement="bottom">\n <p>Raw bytes of JavaScript from a production build.</p>\n</Popover>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'id', type: 'string', description: 'Popover id; the trigger points at it.' },
|
|
17
|
+
{ name: 'label', type: 'string', default: "'Open'", description: 'Trigger label. Use the trigger slot for custom markup (give it popovertarget={id} and style="anchor-name: --ma-pop-{id}").' },
|
|
18
|
+
{ name: 'placement', type: 'string', default: "'bottom'", description: 'A position-area value: top, bottom, left, right, or two keywords such as "bottom span-right".' },
|
|
19
|
+
{ name: 'gap', type: 'string', default: "'0.5rem'", description: 'Distance from the trigger.' },
|
|
20
|
+
{ name: '--ma-pop-max', type: 'CSS length (custom property)', default: 'min(20rem, calc(100vw - 2rem))', description: 'The popover\'s max-width. Set it in a rule or style on the popover (a class passed to it) or an ancestor, for example for a menu panel wider than 20 rem.' },
|
|
21
|
+
{ name: '--ma-pop-pad', type: 'CSS padding (custom property)', default: '0.875rem 1rem', description: 'The popover\'s padding. Set it in a rule or style on the popover (a class passed to it) or an ancestor.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'A 150 ms crossfade, no scale.',
|
|
24
|
+
clientRouter: 'Native; nothing to rebind.',
|
|
25
|
+
craft: [
|
|
26
|
+
'position-try-fallbacks: flip-block, flip-inline. The browser flips it when it would overflow, which is the part libraries spend most of their bytes on.',
|
|
27
|
+
'Light dismiss, Escape and the top layer come from popover="auto"; none of it is re-implemented.',
|
|
28
|
+
'Enter on the preset curve, exit in 150 ms; the backdrop is transparent because a popover is not a modal.',
|
|
29
|
+
],
|
|
30
|
+
related: ['dialog', 'accordion', 'tabs'],
|
|
31
|
+
intents: ['popover', 'tooltip popover', 'anchored popup', 'dropdown without javascript', 'popover api astro'],
|
|
32
|
+
replaces: ['Popover (Radix / shadcn)', 'Floating UI for simple popovers', 'HoverCard'],
|
|
33
|
+
fixture: { props: { id: 'ma-fixture-pop', label: 'Fixture content for Popover' }, expect: 'Fixture content for Popover' },
|
|
34
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PriceSwitch — monthly or yearly; the prices roll. Two native radios and
|
|
4
|
+
* :has(): the checked one sets --ma-nf-value on every plan, so each
|
|
5
|
+
* NumberFlow rolls to the other price, the period label swaps, and the
|
|
6
|
+
* "save" badge enters through @starting-style. Zero JS. The server HTML
|
|
7
|
+
* carries both prices as text for assistive tech; where the engine cannot
|
|
8
|
+
* roll the digits (no round() / mod()), that text is the visible price.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
11
|
+
import NumberFlow from './NumberFlow.astro';
|
|
12
|
+
|
|
13
|
+
interface Plan {
|
|
14
|
+
name?: string;
|
|
15
|
+
monthly: number;
|
|
16
|
+
yearly: number;
|
|
17
|
+
note?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
21
|
+
/** Radio group name and id prefix; unique per page. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Plans to price. One plan can also be given as monthly + yearly. */
|
|
24
|
+
plans?: Plan[];
|
|
25
|
+
monthly?: number;
|
|
26
|
+
yearly?: number;
|
|
27
|
+
/** Currency symbol before the number. */
|
|
28
|
+
currency?: string;
|
|
29
|
+
/** Period labels after the number. */
|
|
30
|
+
periods?: [string, string];
|
|
31
|
+
/** Toggle labels. */
|
|
32
|
+
labels?: [string, string];
|
|
33
|
+
/** Badge shown on the yearly option once it is chosen. */
|
|
34
|
+
save?: string;
|
|
35
|
+
/** Accessible name of the radio group. */
|
|
36
|
+
groupLabel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { id, plans, monthly, yearly: yearlyPrice, currency = '$', periods = ['/mo', '/yr'], labels = ['Monthly', 'Yearly'], save, groupLabel = 'Billing period', class: className, ...rest } = Astro.props;
|
|
40
|
+
const list: Plan[] = plans ?? [{ monthly: monthly ?? 0, yearly: yearlyPrice ?? 0 }];
|
|
41
|
+
const digits = Math.max(...list.map((p) => String(Math.floor(Math.max(p.monthly, p.yearly))).length));
|
|
42
|
+
const decimals = list.some((p) => !Number.isInteger(p.monthly) || !Number.isInteger(p.yearly)) ? 2 : 0;
|
|
43
|
+
const fmt = (n: number) => `${currency}${n.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`;
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<div class:list={['ma-price', className]} {...rest}>
|
|
47
|
+
<div class="ma-price__toggle" role="radiogroup" aria-label={groupLabel}>
|
|
48
|
+
<input type="radio" class="ma-price__input" name={id} id={`${id}-m`} checked />
|
|
49
|
+
<label for={`${id}-m`} class="ma-price__opt">{labels[0]}</label>
|
|
50
|
+
<input type="radio" class="ma-price__input" name={id} id={`${id}-y`} />
|
|
51
|
+
<label for={`${id}-y`} class="ma-price__opt">{labels[1]}{save && <span class="ma-price__save">{save}</span>}</label>
|
|
52
|
+
<span class="ma-price__thumb" aria-hidden="true"></span>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="ma-price__plans">
|
|
55
|
+
{list.map((p) => (
|
|
56
|
+
<div class="ma-price__plan" style={`--ma-price-monthly:${p.monthly};--ma-price-yearly:${p.yearly}`}>
|
|
57
|
+
{p.name && <p class="ma-price__name">{p.name}</p>}
|
|
58
|
+
<p class="ma-price__amount">
|
|
59
|
+
<span class="ma-price__sr">{fmt(p.monthly)}{periods[0]}</span>
|
|
60
|
+
<span class="ma-price__sr ma-price__sr--y">{fmt(p.yearly)}{periods[1]}</span>
|
|
61
|
+
<span class="ma-price__currency" aria-hidden="true">{currency}</span>
|
|
62
|
+
<NumberFlow value={p.monthly} digits={digits} decimals={decimals} class="ma-price__number" aria-hidden="true" />
|
|
63
|
+
<span class="ma-price__period" aria-hidden="true"><span class="ma-price__per">{periods[0]}</span><span class="ma-price__per ma-price__per--y">{periods[1]}</span></span>
|
|
64
|
+
</p>
|
|
65
|
+
{p.note && <p class="ma-price__note">{p.note}</p>}
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
<slot />
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<style is:global>
|
|
73
|
+
@layer components {
|
|
74
|
+
:where(.ma-price__toggle) {
|
|
75
|
+
position: relative;
|
|
76
|
+
display: inline-grid;
|
|
77
|
+
grid-template-columns: 1fr 1fr;
|
|
78
|
+
padding: 0.2em;
|
|
79
|
+
border: 1px solid var(--ma-edge);
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
}
|
|
82
|
+
:where(.ma-price__input) {
|
|
83
|
+
position: absolute;
|
|
84
|
+
width: 1px;
|
|
85
|
+
height: 1px;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
clip-path: inset(50%);
|
|
88
|
+
white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
:where(.ma-price__opt) {
|
|
91
|
+
position: relative;
|
|
92
|
+
z-index: 1;
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
justify-content: center;
|
|
96
|
+
gap: 0.5em;
|
|
97
|
+
padding: 0.45em 1.1em;
|
|
98
|
+
border-radius: 999px;
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
font-weight: 500;
|
|
101
|
+
opacity: var(--ma-dim, 0.7);
|
|
102
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
103
|
+
}
|
|
104
|
+
:where(.ma-price__input:checked + .ma-price__opt) {
|
|
105
|
+
opacity: 1;
|
|
106
|
+
}
|
|
107
|
+
:where(.ma-price__input:focus-visible + .ma-price__opt) {
|
|
108
|
+
outline: 2px solid currentColor;
|
|
109
|
+
outline-offset: 2px;
|
|
110
|
+
}
|
|
111
|
+
:where(.ma-price__thumb) {
|
|
112
|
+
position: absolute;
|
|
113
|
+
inset: 0.2em auto 0.2em 0.2em;
|
|
114
|
+
width: calc(50% - 0.2em);
|
|
115
|
+
border-radius: 999px;
|
|
116
|
+
background: var(--ma-edge);
|
|
117
|
+
transition: translate var(--ma-duration) var(--ma-ease);
|
|
118
|
+
}
|
|
119
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__thumb) {
|
|
120
|
+
translate: 100% 0;
|
|
121
|
+
}
|
|
122
|
+
/* the badge enters when yearly is chosen */
|
|
123
|
+
:where(.ma-price__save) {
|
|
124
|
+
display: none;
|
|
125
|
+
padding: 0.1em 0.55em;
|
|
126
|
+
border-radius: 999px;
|
|
127
|
+
font-size: 0.75em;
|
|
128
|
+
background: var(--ma-ink);
|
|
129
|
+
color: var(--ma-panel);
|
|
130
|
+
transition:
|
|
131
|
+
opacity var(--ma-duration) var(--ma-ease),
|
|
132
|
+
scale var(--ma-duration) var(--ma-ease),
|
|
133
|
+
display var(--ma-duration) allow-discrete;
|
|
134
|
+
}
|
|
135
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__save) {
|
|
136
|
+
display: inline-block;
|
|
137
|
+
}
|
|
138
|
+
@starting-style {
|
|
139
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__save) {
|
|
140
|
+
opacity: 0;
|
|
141
|
+
scale: 0.6;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
:where(.ma-price__plans) {
|
|
145
|
+
display: grid;
|
|
146
|
+
gap: 1.5em;
|
|
147
|
+
grid-template-columns: repeat(auto-fit, minmax(8em, 1fr));
|
|
148
|
+
margin-top: 1.25em;
|
|
149
|
+
}
|
|
150
|
+
/* the price every NumberFlow in a plan shows */
|
|
151
|
+
:where(.ma-price__plan) {
|
|
152
|
+
--ma-nf-value: var(--ma-price-monthly);
|
|
153
|
+
}
|
|
154
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__plan) {
|
|
155
|
+
--ma-nf-value: var(--ma-price-yearly);
|
|
156
|
+
}
|
|
157
|
+
:where(.ma-price__name) {
|
|
158
|
+
margin: 0 0 0.35em;
|
|
159
|
+
font-size: 0.875em;
|
|
160
|
+
opacity: 0.7;
|
|
161
|
+
}
|
|
162
|
+
:where(.ma-price__amount) {
|
|
163
|
+
display: flex;
|
|
164
|
+
align-items: baseline;
|
|
165
|
+
gap: 0.1em;
|
|
166
|
+
margin: 0;
|
|
167
|
+
font-size: 2.25em;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
letter-spacing: -0.03em;
|
|
170
|
+
line-height: 1;
|
|
171
|
+
}
|
|
172
|
+
:where(.ma-price__currency) {
|
|
173
|
+
font-size: 0.6em;
|
|
174
|
+
align-self: flex-start;
|
|
175
|
+
margin-top: 0.15em;
|
|
176
|
+
}
|
|
177
|
+
:where(.ma-price__period) {
|
|
178
|
+
display: inline-grid;
|
|
179
|
+
margin-left: 0.2em;
|
|
180
|
+
font-size: 0.4em;
|
|
181
|
+
font-weight: 400;
|
|
182
|
+
letter-spacing: 0;
|
|
183
|
+
opacity: 0.7;
|
|
184
|
+
}
|
|
185
|
+
:where(.ma-price__per) {
|
|
186
|
+
grid-area: 1 / 1;
|
|
187
|
+
transition:
|
|
188
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
189
|
+
translate var(--ma-duration) var(--ma-ease);
|
|
190
|
+
}
|
|
191
|
+
:where(.ma-price__per--y) {
|
|
192
|
+
opacity: 0;
|
|
193
|
+
translate: 0 var(--ma-travel-hover);
|
|
194
|
+
}
|
|
195
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__per) {
|
|
196
|
+
opacity: 0;
|
|
197
|
+
translate: 0 calc(-1 * var(--ma-travel-hover));
|
|
198
|
+
}
|
|
199
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__per--y) {
|
|
200
|
+
opacity: 1;
|
|
201
|
+
translate: 0 0;
|
|
202
|
+
}
|
|
203
|
+
:where(.ma-price__note) {
|
|
204
|
+
margin: 0.5em 0 0;
|
|
205
|
+
font-size: 0.875em;
|
|
206
|
+
opacity: 0.7;
|
|
207
|
+
}
|
|
208
|
+
/* the current price as text, for assistive tech */
|
|
209
|
+
:where(.ma-price__sr) {
|
|
210
|
+
position: absolute;
|
|
211
|
+
width: 1px;
|
|
212
|
+
height: 1px;
|
|
213
|
+
overflow: hidden;
|
|
214
|
+
clip-path: inset(50%);
|
|
215
|
+
white-space: nowrap;
|
|
216
|
+
}
|
|
217
|
+
:where(.ma-price__sr--y),
|
|
218
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__sr:not(.ma-price__sr--y)) {
|
|
219
|
+
display: none;
|
|
220
|
+
}
|
|
221
|
+
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__sr--y) {
|
|
222
|
+
display: block;
|
|
223
|
+
}
|
|
224
|
+
/* NumberFlow's digits need round() and mod() (the same test as its own). An engine with :has() and without them
|
|
225
|
+
(Chrome 105 to 124) kept the monthly digits while the period swapped to /yr: there the price is the text itself */
|
|
226
|
+
@supports not (translate: calc(mod(round(down, 7, 1), 10) * 1em)) {
|
|
227
|
+
:where(.ma-price__currency),
|
|
228
|
+
:where(.ma-price__period) {
|
|
229
|
+
display: none;
|
|
230
|
+
}
|
|
231
|
+
/* one class of weight: the number is also NumberFlow's root, whose own display rule may come later in the page */
|
|
232
|
+
:where(.ma-price__amount) .ma-price__number {
|
|
233
|
+
display: none;
|
|
234
|
+
}
|
|
235
|
+
:where(.ma-price__sr) {
|
|
236
|
+
position: static;
|
|
237
|
+
width: auto;
|
|
238
|
+
height: auto;
|
|
239
|
+
overflow: visible;
|
|
240
|
+
clip-path: none;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
@media (prefers-reduced-motion: reduce) {
|
|
244
|
+
:where(.ma-price__thumb),
|
|
245
|
+
:where(.ma-price__save),
|
|
246
|
+
:where(.ma-price__per) {
|
|
247
|
+
transition: none;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'PriceSwitch',
|
|
5
|
+
slug: 'price-switch',
|
|
6
|
+
title: 'Price Switch',
|
|
7
|
+
description:
|
|
8
|
+
'A monthly / yearly toggle whose prices roll to the other amount instead of swapping. The thumb slides, the period label changes places, and a "save" badge appears on the yearly option. Two native radios, :has() and NumberFlow. Zero JavaScript; both prices are in the HTML as text for assistive tech.',
|
|
9
|
+
summary: 'A monthly and yearly price toggle whose prices roll to the other amount, with a save badge on the yearly option.',
|
|
10
|
+
category: 'data',
|
|
11
|
+
tier: 'A',
|
|
12
|
+
trigger: ['click'],
|
|
13
|
+
support: { baseline: 'newly', needs: ['has', 'css-math', 'starting-style', 'allow-discrete'], fallback: 'without round() and mod() the price is plain text that changes in place with the period; without @starting-style the badge appears without its entrance' },
|
|
14
|
+
readout: '<PriceSwitch monthly={12} yearly={115}>',
|
|
15
|
+
usage: `<PriceSwitch id="billing" plans={[\n { name: 'Solo', monthly: 12, yearly: 115, note: 'one site' },\n { name: 'Team', monthly: 48, yearly: 460, note: 'up to ten' },\n]} save="2 months free" />`,
|
|
16
|
+
props: [
|
|
17
|
+
{ name: 'id', type: 'string', description: 'Radio group name and id prefix; unique per page.' },
|
|
18
|
+
{ name: 'plans', type: '{ name?, monthly, yearly, note? }[]', description: 'Plans to price side by side. Omit it for a single price from monthly and yearly.' },
|
|
19
|
+
{ name: 'monthly', type: 'number', description: 'Monthly price of the single plan.' },
|
|
20
|
+
{ name: 'yearly', type: 'number', description: 'Yearly price of the single plan.' },
|
|
21
|
+
{ name: 'currency', type: 'string', default: "'$'", description: 'Symbol before the number.' },
|
|
22
|
+
{ name: 'periods', type: '[string, string]', default: "['/mo', '/yr']", description: 'Period labels after the number.' },
|
|
23
|
+
{ name: 'labels', type: '[string, string]', default: "['Monthly', 'Yearly']", description: 'Toggle labels.' },
|
|
24
|
+
{ name: 'save', type: 'string', description: 'Badge shown on the yearly option once it is chosen, e.g. "save 20%".' },
|
|
25
|
+
{ name: 'groupLabel', type: 'string', default: "'Billing period'", description: 'Accessible name of the radio group, for a page in another language.' },
|
|
26
|
+
],
|
|
27
|
+
reducedMotion: 'Prices, thumb and labels change in place; the badge appears without scaling in.',
|
|
28
|
+
clientRouter: 'CSS-only; nothing to rebind. The radio state is the page\'s own.',
|
|
29
|
+
craft: [
|
|
30
|
+
'The toggle is two radios: keyboard, focus ring and one-of-two are the browser\'s. :has() on the wrapper turns the checked one into --ma-nf-value on every plan, and NumberFlow does the rest.',
|
|
31
|
+
'Prices roll rather than swap, so the eye sees how far apart the two amounts are; a swap hides that.',
|
|
32
|
+
'The badge waits until yearly is chosen and enters through @starting-style: information arrives at the moment it becomes true, not as a permanent nag beside the label.',
|
|
33
|
+
'The period label crosses the thumb\'s direction (/mo leaves upward while /yr arrives from below), the same enter/exit asymmetry as everything else here.',
|
|
34
|
+
'Both prices exist as visually hidden text and the one for the chosen period is the only one not display: none, so a screen reader hears the right amount while the rolling digits stay aria-hidden.',
|
|
35
|
+
'The digits need round() and mod() (NumberFlow). In an engine with :has() and without them (Chrome 105 to 124) the rolling number, the symbol and the period are hidden and that same text is the visible price, so the amount and the period always agree.',
|
|
36
|
+
],
|
|
37
|
+
related: ['number-flow', 'tabs', 'count-up'],
|
|
38
|
+
intents: ['pricing toggle monthly yearly', 'price switch animation', 'billing period toggle', 'animated pricing switch', 'pricing table toggle astro'],
|
|
39
|
+
replaces: ['pricing toggles (motion.dev, Tailwind Plus)', 'framer-motion price flips'],
|
|
40
|
+
fixture: { props: { id: 'fixture', monthly: 12, yearly: 115 }, expect: 'class="ma-price"' },
|
|
41
|
+
} satisfies ComponentMeta;
|