286 lines
11 KiB
Plaintext
286 lines
11 KiB
Plaintext
---
|
|
import { Picture } from 'astro:assets';
|
|
import felix from '../../assets/images/people/felix.png';
|
|
import florian from '../../assets/images/people/florian.png';
|
|
import { localePath, t, type Lang } from '../../i18n/ui';
|
|
interface Props { lang: Lang }
|
|
const { lang } = Astro.props;
|
|
const ui = t(lang);
|
|
// Where each statement's eyebrow leads, and whose portrait sits beside it. Same order as ui.quote.items.
|
|
const sources = [
|
|
{ href: localePath(lang, '/insights/xg-aloe-user-forum'), portrait: felix },
|
|
{ href: `${localePath(lang, '/team')}#research-title`, portrait: florian },
|
|
];
|
|
const items = ui.quote.items.map((item, i) => ({ ...item, ...sources[i] }));
|
|
const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}”`);
|
|
---
|
|
<section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
|
|
<div class="container">
|
|
<div class="quote__frame" data-frame>
|
|
<div class="quote__panel">
|
|
<div class="quote__stack" aria-live="off" data-live>
|
|
{items.map((item, i) => (
|
|
<figure
|
|
class="quote__slide"
|
|
id={`quote-${i}`}
|
|
role="group"
|
|
aria-roledescription="slide"
|
|
aria-label={ui.quote.slide(i + 1, items.length)}
|
|
data-slide
|
|
data-active={i === 0 ? '' : undefined}
|
|
>
|
|
<p class="eyebrow"><a href={item.href} class="quote__source">{item.eyebrow}</a></p>
|
|
<blockquote>
|
|
<p class="h2 quote__text">{quoted(item.text)}</p>
|
|
</blockquote>
|
|
<figcaption class="quote__caption">
|
|
<span class="quote__name">{item.name}</span><br /><span class="muted">{item.role}</span>
|
|
</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
<div class="quote__controls" data-controls hidden>
|
|
{items.map((item, i) => (
|
|
<button
|
|
type="button"
|
|
class="quote__pick"
|
|
aria-controls={`quote-${i}`}
|
|
aria-label={ui.quote.show(item.name)}
|
|
aria-current={i === 0 ? 'true' : undefined}
|
|
data-pick
|
|
>
|
|
<span class="quote__track" aria-hidden="true"><span class="quote__fill"></span></span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div class="quote__portraits">
|
|
{items.map((item, i) => (
|
|
<div class="quote__portrait" data-portrait data-active={i === 0 ? '' : undefined}>
|
|
<Picture
|
|
src={item.portrait}
|
|
alt={item.portraitAlt}
|
|
formats={['avif']}
|
|
fallbackFormat="webp"
|
|
widths={[480, 640, 960, 1200]}
|
|
sizes="(min-width: 64rem) 40vw, 100vw"
|
|
loading="lazy"
|
|
decoding="async"
|
|
class="img-mono quote__img"
|
|
pictureAttributes={{ class: 'quote__picture' }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.quote {
|
|
/* One statement holds for this long before the next one comes in. */
|
|
--quote-hold: 9s;
|
|
}
|
|
.quote__frame {
|
|
display: grid;
|
|
background: var(--paper-deep);
|
|
}
|
|
/* The whole card advances on click once the script has set it up. */
|
|
.quote[data-ready] .quote__frame { cursor: pointer; }
|
|
.quote__panel {
|
|
display: grid;
|
|
gap: var(--space-7);
|
|
align-content: space-between;
|
|
padding: var(--space-7) var(--space-6);
|
|
}
|
|
|
|
/* Slides share one grid cell, so the frame takes the height of the longest and never jumps. */
|
|
.quote__stack, .quote__portraits { display: grid; }
|
|
.quote__slide, .quote__portrait { grid-area: 1 / 1; }
|
|
|
|
/*
|
|
* The change: the outgoing statement lifts away, then the incoming one rises in line by line,
|
|
* eyebrow, quote, caption. The new portrait fades in over the old one and settles from a slight
|
|
* scale, as the hero image does on load.
|
|
*/
|
|
.quote__slide {
|
|
display: grid;
|
|
gap: var(--space-7);
|
|
align-content: space-between;
|
|
margin: 0;
|
|
visibility: hidden;
|
|
transition: visibility 0s linear var(--dur-slow);
|
|
}
|
|
.quote__slide > * {
|
|
opacity: 0;
|
|
transform: translateY(var(--space-6));
|
|
transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
|
|
}
|
|
.quote__slide[data-leaving] > * { transform: translateY(calc(-1 * var(--space-5))); }
|
|
.quote__slide > :nth-child(2) { --i: 1; }
|
|
.quote__slide > :nth-child(3) { --i: 2; }
|
|
.quote__slide[data-active] { visibility: visible; transition: visibility 0s; }
|
|
.quote__slide[data-active] > * {
|
|
opacity: 1;
|
|
transform: none;
|
|
transition-duration: var(--dur-rise);
|
|
transition-delay: calc(var(--dur-fast) + var(--i, 0) * var(--stagger-rise));
|
|
}
|
|
|
|
.quote__portraits { overflow: hidden; }
|
|
.quote__portrait {
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
/* The old portrait stays put until the new one has covered it. */
|
|
transition: opacity 0s linear var(--dur-hero), visibility 0s linear var(--dur-hero);
|
|
}
|
|
.quote__portrait :global(img) {
|
|
transform: scale(1.06);
|
|
transition: transform 0s linear var(--dur-hero);
|
|
}
|
|
.quote__portrait[data-active] {
|
|
z-index: 1;
|
|
opacity: 1;
|
|
visibility: visible;
|
|
transition: opacity var(--dur-rise) var(--ease), visibility 0s;
|
|
}
|
|
.quote__portrait[data-active] :global(img) {
|
|
transform: none;
|
|
transition: transform var(--dur-hero) var(--ease);
|
|
}
|
|
|
|
.quote__source { text-decoration: none; }
|
|
.quote__source:hover { text-decoration: underline; }
|
|
.quote__text { text-wrap: balance; max-width: 16em; }
|
|
.quote__name { font-weight: var(--weight-medium); }
|
|
|
|
/*
|
|
* Controls: the progress bar runs along the bottom edge of the text panel, one segment per
|
|
* statement, filling while it is shown. Each segment is a button with a taller hit area above it.
|
|
*/
|
|
.quote__panel { position: relative; }
|
|
.quote__controls {
|
|
position: absolute;
|
|
inset: auto 0 0 0;
|
|
z-index: 2; /* above the portrait, which on narrow screens starts right below the bar */
|
|
display: flex;
|
|
gap: var(--space-1);
|
|
}
|
|
.quote__controls[hidden] { display: none; }
|
|
.quote__pick {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: end;
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
height: var(--space-6);
|
|
padding: 0;
|
|
background: none;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.quote__pick:focus-visible { outline-offset: calc(-1 * var(--focus-width)); }
|
|
.quote__pick:hover .quote__track { background: var(--ink-muted); }
|
|
.quote__track {
|
|
display: block;
|
|
height: calc(2 * var(--hairline));
|
|
transition: background-color var(--dur-fast) var(--ease);
|
|
background: var(--line);
|
|
overflow: hidden;
|
|
}
|
|
.quote__fill {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--ink);
|
|
/* A full-width bar slid in from the left, not a scaled one: translation stays on the
|
|
compositor and moves by sub-pixels, scaling from zero re-rasterises and steps. */
|
|
transform: translateX(-100%);
|
|
will-change: transform;
|
|
}
|
|
.quote__pick[aria-current] .quote__fill { transform: none; }
|
|
.quote[data-playing] .quote__pick[aria-current] .quote__fill {
|
|
animation: quote-fill var(--quote-hold) linear both;
|
|
}
|
|
.quote[data-playing]:is([data-held], [data-away]) .quote__pick[aria-current] .quote__fill { animation-play-state: paused; }
|
|
@keyframes quote-fill { from { transform: translateX(-100%); } to { transform: none; } }
|
|
|
|
:global(.quote__picture) { display: block; width: 100%; height: 100%; }
|
|
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
|
|
@media (min-width: 64rem) {
|
|
.quote__frame { grid-template-columns: 11fr 9fr; }
|
|
.quote__panel { padding: var(--space-8) var(--space-8); }
|
|
/* The image's own ratio once loaded, and the same square reserved before, so the lazy
|
|
portrait does not grow the section when it arrives. Both portraits are square. */
|
|
.quote__img, :global(.quote__img) { aspect-ratio: auto 1 / 1; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.quote__slide, .quote__slide > *, .quote__portrait, .quote__portrait :global(img) {
|
|
transition: none !important;
|
|
transform: none !important;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
/**
|
|
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
|
|
* keyboard focus is inside or the section is off screen; picking a statement stops it.
|
|
*/
|
|
const root = document.querySelector<HTMLElement>('[data-quotes]');
|
|
if (root) {
|
|
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
|
|
const portraits = Array.from(root.querySelectorAll<HTMLElement>('[data-portrait]'));
|
|
const picks = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-pick]'));
|
|
const live = root.querySelector<HTMLElement>('[data-live]')!;
|
|
const controls = root.querySelector<HTMLElement>('[data-controls]')!;
|
|
const frame = root.querySelector<HTMLElement>('[data-frame]')!;
|
|
const fills = picks.map((p) => p.querySelector<HTMLElement>('.quote__fill')!);
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
let index = 0;
|
|
let playing = false;
|
|
|
|
const show = (next: number) => {
|
|
next = (next + slides.length) % slides.length;
|
|
if (next === index) return;
|
|
// The outgoing statement lifts up instead of sinking back to where the incoming one rises from.
|
|
const leaving = slides[index];
|
|
leaving.setAttribute('data-leaving', '');
|
|
window.setTimeout(() => leaving.removeAttribute('data-leaving'), 1000);
|
|
index = next;
|
|
[slides, portraits].forEach((set) => set.forEach((el, i) => el.toggleAttribute('data-active', i === index)));
|
|
picks.forEach((p, i) => (i === index ? p.setAttribute('aria-current', 'true') : p.removeAttribute('aria-current')));
|
|
};
|
|
const setPlaying = (on: boolean) => {
|
|
playing = on;
|
|
root.toggleAttribute('data-playing', on);
|
|
live.setAttribute('aria-live', on ? 'off' : 'polite');
|
|
};
|
|
|
|
// The fill on the current pick is the timer: when its animation ends, the next statement comes.
|
|
fills.forEach((fill) => fill.addEventListener('animationend', () => { if (playing) show(index + 1); }));
|
|
picks.forEach((pick, i) => pick.addEventListener('click', () => { setPlaying(false); show(i); }));
|
|
|
|
// A click anywhere else on the card moves on to the next statement and (re)starts the rotation,
|
|
// so the new statement gets its full hold. Links and the picks keep their own behaviour, and
|
|
// selecting the quote to copy it does not count.
|
|
frame.addEventListener('click', (e) => {
|
|
if ((e.target as Element).closest('a, button')) return;
|
|
if (window.getSelection()?.toString()) return;
|
|
show(index + 1);
|
|
setPlaying(!reduced);
|
|
});
|
|
|
|
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
|
|
root.addEventListener('focusin', () => hold(true));
|
|
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
|
|
|
|
// Off screen, the clock stops, so a reader arriving from above starts on a fresh statement.
|
|
new IntersectionObserver(([entry]) => root.toggleAttribute('data-away', !entry.isIntersecting), { threshold: 0.4 }).observe(root);
|
|
|
|
controls.hidden = false;
|
|
root.setAttribute('data-ready', '');
|
|
setPlaying(!reduced);
|
|
}
|
|
</script>
|