@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,363 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CommandPalette — ⌘K. A native modal <dialog> (the library's Dialog), an
|
|
4
|
+
* input, and a ranked list; ↑ ↓ move, ↵ follows the link (through the
|
|
5
|
+
* ClientRouter, if there is one), Escape closes, focus returns to what had
|
|
6
|
+
* it. Items come from the server (`items`), from a JSON URL fetched once on
|
|
7
|
+
* the first open (`source`), or both. Ranking is simple and stated: a
|
|
8
|
+
* prefix of the label beats a prefix of a word beats a substring beats a
|
|
9
|
+
* keyword, per query token. Results are re-rendered as new elements, so
|
|
10
|
+
* each arrives through @starting-style on the stagger token — no FLIP.
|
|
11
|
+
* Without JavaScript the trigger still opens the dialog (invoker) and the
|
|
12
|
+
* server-rendered items are plain links.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
15
|
+
import Dialog from './Dialog.astro';
|
|
16
|
+
|
|
17
|
+
export interface PaletteItem {
|
|
18
|
+
label: string;
|
|
19
|
+
href: string;
|
|
20
|
+
/** Group heading. */
|
|
21
|
+
group?: string;
|
|
22
|
+
/** Right-hand hint (a category, a shortcut, a cost). */
|
|
23
|
+
hint?: string;
|
|
24
|
+
/** Extra words the search matches. */
|
|
25
|
+
keywords?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
29
|
+
/** Dialog id; unique per page. */
|
|
30
|
+
id: string;
|
|
31
|
+
items?: PaletteItem[];
|
|
32
|
+
/** URL of a JSON array of items, fetched once on the first open. */
|
|
33
|
+
source?: string;
|
|
34
|
+
/** Key for ⌘ / Ctrl + key. */
|
|
35
|
+
hotkey?: string;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Trigger label. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Results shown at most. */
|
|
40
|
+
max?: number;
|
|
41
|
+
/** Message when nothing matches. */
|
|
42
|
+
empty?: string;
|
|
43
|
+
/** The live count under the list; {n} is the number of results. */
|
|
44
|
+
countLabel?: string;
|
|
45
|
+
/** Add the inline click handler for browsers without invoker commands. */
|
|
46
|
+
polyfill?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { id, items = [], source, hotkey = 'k', placeholder = 'Search…', label = 'Search', max = 12, empty = 'Nothing found', countLabel = '{n} found', polyfill = false, class: className, ...rest } = Astro.props;
|
|
50
|
+
const listId = `${id}-list`;
|
|
51
|
+
const groups = new Map<string, PaletteItem[]>();
|
|
52
|
+
for (const it of items) (groups.get(it.group ?? '') ?? groups.set(it.group ?? '', []).get(it.group ?? '')!).push(it);
|
|
53
|
+
let n = 0;
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
<Dialog id={id} close={false} polyfill={polyfill} class="ma-cmdk__dialog" aria-label={label}>
|
|
57
|
+
<button slot="trigger" type="button" class="ma-cmdk__trigger" command="show-modal" commandfor={id} aria-label={label}>
|
|
58
|
+
<svg aria-hidden="true" viewBox="0 0 20 20" width="14" height="14"><circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="1.5" /><path d="M14 14l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
|
|
59
|
+
<span class="ma-cmdk__trigger-label">{label}</span>
|
|
60
|
+
<kbd class="ma-cmdk__kbd">⌘{hotkey.toUpperCase()}</kbd>
|
|
61
|
+
</button>
|
|
62
|
+
<div class:list={['ma-cmdk', className]} data-ma-cmdk data-source={source} data-hotkey={hotkey} data-max={max} data-count={countLabel} {...rest}>
|
|
63
|
+
<div class="ma-cmdk__field">
|
|
64
|
+
<svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16"><circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="1.5" /><path d="M14 14l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
|
|
65
|
+
<input type="search" class="ma-cmdk__input" placeholder={placeholder} aria-label={label} role="combobox" aria-expanded="true" aria-controls={listId} aria-autocomplete="list" autocomplete="off" spellcheck="false" />
|
|
66
|
+
</div>
|
|
67
|
+
<ul class="ma-cmdk__list" id={listId} role="listbox" aria-label={label}>
|
|
68
|
+
{
|
|
69
|
+
[...groups].map(([g, list]) => (
|
|
70
|
+
<>
|
|
71
|
+
{g && <li class="ma-cmdk__group" role="presentation">{g}</li>}
|
|
72
|
+
{list.map((it) => (
|
|
73
|
+
<li class="ma-cmdk__item" role="none" style={`--ma-cmdk-i:${Math.min(n, 8)}`} data-l={it.label} data-g={g || undefined} data-d={it.hint} data-k={(it.keywords ?? []).join(' ').toLowerCase() || undefined}>
|
|
74
|
+
<a href={it.href} tabindex="-1" role="option" id={`${listId}-${n++}`} aria-selected="false">
|
|
75
|
+
<span class="ma-cmdk__label">{it.label}</span>
|
|
76
|
+
{it.hint && <small class="ma-cmdk__hint">{it.hint}</small>}
|
|
77
|
+
</a>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</>
|
|
81
|
+
))
|
|
82
|
+
}
|
|
83
|
+
</ul>
|
|
84
|
+
<p class="ma-cmdk__empty" hidden>{empty}</p>
|
|
85
|
+
<p class="ma-cmdk__foot"><span><kbd class="ma-cmdk__kbd">↑↓</kbd> move</span><span><kbd class="ma-cmdk__kbd">↵</kbd> open</span><span><kbd class="ma-cmdk__kbd">esc</kbd> close</span><span class="ma-cmdk__count" aria-live="polite"></span></p>
|
|
86
|
+
</div>
|
|
87
|
+
</Dialog>
|
|
88
|
+
|
|
89
|
+
<style is:global>
|
|
90
|
+
@layer components {
|
|
91
|
+
:where(.ma-cmdk__dialog) {
|
|
92
|
+
max-width: min(36rem, calc(100vw - 2rem));
|
|
93
|
+
margin-top: 10vh;
|
|
94
|
+
margin-bottom: auto;
|
|
95
|
+
}
|
|
96
|
+
:where(.ma-cmdk__dialog .ma-dialog__panel) {
|
|
97
|
+
padding: 0;
|
|
98
|
+
}
|
|
99
|
+
:where(.ma-cmdk__trigger) {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: 0.5rem;
|
|
103
|
+
height: 2.25rem;
|
|
104
|
+
padding: 0 0.625rem 0 0.75rem;
|
|
105
|
+
border: 1px solid var(--ma-edge);
|
|
106
|
+
border-radius: 0.5rem;
|
|
107
|
+
background: var(--ma-panel);
|
|
108
|
+
color: inherit;
|
|
109
|
+
font: inherit;
|
|
110
|
+
font-size: 0.8125rem;
|
|
111
|
+
opacity: 0.8;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), border-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
114
|
+
}
|
|
115
|
+
:where(.ma-cmdk__trigger:hover) {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
border-color: color-mix(in srgb, var(--ma-ink) 30%, transparent);
|
|
118
|
+
}
|
|
119
|
+
:where(.ma-cmdk__trigger:focus-visible) {
|
|
120
|
+
outline: 2px solid currentColor;
|
|
121
|
+
outline-offset: 2px;
|
|
122
|
+
}
|
|
123
|
+
:where(.ma-cmdk__kbd) {
|
|
124
|
+
display: inline-grid;
|
|
125
|
+
place-items: center;
|
|
126
|
+
min-width: 1.5em;
|
|
127
|
+
padding: 0.1em 0.35em;
|
|
128
|
+
border: 1px solid var(--ma-edge);
|
|
129
|
+
border-bottom-width: 2px;
|
|
130
|
+
border-radius: 0.3em;
|
|
131
|
+
font-family: ui-monospace, monospace;
|
|
132
|
+
font-size: 0.6875rem;
|
|
133
|
+
line-height: 1.4;
|
|
134
|
+
opacity: 0.8;
|
|
135
|
+
}
|
|
136
|
+
:where(.ma-cmdk__field) {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 0.75rem;
|
|
140
|
+
padding: 0.875rem 1rem;
|
|
141
|
+
border-bottom: 1px solid var(--ma-edge);
|
|
142
|
+
opacity: 0.9;
|
|
143
|
+
}
|
|
144
|
+
/* the input's focus ring is the row it sits in: a 2 px line along the bottom (the input itself draws no outline) */
|
|
145
|
+
:where(.ma-cmdk__field:focus-within) {
|
|
146
|
+
box-shadow: inset 0 -2px 0 currentColor;
|
|
147
|
+
}
|
|
148
|
+
:where(.ma-cmdk__input) {
|
|
149
|
+
flex: 1;
|
|
150
|
+
min-width: 0;
|
|
151
|
+
border: 0;
|
|
152
|
+
background: none;
|
|
153
|
+
color: inherit;
|
|
154
|
+
font: inherit;
|
|
155
|
+
font-size: 1rem;
|
|
156
|
+
outline: none;
|
|
157
|
+
}
|
|
158
|
+
:where(.ma-cmdk__input::-webkit-search-cancel-button) {
|
|
159
|
+
display: none;
|
|
160
|
+
}
|
|
161
|
+
:where(.ma-cmdk__list) {
|
|
162
|
+
max-height: min(50vh, 24rem);
|
|
163
|
+
margin: 0;
|
|
164
|
+
padding: 0.375rem;
|
|
165
|
+
overflow: auto;
|
|
166
|
+
overscroll-behavior: contain;
|
|
167
|
+
list-style: none;
|
|
168
|
+
}
|
|
169
|
+
:where(.ma-cmdk__group) {
|
|
170
|
+
padding: 0.625rem 0.625rem 0.25rem;
|
|
171
|
+
font-family: ui-monospace, monospace;
|
|
172
|
+
font-size: 0.6875rem;
|
|
173
|
+
letter-spacing: 0.08em;
|
|
174
|
+
text-transform: uppercase;
|
|
175
|
+
opacity: var(--ma-dim, 0.7);
|
|
176
|
+
}
|
|
177
|
+
/* each result is a new element: it arrives through @starting-style, staggered by its index (capped at nine). The
|
|
178
|
+
index has the palette's own name: the shared --ma-i inherits, and a row would pass it to anything inside it */
|
|
179
|
+
:where(.ma-cmdk__item) {
|
|
180
|
+
opacity: 1;
|
|
181
|
+
translate: 0 0;
|
|
182
|
+
transition:
|
|
183
|
+
opacity var(--ma-duration) var(--ma-ease-out),
|
|
184
|
+
translate var(--ma-duration) var(--ma-ease);
|
|
185
|
+
transition-delay: calc(var(--ma-cmdk-i, 0) * var(--ma-stagger-tight));
|
|
186
|
+
}
|
|
187
|
+
@starting-style {
|
|
188
|
+
:where(.ma-cmdk__item) {
|
|
189
|
+
opacity: 0;
|
|
190
|
+
translate: 0 var(--ma-travel-hover, 4px);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
:where(.ma-cmdk__item a) {
|
|
194
|
+
display: flex;
|
|
195
|
+
align-items: baseline;
|
|
196
|
+
justify-content: space-between;
|
|
197
|
+
gap: 1rem;
|
|
198
|
+
padding: 0.5rem 0.625rem;
|
|
199
|
+
border-radius: 0.5rem;
|
|
200
|
+
color: inherit;
|
|
201
|
+
text-decoration: none;
|
|
202
|
+
font-size: 0.9375rem;
|
|
203
|
+
transition: background-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
204
|
+
}
|
|
205
|
+
:where(.ma-cmdk__item a[aria-selected='true']),
|
|
206
|
+
:where(.ma-cmdk__item a:hover) {
|
|
207
|
+
background: color-mix(in srgb, var(--ma-ink) 7%, transparent);
|
|
208
|
+
}
|
|
209
|
+
:where(.ma-cmdk__hint) {
|
|
210
|
+
flex: none;
|
|
211
|
+
font-family: ui-monospace, monospace;
|
|
212
|
+
font-size: 0.6875rem;
|
|
213
|
+
opacity: var(--ma-dim, 0.7);
|
|
214
|
+
white-space: nowrap;
|
|
215
|
+
}
|
|
216
|
+
:where(.ma-cmdk__empty) {
|
|
217
|
+
margin: 0;
|
|
218
|
+
padding: 1.5rem 1rem;
|
|
219
|
+
text-align: center;
|
|
220
|
+
font-size: 0.875rem;
|
|
221
|
+
opacity: var(--ma-dim, 0.7);
|
|
222
|
+
}
|
|
223
|
+
:where(.ma-cmdk__foot) {
|
|
224
|
+
display: flex;
|
|
225
|
+
gap: 1rem;
|
|
226
|
+
margin: 0;
|
|
227
|
+
padding: 0.625rem 1rem;
|
|
228
|
+
border-top: 1px solid var(--ma-edge);
|
|
229
|
+
font-family: ui-monospace, monospace;
|
|
230
|
+
font-size: 0.6875rem;
|
|
231
|
+
opacity: var(--ma-dim, 0.7);
|
|
232
|
+
}
|
|
233
|
+
:where(.ma-cmdk__count) {
|
|
234
|
+
margin-left: auto;
|
|
235
|
+
}
|
|
236
|
+
@media (prefers-reduced-motion: reduce) {
|
|
237
|
+
:where(.ma-cmdk__item) {
|
|
238
|
+
transition: none;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
243
|
+
|
|
244
|
+
<script>
|
|
245
|
+
import { onMount } from '../lib/runtime';
|
|
246
|
+
|
|
247
|
+
type Item = { l: string; h: string; g?: string; d?: string; k: string };
|
|
248
|
+
|
|
249
|
+
// the index outlives a binding: a palette kept through a swap (transition:persist) is bound again on the same node,
|
|
250
|
+
// and by then its list holds rendered results, which carry none of the item data
|
|
251
|
+
const indexes = new WeakMap<HTMLElement, { items: Item[]; loaded: boolean }>();
|
|
252
|
+
|
|
253
|
+
onMount<HTMLElement>('[data-ma-cmdk]', (root, { signal }) => {
|
|
254
|
+
const dialog = root.closest('dialog') as HTMLDialogElement;
|
|
255
|
+
const input = root.querySelector('input')!;
|
|
256
|
+
const list = root.querySelector('ul')!;
|
|
257
|
+
const empty = root.querySelector<HTMLElement>('.ma-cmdk__empty')!;
|
|
258
|
+
const count = root.querySelector<HTMLElement>('.ma-cmdk__count')!;
|
|
259
|
+
const max = Number(root.dataset.max) || 12;
|
|
260
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
261
|
+
const key = root.dataset.hotkey!;
|
|
262
|
+
// the server-rendered options are the first index; `source` extends it once
|
|
263
|
+
const ix = indexes.get(root) ?? { items: [...list.querySelectorAll<HTMLElement>('.ma-cmdk__item')].map((o) => ({ l: o.dataset.l!, h: o.querySelector('a')!.getAttribute('href')!, g: o.dataset.g, d: o.dataset.d, k: o.dataset.k ?? '' })), loaded: !root.dataset.source };
|
|
264
|
+
indexes.set(root, ix);
|
|
265
|
+
let results: Item[] = [];
|
|
266
|
+
let active = 0;
|
|
267
|
+
|
|
268
|
+
// ↑ ↓ move aria-selected over the rows already there: rendered again, every row would enter again through @starting-style
|
|
269
|
+
const select = (n: number) => {
|
|
270
|
+
active = n;
|
|
271
|
+
const opts = list.querySelectorAll<HTMLElement>('[role="option"]');
|
|
272
|
+
opts.forEach((o, i) => o.setAttribute('aria-selected', String(i === n)));
|
|
273
|
+
const sel = opts[n];
|
|
274
|
+
input.setAttribute('aria-activedescendant', sel?.id ?? '');
|
|
275
|
+
// keep the selected row inside the list by moving the list's own scroll only: scrollIntoView() scrolls every ancestor,
|
|
276
|
+
// and a palette rendered off screen (a catalogue tile typing on a timer) would drag the whole page to itself
|
|
277
|
+
if (sel) {
|
|
278
|
+
const r = sel.getBoundingClientRect();
|
|
279
|
+
const l = list.getBoundingClientRect();
|
|
280
|
+
if (r.top < l.top) list.scrollTop += r.top - l.top;
|
|
281
|
+
else if (r.bottom > l.bottom) list.scrollTop += r.bottom - l.bottom;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const render = () => {
|
|
285
|
+
let g = '';
|
|
286
|
+
let i = 0;
|
|
287
|
+
list.innerHTML = results
|
|
288
|
+
.map((r) => {
|
|
289
|
+
const head = r.g && r.g !== g ? ((g = r.g), `<li class="ma-cmdk__group" role="presentation">${esc(g)}</li>`) : '';
|
|
290
|
+
const n = i++;
|
|
291
|
+
return `${head}<li class="ma-cmdk__item" role="none" style="--ma-cmdk-i:${Math.min(n, 8)}"><a href='${esc(r.h)}' tabindex="-1" role="option" id="${list.id}-${n}"><span class="ma-cmdk__label">${esc(r.l)}</span>${r.d ? `<small class="ma-cmdk__hint">${esc(r.d)}</small>` : ''}</a></li>`;
|
|
292
|
+
})
|
|
293
|
+
.join('');
|
|
294
|
+
empty.hidden = results.length > 0 || !input.value;
|
|
295
|
+
// the live count says what it counts: a bare "3" is read out as a number and nothing else
|
|
296
|
+
count.textContent = input.value ? root.dataset.count!.replace('{n}', String(results.length)) : '';
|
|
297
|
+
select(0);
|
|
298
|
+
};
|
|
299
|
+
// prefix of the label > prefix of a word > substring > keyword, per token; every token has to match
|
|
300
|
+
const score = (it: Item, tokens: string[]) => {
|
|
301
|
+
const t = it.l.toLowerCase();
|
|
302
|
+
let s = 0;
|
|
303
|
+
for (const q of tokens) {
|
|
304
|
+
const p = t.startsWith(q) ? 4 : t.split(' ').some((w) => w.startsWith(q)) ? 3 : t.includes(q) ? 2 : it.k.includes(q) ? 1 : 0;
|
|
305
|
+
if (!p) return 0;
|
|
306
|
+
s += p;
|
|
307
|
+
}
|
|
308
|
+
return s;
|
|
309
|
+
};
|
|
310
|
+
const search = () => {
|
|
311
|
+
const tokens = input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
312
|
+
results = ix.items
|
|
313
|
+
.map((it) => [it, tokens.length ? score(it, tokens) : 1] as const)
|
|
314
|
+
.filter((x) => x[1])
|
|
315
|
+
.sort((a, b) => b[1] - a[1])
|
|
316
|
+
.slice(0, max)
|
|
317
|
+
.map((x) => x[0]);
|
|
318
|
+
render();
|
|
319
|
+
};
|
|
320
|
+
const open = async () => {
|
|
321
|
+
if (!dialog.open) dialog.showModal();
|
|
322
|
+
input.value = '';
|
|
323
|
+
input.focus();
|
|
324
|
+
if (!ix.loaded) {
|
|
325
|
+
ix.loaded = true;
|
|
326
|
+
try {
|
|
327
|
+
const more = (await (await fetch(root.dataset.source!, { signal })).json()) as { label: string; href: string; group?: string; hint?: string; keywords?: string[] }[];
|
|
328
|
+
// an entry without a label or an href (another index's shape) is left out, not ranked into a throw
|
|
329
|
+
ix.items = [...ix.items, ...more.filter((m) => m.label && m.href).map((m) => ({ l: m.label, h: m.href, g: m.group, d: m.hint, k: (m.keywords ?? []).join(' ').toLowerCase() }))];
|
|
330
|
+
} catch {
|
|
331
|
+
// offline, or the binding ended mid-fetch (a swap that keeps the palette): the server list stands, and the next
|
|
332
|
+
// open tries again, since the index outlives the binding
|
|
333
|
+
ix.loaded = false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
search();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// every trigger that points at this dialog: the invoker's default is cancelled so the input is focused and the list
|
|
340
|
+
// refreshed; its key hint says Ctrl where the platform is not Apple's
|
|
341
|
+
const ctrl = !/Mac|iP/.test(navigator.platform);
|
|
342
|
+
for (const t of document.querySelectorAll<HTMLElement>(`[commandfor="${dialog.id}"][command="show-modal"]`)) {
|
|
343
|
+
t.addEventListener('click', (e) => { e.preventDefault(); open(); }, { signal });
|
|
344
|
+
if (ctrl) t.querySelector('.ma-cmdk__kbd')?.replaceChildren(`Ctrl ${key.toUpperCase()}`);
|
|
345
|
+
}
|
|
346
|
+
document.addEventListener('keydown', (e) => {
|
|
347
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) { e.preventDefault(); dialog.open ? dialog.close() : open(); }
|
|
348
|
+
}, { signal });
|
|
349
|
+
input.addEventListener('input', search, { signal });
|
|
350
|
+
input.addEventListener('keydown', (e) => {
|
|
351
|
+
const n = results.length;
|
|
352
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) select((active + (e.key === 'ArrowDown' ? 1 : n - 1)) % n); }
|
|
353
|
+
else if (e.key === 'Enter') { e.preventDefault(); list.querySelector<HTMLAnchorElement>('a[aria-selected="true"]')?.click(); }
|
|
354
|
+
// one Escape closes a modal palette: in a search field the first one would only clear the query (Chromium, Firefox)
|
|
355
|
+
else if (e.key === 'Escape' && dialog.matches(':modal')) { e.preventDefault(); dialog.close(); }
|
|
356
|
+
}, { signal });
|
|
357
|
+
// a click on a result follows the link; the dialog closes with the page (or now, on the same page)
|
|
358
|
+
list.addEventListener('click', (e) => { if ((e.target as Element).closest('a')) dialog.close(); }, { signal });
|
|
359
|
+
// Safari has no closedby, and the palette has no close button: there, a click on the backdrop (the dialog itself, the
|
|
360
|
+
// panel fills the rest) closes it, so a phone with no Escape key still has a way out
|
|
361
|
+
if ((dialog as { closedBy?: string }).closedBy == null) dialog.addEventListener('click', (e) => e.target === dialog && dialog.close(), { signal });
|
|
362
|
+
});
|
|
363
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'CommandPalette',
|
|
5
|
+
slug: 'command-palette',
|
|
6
|
+
title: 'Command Palette',
|
|
7
|
+
description: 'A ⌘K command palette: a native modal dialog with an input and a ranked list of links. ↑ ↓ move, ↵ follows the link through the ClientRouter, Escape closes and focus returns to where it was; results are re-rendered and each arrives through @starting-style on the stagger token. Items come from the server, from a JSON URL fetched once on the first open, or both. Without JavaScript the invoker still opens the dialog and the server-rendered items are plain links.',
|
|
8
|
+
summary: 'A ⌘K command palette in a native dialog: type to filter, arrow keys to move, Enter to go, Escape to close.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'C',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['dialog', 'command', 'starting-style'], fallback: 'without invoker commands the trigger is the script\'s (or polyfill\'s); without @starting-style results appear at once; without closedby (Safari) the script closes it on a tap on the backdrop' },
|
|
13
|
+
readout: '<CommandPalette hotkey="k">',
|
|
14
|
+
usage: `<CommandPalette\n id="palette"\n items={[\n { label: 'Installation', href: '/docs/installation/', group: 'Docs', hint: '5 min' },\n { label: 'Scroll Reveal', href: '/components/reveal/', group: 'Components', hint: 'scroll', keywords: ['aos', 'fade in'] },\n ]}\n source="/palette.json"\n/>\n\n<!-- any button on the page can open it -->\n<button type="button" command="show-modal" commandfor="palette">Search</button>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'id', type: 'string', description: 'Dialog id; every button with commandfor={id} opens it.' },
|
|
17
|
+
{ name: 'items', type: '{ label: string; href: string; group?: string; hint?: string; keywords?: string[] }[]', default: '[]', description: 'Server-rendered items: the first index, and the list without JavaScript.' },
|
|
18
|
+
{ name: 'source', type: 'string', description: 'URL of a JSON array in the same shape, fetched on the first open and merged (a fetch that failed is tried again on the next open); an entry without a label or an href is left out.' },
|
|
19
|
+
{ name: 'hotkey', type: 'string', default: "'k'", description: '⌘ / Ctrl + this key toggles the palette. The trigger shows ⌘ on Apple platforms and Ctrl elsewhere.' },
|
|
20
|
+
{ name: 'placeholder', type: 'string', default: "'Search…'", description: 'Input placeholder.' },
|
|
21
|
+
{ name: 'label', type: 'string', default: "'Search'", description: 'Trigger label and the accessible name of the input and the list.' },
|
|
22
|
+
{ name: 'max', type: 'number', default: '12', description: 'Results shown at most.' },
|
|
23
|
+
{ name: 'empty', type: 'string', default: "'Nothing found'", description: 'Shown when a query matches nothing.' },
|
|
24
|
+
{ name: 'countLabel', type: 'string', default: "'{n} found'", description: 'The result count under the list, a polite live region; {n} is the number.' },
|
|
25
|
+
{ name: 'polyfill', type: 'boolean', default: 'false', description: 'Inline the invoker click handler (403 B raw) for browsers without command / commandfor.' },
|
|
26
|
+
],
|
|
27
|
+
reducedMotion: 'Results appear without the stagger; the dialog itself cross-fades in 150 ms.',
|
|
28
|
+
clientRouter: 'Bound through the shared runtime; the document hotkey listener is aborted before the swap. ↵ clicks the link, so the router handles the navigation and the dialog closes with the page. A palette kept with transition:persist keeps its index (and the fetched source) across the rebind.',
|
|
29
|
+
craft: [
|
|
30
|
+
'A native modal dialog does the focus trap and the focus return; the script owns the search, the keyboard on the list and the fetch, and closes on the first Escape (a search field would spend it clearing the query) and on a tap on the backdrop, which Safari\'s dialog does not do without closedby.',
|
|
31
|
+
'The ranking is four levels and stated on the page: a prefix of the label, a prefix of a word, a substring, a keyword. It is applied per token, and every token is required. The order is predictable, which suits a list of a hundred links.',
|
|
32
|
+
'Results are new elements every time, so @starting-style gives each one an entrance delayed by its index on the tight stagger, capped at nine so a long list never waits. There is no FLIP and no measured position. ↑ ↓ only move aria-selected over the rows already there, so the selection moves and nothing enters again.',
|
|
33
|
+
'aria-activedescendant on the combobox input, options with ids, aria-selected on one: the screen reader follows the arrow keys without the focus leaving the input.',
|
|
34
|
+
'The trigger is a real invoker: without the script the dialog still opens and the server-rendered items are links. The script cancels the invoker\'s default only to focus the input and refresh the list.',
|
|
35
|
+
],
|
|
36
|
+
related: ['dialog', 'file-tree', 'docs-shell'],
|
|
37
|
+
intents: ['command palette', 'cmd k search', 'keyboard search dialog', 'spotlight search astro', 'site search modal', 'command menu'],
|
|
38
|
+
replaces: ['Command (shadcn / cmdk)', 'CommandPalette (Motion Primitives)', 'kbar', 'Search modal (Tailwind Plus)'],
|
|
39
|
+
fixture: { props: { id: 'ma-fixture-cmdk', items: [{ label: 'Fixture content for CommandPalette', href: '/', group: 'Pages' }, { label: 'Two', href: '/two/' }] }, expect: 'Fixture content for CommandPalette' },
|
|
40
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Confetti — a burst of paper on load, on a click or on an `ma:confetti`
|
|
4
|
+
* event, then nothing: the canvas exists only while pieces are falling and is
|
|
5
|
+
* removed after the last one. The physics (gravity, drag, launch speed,
|
|
6
|
+
* spread, flutter) are custom properties this stylesheet declares on :root,
|
|
7
|
+
* one set per preset, so a snappy page bursts harder than a gentle one; the
|
|
8
|
+
* script reads them and never carries a constant of its own. Colours are the
|
|
9
|
+
* element's computed `color` and four border colours, so tokens go through
|
|
10
|
+
* CSS on .ma-confetti; `colors` takes literal ones only, because the canvas
|
|
11
|
+
* does not resolve var(). trigger="event" fires on `ma:confetti` dispatched on
|
|
12
|
+
* document (detail.target empty or this element), and on a
|
|
13
|
+
* `data-ma-confetti-fire` attribute already present when it binds, so the
|
|
14
|
+
* script asking for a burst may run before or after this one. `confetti()`
|
|
15
|
+
* from '@moonarc/core/confetti' is the same burst from your own handler.
|
|
16
|
+
* Nothing fires under reduced motion.
|
|
17
|
+
*/
|
|
18
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
19
|
+
|
|
20
|
+
interface Props extends HTMLAttributes<'span'> {
|
|
21
|
+
/**
|
|
22
|
+
* load: once, when the element is mounted (a persisted element does not burst again) · click: every click on `for`
|
|
23
|
+
* (or on the parent when `for` is not given) · event: every `ma:confetti` on document whose `detail.target` is empty
|
|
24
|
+
* or this element, plus once at bind when the element carries `data-ma-confetti-fire` (removed as it fires).
|
|
25
|
+
*/
|
|
26
|
+
trigger?: 'load' | 'click' | 'event';
|
|
27
|
+
/** Selector of the element whose clicks fire (click) and whose centre the burst starts from (every trigger). */
|
|
28
|
+
for?: string;
|
|
29
|
+
/** Pieces per burst. */
|
|
30
|
+
count?: number;
|
|
31
|
+
/** Literal colours (hex, rgb(), oklch()); var() is not resolved by the canvas — for tokens use CSS on .ma-confetti (`color` and the four `border-*-color`). Default: those five. */
|
|
32
|
+
colors?: string[];
|
|
33
|
+
/** Launch point in viewport px; default the centre of `for` (or of the clicked parent), the viewport centre on load or event without `for`. */
|
|
34
|
+
origin?: { x: number; y: number };
|
|
35
|
+
/** Launch direction in degrees, 0 = up, 90 = right. */
|
|
36
|
+
angle?: number;
|
|
37
|
+
/** Launch cone in degrees; default the preset token. */
|
|
38
|
+
spread?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { trigger = 'click', for: target, count = 120, colors, origin, angle = 0, spread, class: className, ...rest } = Astro.props;
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<span
|
|
45
|
+
class:list={['ma-confetti', className]}
|
|
46
|
+
data-ma-confetti
|
|
47
|
+
data-trigger={trigger}
|
|
48
|
+
data-for={target}
|
|
49
|
+
data-count={count}
|
|
50
|
+
data-colors={colors?.join('|')}
|
|
51
|
+
data-origin={origin ? `${origin.x},${origin.y}` : undefined}
|
|
52
|
+
data-angle={angle}
|
|
53
|
+
data-spread={spread}
|
|
54
|
+
hidden
|
|
55
|
+
{...rest}
|
|
56
|
+
></span>
|
|
57
|
+
|
|
58
|
+
<style is:global>
|
|
59
|
+
@layer components {
|
|
60
|
+
/* physics per preset: px per frame at 60 fps; the script reads these on :root */
|
|
61
|
+
:where(:root) {
|
|
62
|
+
--ma-confetti-gravity: 0.22;
|
|
63
|
+
--ma-confetti-drag: 0.985;
|
|
64
|
+
--ma-confetti-velocity: 14;
|
|
65
|
+
--ma-confetti-spread: 70;
|
|
66
|
+
--ma-confetti-flutter: 0.12;
|
|
67
|
+
}
|
|
68
|
+
:where([data-ma-preset='snap']) {
|
|
69
|
+
--ma-confetti-velocity: 18;
|
|
70
|
+
--ma-confetti-drag: 0.975;
|
|
71
|
+
--ma-confetti-spread: 50;
|
|
72
|
+
}
|
|
73
|
+
:where([data-ma-preset='lively']) {
|
|
74
|
+
--ma-confetti-velocity: 17;
|
|
75
|
+
--ma-confetti-spread: 90;
|
|
76
|
+
--ma-confetti-flutter: 0.18;
|
|
77
|
+
}
|
|
78
|
+
:where([data-ma-preset='gentle']),
|
|
79
|
+
:where([data-ma-preset='ambient']) {
|
|
80
|
+
--ma-confetti-velocity: 11;
|
|
81
|
+
--ma-confetti-gravity: 0.16;
|
|
82
|
+
--ma-confetti-drag: 0.99;
|
|
83
|
+
}
|
|
84
|
+
/* the default palette: five colours the script reads from this element's computed style; override them with CSS (var() included), not through `colors` */
|
|
85
|
+
:where(.ma-confetti) {
|
|
86
|
+
color: #f5a524;
|
|
87
|
+
border: 0 solid;
|
|
88
|
+
border-top-color: #e4572e;
|
|
89
|
+
border-right-color: #3a9a5b;
|
|
90
|
+
border-bottom-color: #4c7dff;
|
|
91
|
+
border-left-color: #c94ad3;
|
|
92
|
+
}
|
|
93
|
+
/* nothing fires under reduced motion (the script checks); stated here so the audit sees the branch */
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
:where(.ma-confetti) {
|
|
96
|
+
display: none;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
</style>
|
|
101
|
+
|
|
102
|
+
<script>
|
|
103
|
+
import { onMount } from '../lib/runtime';
|
|
104
|
+
import { colors } from '../lib/canvas';
|
|
105
|
+
import { confetti } from '../lib/confetti';
|
|
106
|
+
|
|
107
|
+
onMount<HTMLElement>('[data-ma-confetti]', (el, { signal }) => {
|
|
108
|
+
const d = el.dataset;
|
|
109
|
+
// every trigger is armed whatever the motion setting: confetti() reads reduced motion at the moment of the burst and
|
|
110
|
+
// drops it, so a click after the reader turns motion back on bursts (bound only when motion was allowed, it never did)
|
|
111
|
+
const fire = (from?: Element | null) => {
|
|
112
|
+
const o = d.origin?.split(',').map(Number);
|
|
113
|
+
let origin = o ? { x: o[0]!, y: o[1]! } : undefined;
|
|
114
|
+
if (!origin && from) {
|
|
115
|
+
const r = from.getBoundingClientRect();
|
|
116
|
+
origin = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
117
|
+
}
|
|
118
|
+
confetti({ count: Number(d.count) || 120, colors: d.colors ? d.colors.split('|') : colors(el), origin, angle: Number(d.angle) || 0, spread: d.spread ? Number(d.spread) : undefined });
|
|
119
|
+
};
|
|
120
|
+
const fromFor = () => fire(d.for ? document.querySelector(d.for) : null);
|
|
121
|
+
// load is once per element: one kept across a swap by transition:persist is bound again after it, and must not burst again
|
|
122
|
+
if (d.trigger === 'load') {
|
|
123
|
+
if (!('maConfettiDone' in d)) fromFor();
|
|
124
|
+
d.maConfettiDone = '';
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (d.trigger === 'event') {
|
|
128
|
+
// A request made before this script ran is the attribute, one made after is the event; a caller that does both in
|
|
129
|
+
// one task (set the attribute, dispatch) gets one burst whichever script runs first. Firing clears the attribute.
|
|
130
|
+
const go = () => {
|
|
131
|
+
delete d.maConfettiFire;
|
|
132
|
+
fromFor();
|
|
133
|
+
};
|
|
134
|
+
if ('maConfettiFire' in d) go();
|
|
135
|
+
document.addEventListener('ma:confetti', ((e: CustomEvent<{ target?: Element | null } | null>) => {
|
|
136
|
+
if (!e.detail?.target || e.detail.target === el) go();
|
|
137
|
+
}) as EventListener, { signal });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const target = d.for ? document.querySelector(d.for) : el.parentElement;
|
|
141
|
+
target?.addEventListener('click', () => fire(target), { signal });
|
|
142
|
+
});
|
|
143
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Confetti',
|
|
5
|
+
slug: 'confetti',
|
|
6
|
+
title: 'Confetti',
|
|
7
|
+
description: 'Fires a burst of paper confetti on load, on a click or on an ma:confetti event. One fixed canvas exists only while pieces are falling. Gravity, drag, launch speed and spread are custom properties declared per preset in the stylesheet: the script reads them and carries no constant. Also callable as confetti() from your own handler. Never fires under reduced motion.',
|
|
8
|
+
summary: 'A burst of paper confetti on load, on a click, or from your own code with confetti(), never under reduced motion.',
|
|
9
|
+
category: 'pointer',
|
|
10
|
+
tier: 'C',
|
|
11
|
+
trigger: ['load', 'click'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<Confetti trigger="click" for="#celebrate">',
|
|
14
|
+
usage: `<button id="celebrate" type="button">Celebrate</button>\n<Confetti trigger="click" for="#celebrate" />\n\n<!-- tokens are CSS on the element; the canvas cannot resolve var() in \`colors\` -->\n<Confetti trigger="click" for="#celebrate" style="color: var(--primary, #4c7dff); border-color: var(--chart-1, #e4572e) var(--chart-2, #3a9a5b) var(--chart-3, #f5a524) var(--chart-4, #c94ad3)" />\n\n<!-- from any script, without importing the lib; the attribute covers a script that runs before the component binds -->\n<Confetti trigger="event" id="party" />\n<script>\n const party = document.getElementById('party');\n party.setAttribute('data-ma-confetti-fire', '');\n document.dispatchEvent(new CustomEvent('ma:confetti', { detail: { target: party } }));\n</script>\n\n<!-- or the function itself -->\n<script>\n import { confetti } from '@moonarc/core/confetti';\n form.addEventListener('submit', () => confetti({ count: 200 }));\n</script>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'trigger', type: "'load' | 'click' | 'event'", default: "'click'", description: 'load: once when mounted, once per element (one kept across a navigation by transition:persist does not burst again) · click: on every click of `for`, or of the parent when `for` is not given · event: on every `ma:confetti` dispatched on document whose `detail.target` is empty or this element, and once at bind when the element carries `data-ma-confetti-fire` (removed as it fires). A script that may run before the component sets the attribute and dispatches in the same task: whichever runs first, one burst.' },
|
|
17
|
+
{ name: 'for', type: 'string', description: 'Selector of the element whose clicks fire (click); on every trigger the burst starts from its centre.' },
|
|
18
|
+
{ name: 'count', type: 'number', default: '120', description: 'Pieces per burst.' },
|
|
19
|
+
{ name: 'colors', type: 'string[]', default: 'five accents from the stylesheet', description: 'Literal colours (hex, rgb(), oklch()), cycled through; var() is not resolved by the canvas. For tokens, use CSS on .ma-confetti: its computed `color` and `border-top/right/bottom/left-color` are the five the default reads.' },
|
|
20
|
+
{ name: 'origin', type: '{ x, y }', description: 'Launch point in viewport px; default the centre of `for` (or of the clicked parent), the viewport centre on load or event without `for`.' },
|
|
21
|
+
{ name: 'angle', type: 'number', default: '0', description: 'Launch direction in degrees: 0 up, 90 right.' },
|
|
22
|
+
{ name: 'spread', type: 'number', default: 'the preset token', description: 'Launch cone in degrees; the token is 70, narrower on snap, wider on lively.' },
|
|
23
|
+
],
|
|
24
|
+
reducedMotion: 'Nothing fires on load, on click or on an event, and a `data-ma-confetti-fire` request is discarded. The burst itself reads the setting live and every trigger stays armed, so the site\'s motion switch applies to the next one in both directions, without a reload.',
|
|
25
|
+
clientRouter: 'Bound through the shared runtime, the ma:confetti listener included (one per element, removed on swap); a burst in flight is dropped with its canvas before the swap, and the next page starts clean.',
|
|
26
|
+
craft: [
|
|
27
|
+
'The canvas is created on the first piece and removed after the last one lands: a page with a confetti button costs a hidden span until it is pressed.',
|
|
28
|
+
'The physics live in CSS as custom properties per preset (snap launches harder and stops sooner, ambient floats) so the burst has the page\'s character; the script only reads numbers.',
|
|
29
|
+
'Pieces are paper rectangles that rotate and flutter (a cosine on their height); drag on the velocity gives the arc its shoulder.',
|
|
30
|
+
'Bursts share one canvas and one frame loop; the loop sleeps in a hidden tab like every canvas in the library.',
|
|
31
|
+
'confetti() is exported like toast(): the component is the declarative wiring, the function is the act.',
|
|
32
|
+
'trigger="event" is the ma:toast pattern for code that should not import the lib, made independent of script order: a request made before the component binds is an attribute it consumes, one made after is the event, and firing clears the attribute, so set-then-dispatch bursts once.',
|
|
33
|
+
'Colours are read from the element\'s computed style, like every canvas in the library: fillStyle does not resolve var(), so a token passed through `colors` paints black, while the same token on `color` or a border colour arrives resolved and follows the theme.',
|
|
34
|
+
],
|
|
35
|
+
related: ['toast', 'stateful-button', 'particles'],
|
|
36
|
+
intents: ['confetti on click', 'celebration animation', 'confetti burst success', 'party popper button', 'confetti astro without canvas-confetti', 'confetti after form submit', 'trigger confetti from javascript event'],
|
|
37
|
+
replaces: ['Confetti (Magic UI)', 'canvas-confetti', 'react-confetti'],
|
|
38
|
+
// event: the attribute in the server HTML, the order a thank-you page produces when its script runs first
|
|
39
|
+
fixture: { props: { trigger: 'click' }, expect: 'class="ma-confetti"', variants: { event: { props: { trigger: 'event', 'data-ma-confetti-fire': '' }, expect: 'data-trigger="event"' } } },
|
|
40
|
+
} satisfies ComponentMeta;
|