Files
cameleer-website/src/components/Lightbox.astro
hsiegeln b7b58dd948
Some checks failed
ci / build-test (push) Failing after 3m41s
feat(design): click-to-enlarge on product screenshots
Lightbox.astro — reusable native HTMLDialogElement wrapper:
- Trigger: <button> wrapping the <img>, cursor: zoom-in, amber zoom-pill
  badge fades in on hover/focus.
- Dialog: showModal() opens a full-viewport modal (≤1800x1200 cap) with
  blurred amber-tinted backdrop.
- Close paths: native form[method=dialog] submit (Escape + close button),
  click on backdrop, click on the image itself.
- Accessibility: aria-labelledby + visually-hidden heading avoids both
  aria-label-misuse and no-redundant-role validator conflicts. Focus
  returns to trigger on close (native HTMLDialogElement behavior).
- Motion: 220ms fade+scale open, disabled under prefers-reduced-motion.
- CSP: <script> is Astro-bundled to an external file — script-src 'self'
  respected.

Hero and ProductShowcase now use <Lightbox> instead of a raw <img> for
the product screenshots. The existing frame styling (border, glow, ring
overlay) is untouched — the lightbox trigger is a block-level button
that fills the frame.
2026-04-25 00:31:48 +02:00

234 lines
6.2 KiB
Plaintext

---
interface Props {
src: string;
alt: string;
width: number;
height: number;
loading?: 'eager' | 'lazy';
caption?: string;
triggerClass?: string;
imgClass?: string;
}
const {
src,
alt,
width,
height,
loading = 'lazy',
caption,
triggerClass = '',
imgClass = 'block w-full h-auto',
} = Astro.props;
// Unique per-instance id so multiple lightboxes on a page do not collide.
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
---
<button
type="button"
class={`lightbox-trigger group ${triggerClass}`.trim()}
data-lightbox-open={dialogId}
aria-label={`Enlarge: ${alt}`}
>
<img
src={src}
alt={alt}
width={width}
height={height}
loading={loading}
decoding="async"
class={imgClass}
/>
<span aria-hidden="true" class="lightbox-zoom-badge">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7.5"/>
<path d="m20 20-3.5-3.5"/>
<line x1="11" y1="8" x2="11" y2="14"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
</span>
</button>
<dialog id={dialogId} class="lightbox-dialog" aria-modal="true" aria-labelledby={`${dialogId}-title`}>
<h2 id={`${dialogId}-title`} class="sr-only">{alt}</h2>
<form method="dialog" class="lightbox-close-form">
<button type="submit" class="lightbox-close" aria-label="Close">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</button>
</form>
<img src={src} alt={alt} class="lightbox-image" />
{caption && <p class="lightbox-caption">{caption}</p>}
</dialog>
<style>
/* Trigger: reset native button chrome, keep block layout matching the old <img>. */
.lightbox-trigger {
all: unset;
display: block;
position: relative;
cursor: zoom-in;
width: 100%;
}
.lightbox-trigger:focus-visible {
outline: 2px solid #f0b429;
outline-offset: 4px;
border-radius: 0.5rem;
}
/* Zoom pill (fades in on hover / keyboard focus) */
.lightbox-zoom-badge {
position: absolute;
top: 0.85rem;
right: 0.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 9999px;
background: rgba(240, 180, 41, 0.92);
color: #060a13;
opacity: 0;
transform: translateY(-3px);
transition: opacity 180ms ease-out, transform 180ms ease-out;
pointer-events: none;
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.5);
}
.lightbox-trigger:hover .lightbox-zoom-badge,
.lightbox-trigger:focus-visible .lightbox-zoom-badge {
opacity: 1;
transform: translateY(0);
}
/* Dialog */
.lightbox-dialog {
padding: 0;
margin: auto;
width: min(98vw, 1800px);
height: min(96vh, 1200px);
background: #060a13;
color: #e8eaed;
border: 1px solid rgba(240, 180, 41, 0.25);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 40px 100px -30px rgba(0, 0, 0, 0.8);
}
.lightbox-dialog::backdrop {
background: rgba(6, 10, 19, 0.82);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.lightbox-close-form {
position: absolute;
top: 0.75rem;
right: 0.75rem;
margin: 0;
z-index: 2;
}
.lightbox-close {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
color: #f0b429;
background: rgba(12, 17, 26, 0.92);
border: 1px solid rgba(240, 180, 41, 0.35);
transition: background 160ms ease-out, transform 160ms ease-out, border-color 160ms ease-out;
}
.lightbox-close:hover {
background: rgba(240, 180, 41, 0.18);
border-color: rgba(240, 180, 41, 0.7);
transform: scale(1.06);
}
.lightbox-close:focus-visible {
outline: 2px solid #f0b429;
outline-offset: 2px;
}
.lightbox-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #060a13;
cursor: zoom-out;
}
.lightbox-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0.5rem;
margin: 0;
text-align: center;
color: #9aa3b2;
font-size: 0.875rem;
padding: 0 1rem;
}
/* Open animation */
@keyframes lightbox-in {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
@keyframes lightbox-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
.lightbox-dialog[open] {
animation: lightbox-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.lightbox-dialog[open]::backdrop {
animation: lightbox-backdrop-in 220ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.lightbox-zoom-badge {
transition: none;
}
.lightbox-dialog[open],
.lightbox-dialog[open]::backdrop {
animation: none;
}
.lightbox-close {
transition: none;
}
}
</style>
<script>
// Open lightbox on trigger click. Close on click of the image itself or
// the backdrop. Bundled by Astro — CSP script-src 'self'.
const triggers = document.querySelectorAll<HTMLButtonElement>('[data-lightbox-open]');
triggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const id = trigger.getAttribute('data-lightbox-open');
if (!id) return;
const dialog = document.getElementById(id);
if (dialog instanceof HTMLDialogElement) {
dialog.showModal();
}
});
});
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.lightbox-dialog');
dialogs.forEach((dialog) => {
dialog.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
// Click on the dialog element itself (the backdrop area) or the image
// closes. Clicks on the close button are already handled by the native
// form[method=dialog].
if (target === dialog || target.classList.contains('lightbox-image')) {
dialog.close();
}
});
});
</script>