asdf
This commit is contained in:
1 parent
efc2ad7da4
commit
3e6cfcbe35
2 files changed
+274
-40
No files matched your search
+236
-28
@@ -1,44 +1,87 @@
|
||||
---
|
||||
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);
|
||||
const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
||||
// 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-labelledby="quote-text">
|
||||
<section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
|
||||
<div class="container">
|
||||
<figure class="quote__figure">
|
||||
<div class="quote__frame">
|
||||
<div class="quote__panel">
|
||||
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p>
|
||||
<blockquote>
|
||||
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p>
|
||||
</blockquote>
|
||||
<figcaption class="quote__caption">
|
||||
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p>
|
||||
</figcaption>
|
||||
<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__portrait">
|
||||
<Picture
|
||||
src={felix}
|
||||
alt={ui.quote.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 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>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.quote__figure {
|
||||
.quote {
|
||||
/* One statement holds for this long before the next one comes in. */
|
||||
--quote-hold: 9s;
|
||||
}
|
||||
.quote__frame {
|
||||
display: grid;
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
@@ -48,15 +91,180 @@ const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
||||
align-content: space-between;
|
||||
padding: var(--space-7) var(--space-6);
|
||||
}
|
||||
.quote__essay { text-decoration: none; }
|
||||
.quote__essay:hover { text-decoration: underline; }
|
||||
|
||||
/* 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;
|
||||
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__figure { grid-template-columns: 11fr 9fr; }
|
||||
.quote__frame { grid-template-columns: 11fr 9fr; }
|
||||
.quote__panel { padding: var(--space-8) var(--space-8); }
|
||||
.quote__img, :global(.quote__img) { aspect-ratio: auto; }
|
||||
}
|
||||
|
||||
@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
|
||||
* the pointer or 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 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); }));
|
||||
|
||||
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
|
||||
root.addEventListener('pointerenter', () => hold(true));
|
||||
root.addEventListener('pointerleave', () => hold(root.contains(document.activeElement)));
|
||||
root.addEventListener('focusin', () => hold(true));
|
||||
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(root.matches(':hover')); });
|
||||
|
||||
// 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;
|
||||
setPlaying(!reduced);
|
||||
}
|
||||
</script>
|
||||
Reference in new issue
Block a user