@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,36 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'ImageTrail',
|
|
5
|
+
slug: 'image-trail',
|
|
6
|
+
title: 'Image Trail',
|
|
7
|
+
description: 'Small images pop up along the pointer\'s path and fade behind it. The images are a server-rendered pool of lazy <img> elements; every `threshold` px the script positions the next one and restarts its keyframes, so nothing is created on move. The pop and the fade are CSS on the preset curve, the lifetime a duration token. On touch and under reduced motion the first image simply rests in the centre, under the slotted content.',
|
|
8
|
+
summary: "Images that pop up along the cursor's path and fade behind it, drawn from a pool of lazy images.",
|
|
9
|
+
category: 'pointer',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['pointer'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['container-units'] },
|
|
13
|
+
readout: '<ImageTrail images={[…]} threshold={80}>',
|
|
14
|
+
usage: `<ImageTrail images={['/a.jpg', '/b.jpg', '/c.jpg', '/d.jpg']} class="h-[28rem]">\n <h2 class="relative">Move over me</h2>\n</ImageTrail>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'images', type: '(string | { src, alt? })[]', description: 'Up to 12 images; the trail cycles through them in order.' },
|
|
17
|
+
{ name: 'threshold', type: 'number', default: '80', description: 'Pointer travel in px between two images. Lower is denser.' },
|
|
18
|
+
{ name: 'lifetime', type: "'fast' | 'base' | 'slow'", default: "'base'", description: 'How long an image stays, as a duration token: the preset duration, the ambient duration, or twice it.' },
|
|
19
|
+
{ name: 'size', type: 'number', default: '140', description: 'Image width in px; the height follows the image.' },
|
|
20
|
+
{ name: 'tilt', type: 'number', default: '8', description: 'Maximum lean in degrees; each image tilts with the direction the pointer was moving.' },
|
|
21
|
+
{ name: 'as', type: 'HTMLTag', default: "'div'", description: 'Element to render.' },
|
|
22
|
+
],
|
|
23
|
+
reducedMotion: 'Nothing spawns (the script reads the setting on every move, so the page\'s own switch applies both ways without a reload); the first image sits still in the centre of the box, under the slotted content, which is also what the HTML shows without JavaScript and on touch.',
|
|
24
|
+
clientRouter: 'Bound through the shared runtime: the listeners are aborted before the swap; the pool is server HTML on every page.',
|
|
25
|
+
craft: [
|
|
26
|
+
'The images are a pool: they exist in the HTML, lazy and hidden, and the script only moves the next one and restarts its animation. The trail never allocates, never decodes on move, and the first image is there without JavaScript. Each move reads the box\'s position once (getBoundingClientRect), and a restart reads offsetWidth once.',
|
|
27
|
+
'Spawn on distance, not on time: an image every N px reads as a trail at any pointer speed; an image every N ms reads as a stutter when the pointer is slow.',
|
|
28
|
+
'The lean follows the pointer\'s direction, clamped to ±tilt, so the trail bends the way the hand moved.',
|
|
29
|
+
'Pop on the exit curve, hold, then fade: three phases in one keyframe set on the lifetime token, so a preset or an override changes all of them at once.',
|
|
30
|
+
'The pool is a size container so the resting image is centred with cqw / cqh and no measurement. At rest the pool drops under the slotted content (z-index -1 inside the isolated box), so a heading in the middle stays readable; live, the trail passes over it.',
|
|
31
|
+
],
|
|
32
|
+
related: ['cursor', 'spotlight', 'tilt'],
|
|
33
|
+
intents: ['image trail on cursor', 'images follow mouse', 'photo trail hover effect', 'cursor image trail astro', 'hover image reveal along mouse path'],
|
|
34
|
+
replaces: ['ImageTrail (React Bits)', 'ImageTrail (Motion Primitives)', 'Image trail (Codrops)'],
|
|
35
|
+
fixture: { props: { images: ['/a.jpg', '/b.jpg'] }, expect: 'class="ma-trail"' },
|
|
36
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Lightbox — thumbnails that open full size, the image growing out of its
|
|
4
|
+
* own thumbnail and returning to it. Built on <Dialog morph>: the dialog is
|
|
5
|
+
* native, the growth is a same-document view transition through lib/morph
|
|
6
|
+
* (no view transitions, or reduced motion → it opens as a plain dialog).
|
|
7
|
+
* The full image is loaded when it opens; the thumbnails are the images on
|
|
8
|
+
* the page, and without JavaScript each is a link to the full file.
|
|
9
|
+
* ← → and the buttons move through the set; the return target follows.
|
|
10
|
+
*/
|
|
11
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
12
|
+
import Dialog from './Dialog.astro';
|
|
13
|
+
|
|
14
|
+
interface Item {
|
|
15
|
+
src: string;
|
|
16
|
+
alt: string;
|
|
17
|
+
/** Thumbnail; default the full image. */
|
|
18
|
+
thumb?: string;
|
|
19
|
+
caption?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
23
|
+
/** Dialog id; unique per page. */
|
|
24
|
+
id: string;
|
|
25
|
+
items: Item[];
|
|
26
|
+
/** Thumbnail columns. */
|
|
27
|
+
columns?: number;
|
|
28
|
+
/** Thumbnail aspect ratio (CSS). */
|
|
29
|
+
ratio?: string;
|
|
30
|
+
/** Accessible name of the gallery and the dialog. */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Accessible names of the previous, next and close buttons. */
|
|
33
|
+
prevLabel?: string;
|
|
34
|
+
nextLabel?: string;
|
|
35
|
+
closeLabel?: string;
|
|
36
|
+
/** Add the inline click handler for browsers without invoker commands. */
|
|
37
|
+
polyfill?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { id, items, columns = 3, ratio = '1', label = 'Gallery', prevLabel = 'Previous image', nextLabel = 'Next image', closeLabel = 'Close', polyfill = false, class: className, style, ...rest } = Astro.props;
|
|
41
|
+
const inline = [`--ma-lightbox-cols:${columns}`, `--ma-lightbox-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<div class:list={['ma-lightbox', className]} data-ma-lightbox style={inline} {...rest}>
|
|
45
|
+
<div class="ma-lightbox__grid" role="group" aria-label={label}>
|
|
46
|
+
{
|
|
47
|
+
items.map((it, i) => (
|
|
48
|
+
<a href={it.src} class="ma-lightbox__thumb" data-i={i} data-caption={it.caption}>
|
|
49
|
+
<img src={it.thumb ?? it.src} alt={it.alt} loading="lazy" decoding="async" draggable="false" />
|
|
50
|
+
</a>
|
|
51
|
+
))
|
|
52
|
+
}
|
|
53
|
+
</div>
|
|
54
|
+
<Dialog id={id} morph polyfill={polyfill} closeLabel={closeLabel} class="ma-lightbox__dialog" aria-label={label}>
|
|
55
|
+
<Fragment slot="trigger" />
|
|
56
|
+
<figure class="ma-lightbox__figure">
|
|
57
|
+
<img class="ma-lightbox__img" alt="" data-lb-img />
|
|
58
|
+
<figcaption class="ma-lightbox__caption" aria-live="polite">
|
|
59
|
+
<span data-lb-caption></span>
|
|
60
|
+
<span class="ma-lightbox__count" data-lb-count></span>
|
|
61
|
+
</figcaption>
|
|
62
|
+
</figure>
|
|
63
|
+
{items.length > 1 && (
|
|
64
|
+
<>
|
|
65
|
+
<button type="button" class="ma-lightbox__nav ma-lightbox__nav--prev" data-lb="-1" aria-label={prevLabel}>
|
|
66
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M10 3L5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
67
|
+
</button>
|
|
68
|
+
<button type="button" class="ma-lightbox__nav ma-lightbox__nav--next" data-lb="1" aria-label={nextLabel}>
|
|
69
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
70
|
+
</button>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
</Dialog>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<style is:global>
|
|
77
|
+
@layer components {
|
|
78
|
+
:where(.ma-lightbox__grid) {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: repeat(var(--ma-lightbox-cols, 3), 1fr);
|
|
81
|
+
gap: 0.5rem;
|
|
82
|
+
}
|
|
83
|
+
:where(.ma-lightbox__thumb) {
|
|
84
|
+
display: block;
|
|
85
|
+
aspect-ratio: var(--ma-lightbox-ratio, 1);
|
|
86
|
+
border: 1px solid var(--ma-edge);
|
|
87
|
+
border-radius: 0.75rem;
|
|
88
|
+
overflow: clip;
|
|
89
|
+
background: var(--ma-edge);
|
|
90
|
+
}
|
|
91
|
+
:where(.ma-lightbox__thumb img) {
|
|
92
|
+
display: block;
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
object-fit: cover;
|
|
96
|
+
transition: scale var(--ma-duration) var(--ma-ease);
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-lightbox__thumb:hover img) {
|
|
99
|
+
scale: 1.04;
|
|
100
|
+
}
|
|
101
|
+
:where(.ma-lightbox__thumb:focus-visible) {
|
|
102
|
+
outline: 2px solid currentColor;
|
|
103
|
+
outline-offset: 3px;
|
|
104
|
+
}
|
|
105
|
+
/* the dialog is the image: no panel padding, clipped corners, sized by the picture */
|
|
106
|
+
:where(.ma-lightbox) .ma-dialog {
|
|
107
|
+
width: fit-content;
|
|
108
|
+
max-width: min(92vw, 80rem);
|
|
109
|
+
max-height: 92dvh;
|
|
110
|
+
overflow: clip;
|
|
111
|
+
}
|
|
112
|
+
:where(.ma-lightbox) .ma-dialog__panel {
|
|
113
|
+
padding: 0;
|
|
114
|
+
}
|
|
115
|
+
:where(.ma-lightbox) .ma-dialog__close button {
|
|
116
|
+
background: var(--ma-panel);
|
|
117
|
+
opacity: 0.9;
|
|
118
|
+
}
|
|
119
|
+
:where(.ma-lightbox__figure) {
|
|
120
|
+
margin: 0;
|
|
121
|
+
}
|
|
122
|
+
:where(.ma-lightbox__img) {
|
|
123
|
+
display: block;
|
|
124
|
+
width: auto;
|
|
125
|
+
height: auto;
|
|
126
|
+
max-width: min(92vw, 80rem);
|
|
127
|
+
max-height: calc(92dvh - 3rem);
|
|
128
|
+
min-width: 16rem;
|
|
129
|
+
min-height: 12rem;
|
|
130
|
+
object-fit: contain;
|
|
131
|
+
background: var(--ma-edge);
|
|
132
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
133
|
+
}
|
|
134
|
+
:where(.ma-dialog[data-loading] .ma-lightbox__img) {
|
|
135
|
+
opacity: 0.4;
|
|
136
|
+
}
|
|
137
|
+
:where(.ma-lightbox__caption) {
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: baseline;
|
|
140
|
+
justify-content: space-between;
|
|
141
|
+
gap: 1rem;
|
|
142
|
+
min-height: 3rem;
|
|
143
|
+
padding: 0.75rem 1rem;
|
|
144
|
+
font-size: 0.8125rem;
|
|
145
|
+
opacity: 0.85;
|
|
146
|
+
}
|
|
147
|
+
:where(.ma-lightbox__count) {
|
|
148
|
+
font-family: ui-monospace, monospace;
|
|
149
|
+
font-size: 0.6875rem;
|
|
150
|
+
letter-spacing: 0.06em;
|
|
151
|
+
/* under the caption's 0.85: 0.8 × 0.85 keeps the counter at 4.5 : 1 on the panel (0.7 read 4.44 : 1) */
|
|
152
|
+
opacity: 0.8;
|
|
153
|
+
white-space: nowrap;
|
|
154
|
+
}
|
|
155
|
+
:where(.ma-lightbox__nav) {
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: calc(50% - 1.5rem);
|
|
158
|
+
display: grid;
|
|
159
|
+
place-items: center;
|
|
160
|
+
width: 2.5rem;
|
|
161
|
+
height: 2.5rem;
|
|
162
|
+
border: 1px solid var(--ma-edge);
|
|
163
|
+
border-radius: 999px;
|
|
164
|
+
background: var(--ma-panel);
|
|
165
|
+
color: inherit;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
opacity: 0.85;
|
|
168
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration) var(--ma-ease);
|
|
169
|
+
}
|
|
170
|
+
:where(.ma-lightbox__nav:hover) {
|
|
171
|
+
opacity: 1;
|
|
172
|
+
scale: 1.06;
|
|
173
|
+
}
|
|
174
|
+
:where(.ma-lightbox__nav:focus-visible) {
|
|
175
|
+
outline: 2px solid currentColor;
|
|
176
|
+
outline-offset: 2px;
|
|
177
|
+
}
|
|
178
|
+
:where(.ma-lightbox__nav--prev) {
|
|
179
|
+
left: 0.75rem;
|
|
180
|
+
}
|
|
181
|
+
:where(.ma-lightbox__nav--next) {
|
|
182
|
+
right: 0.75rem;
|
|
183
|
+
}
|
|
184
|
+
@media (prefers-reduced-motion: reduce) {
|
|
185
|
+
:where(.ma-lightbox__thumb img),
|
|
186
|
+
:where(.ma-lightbox__nav) {
|
|
187
|
+
transition: none;
|
|
188
|
+
scale: 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
</style>
|
|
193
|
+
|
|
194
|
+
<script>
|
|
195
|
+
import { onMount } from '../lib/runtime';
|
|
196
|
+
import { morphOpen, target } from '../lib/morph';
|
|
197
|
+
|
|
198
|
+
onMount<HTMLElement>('[data-ma-lightbox]', (root, { signal }) => {
|
|
199
|
+
const dialog = root.querySelector('dialog') as HTMLDialogElement;
|
|
200
|
+
const thumbs = [...root.querySelectorAll<HTMLAnchorElement>('.ma-lightbox__thumb')];
|
|
201
|
+
const img = dialog.querySelector<HTMLImageElement>('[data-lb-img]')!;
|
|
202
|
+
const cap = dialog.querySelector<HTMLElement>('[data-lb-caption]')!;
|
|
203
|
+
const count = dialog.querySelector<HTMLElement>('[data-lb-count]')!;
|
|
204
|
+
let i = 0;
|
|
205
|
+
// the full image is loaded when shown; the return target of the morph follows the image on screen
|
|
206
|
+
const show = (n: number) => {
|
|
207
|
+
i = (n + thumbs.length) % thumbs.length;
|
|
208
|
+
const t = thumbs[i]!;
|
|
209
|
+
if (img.src !== t.href) {
|
|
210
|
+
dialog.setAttribute('data-loading', '');
|
|
211
|
+
img.src = t.href;
|
|
212
|
+
}
|
|
213
|
+
img.alt = t.querySelector('img')!.alt;
|
|
214
|
+
cap.textContent = t.dataset.caption ?? '';
|
|
215
|
+
count.textContent = `${i + 1} / ${thumbs.length}`;
|
|
216
|
+
target(dialog, t);
|
|
217
|
+
};
|
|
218
|
+
img.addEventListener('load', () => dialog.removeAttribute('data-loading'), { signal });
|
|
219
|
+
thumbs.forEach((t, n) =>
|
|
220
|
+
t.addEventListener(
|
|
221
|
+
'click',
|
|
222
|
+
(e) => {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
show(n);
|
|
225
|
+
morphOpen(dialog, t);
|
|
226
|
+
},
|
|
227
|
+
{ signal },
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
// the two buttons and the two arrow keys are one step each way
|
|
231
|
+
dialog.addEventListener('click', (e) => { const b = (e.target as Element).closest<HTMLElement>('[data-lb]'); b && show(i + +b.dataset.lb!); }, { signal });
|
|
232
|
+
dialog.addEventListener('keydown', (e) => { const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; d && show(i + d); }, { signal });
|
|
233
|
+
// focus lands on the thumbnail of the image that was open, not the one that was clicked
|
|
234
|
+
dialog.addEventListener('close', () => thumbs[i]!.focus(), { signal });
|
|
235
|
+
});
|
|
236
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Lightbox',
|
|
5
|
+
slug: 'lightbox',
|
|
6
|
+
title: 'Lightbox',
|
|
7
|
+
description: 'A thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set. Built on Dialog morph: the dialog is native (Escape, the backdrop where closedby is supported, the close button, the focus trap), the growth is lib/morph, the caption and the count are a polite live region, and without JavaScript each thumbnail is a link to the full file.',
|
|
8
|
+
summary: 'A gallery lightbox where each image grows out of its thumbnail, with arrow keys to move through the set.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['dialog', 'view-transitions'], fallback: 'without view transitions, or under reduced motion, the dialog opens as a plain dialog; without closedby (Safari) a backdrop click does nothing, and Escape and the close button still close it' },
|
|
13
|
+
readout: '<Lightbox columns={3}>',
|
|
14
|
+
usage: `<Lightbox\n id="gallery"\n columns={3}\n items={[\n { src: '/work/one.jpg', thumb: '/work/one-thumb.jpg', alt: 'Poster, 2025', caption: 'Poster, 2025' },\n { src: '/work/two.jpg', thumb: '/work/two-thumb.jpg', alt: 'Book cover', caption: 'Book, 2024' },\n { src: '/work/three.jpg', alt: 'Sign system' },\n ]}\n/>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'id', type: 'string', description: 'Dialog id; unique per page.' },
|
|
17
|
+
{ name: 'items', type: '{ src: string; alt: string; thumb?: string; caption?: string }[]', description: 'The set. thumb defaults to src; alt is required: it names the thumbnail link and the full image.' },
|
|
18
|
+
{ name: 'columns', type: 'number', default: '3', description: 'Thumbnail columns.' },
|
|
19
|
+
{ name: 'ratio', type: 'string', default: "'1'", description: 'Thumbnail aspect ratio.' },
|
|
20
|
+
{ name: 'label', type: 'string', default: "'Gallery'", description: 'Accessible name of the group and the dialog.' },
|
|
21
|
+
{ name: 'prevLabel', type: 'string', default: "'Previous image'", description: 'Accessible name of the previous button.' },
|
|
22
|
+
{ name: 'nextLabel', type: 'string', default: "'Next image'", description: 'Accessible name of the next button.' },
|
|
23
|
+
{ name: 'closeLabel', type: 'string', default: "'Close'", description: 'Accessible name of the close button.' },
|
|
24
|
+
{ name: 'polyfill', type: 'boolean', default: 'false', description: 'Inline the invoker click handler (403 B raw). The thumbnails open through the script, so the Lightbox itself needs it for nothing; kept for parity.' },
|
|
25
|
+
],
|
|
26
|
+
reducedMotion: 'No morph: the dialog opens with the Dialog\'s 150 ms cross-fade and closes the same way.',
|
|
27
|
+
clientRouter: 'Bound through the shared runtime; the view-transition-name exists only for the duration of a morph, so a navigation never sees it.',
|
|
28
|
+
craft: [
|
|
29
|
+
'The dialog is the picture: no panel padding, clipped corners, sized by the image up to 92 % of the viewport, so the morph is thumbnail → image, not thumbnail → box with an image in it.',
|
|
30
|
+
'The full file is loaded when the image opens and never before; the thumbnails are the page\'s own <img> elements, lazy and decoded async. While it loads the image dims to 40 % rather than showing nothing.',
|
|
31
|
+
'The return target follows the image on screen: after ← → the dialog morphs back into the thumbnail of the image you were looking at, and focus lands there too.',
|
|
32
|
+
'The caption and the "2 / 3" count are one polite live region: a step with ← → or the buttons is read out, and the focus stays on the button that made it.',
|
|
33
|
+
'One gallery is one component, set by `items` instead of a group id shared across instances, because the dialog, the counter and the keys belong to the set.',
|
|
34
|
+
'The morph itself is Dialog morph: the same script, the same tokens, the same root cross-fade, measured once.',
|
|
35
|
+
],
|
|
36
|
+
related: ['dialog', 'video-dialog', 'image-compare', 'carousel'],
|
|
37
|
+
intents: ['lightbox', 'image gallery lightbox', 'image zoom on click', 'photo grows from thumbnail', 'gallery modal astro', 'shared element transition image'],
|
|
38
|
+
replaces: ['Lightbox (PhotoSwipe / yet-another-react-lightbox)', 'LayoutGrid (Aceternity)', 'Image zoom (Medium Zoom)', 'layoutId image (Framer Motion)'],
|
|
39
|
+
fixture: { props: { id: 'ma-fixture-lightbox', items: [{ src: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 4 3%22%3E%3Crect width=%224%22 height=%223%22 fill=%22%23888%22/%3E%3C/svg%3E', alt: 'Fixture content for Lightbox' }, { src: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 4 3%22%3E%3Crect width=%224%22 height=%223%22 fill=%22%23444%22/%3E%3C/svg%3E', alt: 'Two' }] }, expect: 'Fixture content for Lightbox' },
|
|
40
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* LinkPreview — a link that shows a small card (image, title, domain) after
|
|
4
|
+
* a moment on hover or focus. The card is a manual popover anchored to the
|
|
5
|
+
* link, so it sits in the top layer above everything and flips when there is
|
|
6
|
+
* no room; the script only shows and hides it. The hover intent is CSS: the
|
|
7
|
+
* card's entrance carries a transition-delay on a duration token, so a
|
|
8
|
+
* pointer crossing the link never flashes a card, and no timer runs in the
|
|
9
|
+
* script. The card can be hovered (the gap to the link is part of it) and
|
|
10
|
+
* Escape hides it wherever the pointer is: WCAG 1.4.13. Everything on the
|
|
11
|
+
* card comes from props at build — there is no fetch, no scraping. Touch and
|
|
12
|
+
* coarse pointers get the link alone.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
15
|
+
|
|
16
|
+
interface Props extends HTMLAttributes<'a'> {
|
|
17
|
+
href: string;
|
|
18
|
+
/** Card title. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** Card image (URL); 16:9, covers. */
|
|
21
|
+
image?: string;
|
|
22
|
+
/** One line under the title. */
|
|
23
|
+
description?: string;
|
|
24
|
+
/** Shown as the source; default the host of href. */
|
|
25
|
+
domain?: string;
|
|
26
|
+
/** The wait before the card shows, as a duration token. */
|
|
27
|
+
delay?: 'fast' | 'base' | 'slow';
|
|
28
|
+
/** position-area of the card. */
|
|
29
|
+
placement?: string;
|
|
30
|
+
/** Popover id; default generated. */
|
|
31
|
+
id?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { href, title, image, description, domain, delay = 'base', placement = 'bottom', id = `ma-lp-${Math.random().toString(36).slice(2, 7)}`, class: className, style, ...rest } = Astro.props;
|
|
35
|
+
const host = (() => {
|
|
36
|
+
if (domain) return domain;
|
|
37
|
+
try {
|
|
38
|
+
const h = new URL(href, 'https://x.invalid').host.replace(/^www\./, '');
|
|
39
|
+
return h === 'x.invalid' ? undefined : h;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
const anchor = `--ma-lp-${id}`;
|
|
45
|
+
const cardStyle = `position-anchor:${anchor};--ma-lpreview-area:${placement}`;
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<span class:list={['ma-lpreview', className]} data-ma-lpreview data-delay={delay} style={typeof style === 'string' ? style : undefined}>
|
|
49
|
+
<a href={href} class="ma-lpreview__link" aria-describedby={id} style={`anchor-name:${anchor}`} {...rest}><slot /></a>
|
|
50
|
+
<span id={id} class="ma-lpreview__card" popover="manual" role="tooltip" style={cardStyle}>
|
|
51
|
+
{image && <img class="ma-lpreview__img" src={image} alt="" loading="lazy" decoding="async" />}
|
|
52
|
+
<span class="ma-lpreview__body">
|
|
53
|
+
<strong class="ma-lpreview__title">{title}</strong>
|
|
54
|
+
{description && <span class="ma-lpreview__desc">{description}</span>}
|
|
55
|
+
{host && <span class="ma-lpreview__domain">{host}</span>}
|
|
56
|
+
</span>
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
|
|
60
|
+
<style is:global>
|
|
61
|
+
@layer components {
|
|
62
|
+
:where(.ma-lpreview) {
|
|
63
|
+
--ma-lpreview-delay: var(--ma-duration);
|
|
64
|
+
}
|
|
65
|
+
:where(.ma-lpreview[data-delay='fast']) {
|
|
66
|
+
--ma-lpreview-delay: var(--ma-duration-fast);
|
|
67
|
+
}
|
|
68
|
+
:where(.ma-lpreview[data-delay='slow']) {
|
|
69
|
+
--ma-lpreview-delay: var(--ma-duration-slow);
|
|
70
|
+
}
|
|
71
|
+
:where(.ma-lpreview__link) {
|
|
72
|
+
color: inherit;
|
|
73
|
+
text-decoration: underline;
|
|
74
|
+
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
|
|
75
|
+
text-decoration-thickness: 1px;
|
|
76
|
+
text-underline-offset: 0.2em;
|
|
77
|
+
transition: text-decoration-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
78
|
+
}
|
|
79
|
+
:where(.ma-lpreview__link:is(:hover, :focus-visible)) {
|
|
80
|
+
text-decoration-color: currentColor;
|
|
81
|
+
}
|
|
82
|
+
:where(.ma-lpreview__link:focus-visible) {
|
|
83
|
+
outline: 2px solid currentColor;
|
|
84
|
+
outline-offset: 2px;
|
|
85
|
+
border-radius: 2px;
|
|
86
|
+
}
|
|
87
|
+
/* the card: top layer, anchored, and its entrance waits on the delay token — the hover intent is CSS */
|
|
88
|
+
:where(.ma-lpreview__card) {
|
|
89
|
+
position: fixed;
|
|
90
|
+
inset: auto;
|
|
91
|
+
position-area: var(--ma-lpreview-area, bottom);
|
|
92
|
+
position-try-fallbacks: flip-block, flip-inline;
|
|
93
|
+
margin: 0.5rem 0;
|
|
94
|
+
width: 16rem;
|
|
95
|
+
padding: 0;
|
|
96
|
+
border: 1px solid var(--ma-edge);
|
|
97
|
+
border-radius: 0.75rem;
|
|
98
|
+
background: var(--ma-panel);
|
|
99
|
+
color: var(--ma-ink);
|
|
100
|
+
box-shadow: 0 16px 40px -20px rgb(0 0 0 / 0.45);
|
|
101
|
+
/* not the popover's own overflow: auto, which would clip the bridge below; the image rounds its own corners */
|
|
102
|
+
overflow: visible;
|
|
103
|
+
pointer-events: none;
|
|
104
|
+
opacity: 0;
|
|
105
|
+
scale: 0.96;
|
|
106
|
+
translate: 0 calc(var(--ma-travel-hover, 4px) * -1);
|
|
107
|
+
transition:
|
|
108
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
109
|
+
scale var(--ma-duration-fast) var(--ma-ease-out),
|
|
110
|
+
translate var(--ma-duration-fast) var(--ma-ease-out),
|
|
111
|
+
display var(--ma-duration-fast) allow-discrete,
|
|
112
|
+
overlay var(--ma-duration-fast) allow-discrete,
|
|
113
|
+
visibility var(--ma-duration-fast);
|
|
114
|
+
}
|
|
115
|
+
/* open, the card takes the pointer, but only once the delay is over: until then it is visibility: hidden, so a pointer
|
|
116
|
+
that crosses the link and moves on reaches what is under the card and never raises it */
|
|
117
|
+
:where(.ma-lpreview__card:popover-open) {
|
|
118
|
+
pointer-events: auto;
|
|
119
|
+
opacity: 1;
|
|
120
|
+
scale: 1;
|
|
121
|
+
translate: 0 0;
|
|
122
|
+
transition-duration: var(--ma-duration), var(--ma-duration), var(--ma-duration), 0s, 0s, 0s;
|
|
123
|
+
transition-timing-function: var(--ma-ease), var(--ma-ease), var(--ma-ease), linear, linear, linear;
|
|
124
|
+
transition-delay: var(--ma-lpreview-delay), var(--ma-lpreview-delay), var(--ma-lpreview-delay), 0s, 0s, var(--ma-lpreview-delay);
|
|
125
|
+
}
|
|
126
|
+
@starting-style {
|
|
127
|
+
:where(.ma-lpreview__card:popover-open) {
|
|
128
|
+
opacity: 0;
|
|
129
|
+
scale: 0.96;
|
|
130
|
+
translate: 0 calc(var(--ma-travel-hover, 4px) * -1);
|
|
131
|
+
visibility: hidden;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
:where(.ma-lpreview__card)::backdrop {
|
|
135
|
+
background: transparent;
|
|
136
|
+
}
|
|
137
|
+
/* the gap between the link and the card belongs to the card, so the pointer can cross onto it and read it (WCAG
|
|
138
|
+
1.4.13: hoverable): the margin, the 1 px border (the inset starts inside it) and 1 px of rounding. Under the
|
|
139
|
+
content, so the card's own text stays the target. */
|
|
140
|
+
:where(.ma-lpreview__card)::before {
|
|
141
|
+
content: '';
|
|
142
|
+
position: absolute;
|
|
143
|
+
z-index: -1;
|
|
144
|
+
inset: calc(-0.5rem - 2px) 0;
|
|
145
|
+
}
|
|
146
|
+
:where(.ma-lpreview__img) {
|
|
147
|
+
display: block;
|
|
148
|
+
width: 100%;
|
|
149
|
+
border-radius: calc(0.75rem - 1px) calc(0.75rem - 1px) 0 0;
|
|
150
|
+
aspect-ratio: 16 / 9;
|
|
151
|
+
object-fit: cover;
|
|
152
|
+
background: var(--ma-edge);
|
|
153
|
+
}
|
|
154
|
+
:where(.ma-lpreview__body) {
|
|
155
|
+
display: grid;
|
|
156
|
+
gap: 0.15rem;
|
|
157
|
+
padding: 0.625rem 0.75rem 0.75rem;
|
|
158
|
+
font-size: 0.8125rem;
|
|
159
|
+
line-height: 1.35;
|
|
160
|
+
text-align: start;
|
|
161
|
+
}
|
|
162
|
+
:where(.ma-lpreview__title) {
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
}
|
|
165
|
+
:where(.ma-lpreview__desc) {
|
|
166
|
+
display: -webkit-box;
|
|
167
|
+
-webkit-box-orient: vertical;
|
|
168
|
+
-webkit-line-clamp: 2;
|
|
169
|
+
overflow: hidden;
|
|
170
|
+
opacity: 0.7;
|
|
171
|
+
}
|
|
172
|
+
:where(.ma-lpreview__domain) {
|
|
173
|
+
margin-top: 0.2rem;
|
|
174
|
+
font-family: ui-monospace, monospace;
|
|
175
|
+
font-size: 0.6875rem;
|
|
176
|
+
letter-spacing: 0.03em;
|
|
177
|
+
opacity: var(--ma-dim, 0.7);
|
|
178
|
+
}
|
|
179
|
+
/* no anchor positioning: the script hands the link's box over as two lengths */
|
|
180
|
+
@supports not (position-area: bottom) {
|
|
181
|
+
:where(.ma-lpreview__card) {
|
|
182
|
+
inset: auto;
|
|
183
|
+
left: var(--ma-lpreview-x, 0px);
|
|
184
|
+
top: var(--ma-lpreview-y, 0px);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/* touch and coarse pointers: the link alone (the script does not bind either) */
|
|
188
|
+
@media (hover: none), (pointer: coarse) {
|
|
189
|
+
:where(.ma-lpreview__card) {
|
|
190
|
+
display: none;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
@media (prefers-reduced-motion: reduce) {
|
|
194
|
+
:where(.ma-lpreview__card),
|
|
195
|
+
:where(.ma-lpreview__card:popover-open) {
|
|
196
|
+
scale: 1;
|
|
197
|
+
translate: 0 0;
|
|
198
|
+
transition-duration: var(--ma-duration-fast);
|
|
199
|
+
transition-timing-function: linear;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
204
|
+
|
|
205
|
+
<script>
|
|
206
|
+
import { onMount } from '../lib/runtime';
|
|
207
|
+
|
|
208
|
+
onMount<HTMLElement>('[data-ma-lpreview]', (el, { signal }) => {
|
|
209
|
+
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
|
210
|
+
const link = el.querySelector('a')!;
|
|
211
|
+
const card = el.querySelector<HTMLElement>('[popover]')!;
|
|
212
|
+
const place = !CSS.supports('position-area: bottom');
|
|
213
|
+
const show = () => {
|
|
214
|
+
if (place) {
|
|
215
|
+
// no anchor positioning: under the link, from its box
|
|
216
|
+
const r = link.getBoundingClientRect();
|
|
217
|
+
card.style.setProperty('--ma-lpreview-x', `${r.left}px`);
|
|
218
|
+
card.style.setProperty('--ma-lpreview-y', `${r.bottom}px`);
|
|
219
|
+
}
|
|
220
|
+
card.togglePopover(true);
|
|
221
|
+
};
|
|
222
|
+
const hide = () => card.togglePopover(false);
|
|
223
|
+
const on = (t: string, f: () => void, target: HTMLElement = link) => target.addEventListener(t, f, { signal });
|
|
224
|
+
// the pointer on the wrapper, not the link: the card is the wrapper's child, so moving from the link onto the card
|
|
225
|
+
// leaves neither, and the card stays while it is read
|
|
226
|
+
on('pointerenter', show, el);
|
|
227
|
+
on('pointerleave', hide, el);
|
|
228
|
+
on('focus', show);
|
|
229
|
+
on('blur', hide);
|
|
230
|
+
// Escape hides it wherever the focus is: a pointer resting on the link has no focus on it
|
|
231
|
+
document.addEventListener('keydown', (e) => e.key === 'Escape' && hide(), { signal });
|
|
232
|
+
signal.addEventListener('abort', hide);
|
|
233
|
+
});
|
|
234
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'LinkPreview',
|
|
5
|
+
slug: 'link-preview',
|
|
6
|
+
title: 'Link Preview',
|
|
7
|
+
description: 'A link that shows a small card (image, title, one line, domain) after a moment on hover or focus, above everything, flipping when there is no room. The card is a manual popover anchored to the link; the script only shows and hides it, the wait is a CSS transition-delay on a duration token, and everything on the card is a prop at build time, so nothing is fetched. Touch gets the link alone.',
|
|
8
|
+
summary: 'A link that shows a preview card with an image, a title and the domain after a moment on hover or focus.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['hover'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['popover', 'anchor', 'starting-style', 'allow-discrete'], fallback: 'without anchor positioning the script places the card under the link; without the popover attribute the link is a link' },
|
|
13
|
+
readout: '<LinkPreview delay="base">',
|
|
14
|
+
usage: `<p>\n Built on\n <LinkPreview href="https://astro.build" title="Astro" description="The web framework for content-driven websites." image="/previews/astro.png">Astro</LinkPreview>\n and measured on every commit.\n</p>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'href', type: 'string', description: 'The link.' },
|
|
17
|
+
{ name: 'title', type: 'string', description: 'Card title.' },
|
|
18
|
+
{ name: 'image', type: 'string', description: 'Card image, 16:9, covers.' },
|
|
19
|
+
{ name: 'description', type: 'string', description: 'One or two lines under the title.' },
|
|
20
|
+
{ name: 'domain', type: 'string', description: 'Source line; default the host of href.' },
|
|
21
|
+
{ name: 'delay', type: "'fast' | 'base' | 'slow'", default: "'base'", description: 'The wait before the card shows: the fast, active-preset or slow duration token.' },
|
|
22
|
+
{ name: 'placement', type: 'string', default: "'bottom'", description: 'A position-area value.' },
|
|
23
|
+
{ name: 'id', type: 'string', description: 'Popover id; generated when unset.' },
|
|
24
|
+
],
|
|
25
|
+
reducedMotion: 'The card cross-fades in without the scale, after the same wait.',
|
|
26
|
+
clientRouter: 'Bound through the shared runtime; the card is hidden before the swap so nothing stays in the top layer, and the document Escape listener goes with the binding.',
|
|
27
|
+
craft: [
|
|
28
|
+
'The hover intent is a transition-delay, not a setTimeout: the card enters the top layer at once but its opacity waits on the delay token, and a pointer that leaves early cancels a transition that never showed anything. No timer to clear, nothing to read from a token in JavaScript.',
|
|
29
|
+
'A manual popover: the top layer puts the card above sticky headers and overflow: clip, and anchor positioning flips it below or above the link by itself.',
|
|
30
|
+
'The card can be hovered: the gap between it and the link is the card\'s own ::before, and the pointer is watched on the wrapper that holds both, so moving onto the card to read it keeps it open. Escape hides it wherever the focus is. Together that is WCAG 1.4.13 (hoverable, dismissible, persistent).',
|
|
31
|
+
'The card takes the pointer only once it shows. Through the wait it is visibility: hidden on the same delay token, so a pointer that crosses the link on its way elsewhere never lands on it, and a click there reaches the page.',
|
|
32
|
+
'role="tooltip" with aria-describedby from the link: a screen reader hears the title and description as the link\'s description without any hover.',
|
|
33
|
+
'interestfor (Chrome 142) would make this 0 B: a hover-intent popover with no script. It is one engine today, listed in the audit as the C route.',
|
|
34
|
+
],
|
|
35
|
+
related: ['popover', 'hover-image-links', 'cursor'],
|
|
36
|
+
intents: ['link preview on hover', 'hover card link', 'link hover card with image', 'link preview astro', 'hovercard', 'preview tooltip for links'],
|
|
37
|
+
replaces: ['LinkPreview (Aceternity)', 'HoverCard (shadcn / Radix)', 'LinkPreview (Motion Primitives)'],
|
|
38
|
+
fixture: { props: { href: 'https://example.com/', title: 'Example', description: 'Fixture content for LinkPreview' }, slot: 'a link', expect: 'Fixture content for LinkPreview' },
|
|
39
|
+
} satisfies ComponentMeta;
|