Skip to content

Commit 0d0917b

Browse files
authored
Add popovers to work items (#675)
1 parent 7055bda commit 0d0917b

33 files changed

+450
-87
lines changed

src/components/WorkList.astro

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
---
2+
import { Image } from "astro:assets";
3+
4+
interface Props {
5+
items: Array<{
6+
id: string;
7+
title: string;
8+
description?: string;
9+
url: string;
10+
category: string[];
11+
year: number;
12+
img?: {
13+
src: ImageMetadata;
14+
alt: string;
15+
};
16+
}>;
17+
}
18+
19+
const { items } = Astro.props;
20+
---
21+
22+
<ul class="work-list">
23+
{
24+
items.map((item) => (
25+
<li class="work-item">
26+
<h3 class="work-title">
27+
<a href={item.url}>{item.title}</a>
28+
</h3>
29+
<span class="work-category">{item.category.sort().join(", ")}</span>
30+
<span class="work-year">{item.year}</span>
31+
<div popover id={item.id} aria-hidden="true">
32+
{item.img && (
33+
<Image
34+
class="work-img"
35+
src={item.img.src}
36+
alt={item.img.alt}
37+
layout="constrained"
38+
/>
39+
)}
40+
<div class="work-description">
41+
<strong>{item.title}</strong>
42+
<p>{item.description}</p>
43+
<span class="work-url">{item.url.split("//")[1] || item.url}</span>
44+
</div>
45+
</div>
46+
</li>
47+
))
48+
}
49+
</ul>
50+
51+
<style>
52+
.work-list {
53+
list-style: none;
54+
padding: 0;
55+
margin-inline: calc(-1 * var(--space-s));
56+
display: flex;
57+
flex-direction: column;
58+
}
59+
60+
.work-item {
61+
display: grid;
62+
grid-template-columns: 1fr auto auto;
63+
grid-template-areas: "title category year";
64+
gap: var(--space-m);
65+
padding: var(--space-xs) var(--space-s);
66+
border-radius: var(--radius-s);
67+
position: relative;
68+
69+
@media (hover: hover) {
70+
&:hover {
71+
background-color: var(--gray-3);
72+
}
73+
}
74+
75+
&:focus-within {
76+
outline: var(--gray-12) solid 2px;
77+
outline-offset: 2px;
78+
}
79+
80+
.work-title {
81+
grid-area: title;
82+
text-overflow: ellipsis;
83+
overflow: hidden;
84+
white-space: nowrap;
85+
font-size: var(--step-0);
86+
font-weight: var(--font-weight-normal);
87+
line-height: var(--line-height-body);
88+
transition: font-weight 0.2s ease-in-out;
89+
}
90+
91+
.work-category {
92+
grid-area: category;
93+
}
94+
95+
.work-year {
96+
grid-area: year;
97+
font-variant-numeric: tabular-nums;
98+
}
99+
100+
.work-category,
101+
.work-year {
102+
color: var(--gray-11);
103+
}
104+
105+
a {
106+
position: static;
107+
text-decoration: none;
108+
outline: none;
109+
110+
&::before {
111+
content: "";
112+
display: block;
113+
position: absolute;
114+
inset: 0;
115+
}
116+
}
117+
118+
[popover] {
119+
margin: 0;
120+
inset: auto;
121+
background-color: var(--gray-1);
122+
color: var(--gray-12);
123+
box-shadow: var(--shadow-m);
124+
bottom: anchor(top);
125+
left: anchor(left);
126+
border: none;
127+
padding: 0;
128+
max-width: 36ch;
129+
text-wrap: pretty;
130+
border-radius: var(--radius-s);
131+
font-size: var(--step--1);
132+
}
133+
134+
.work-img {
135+
aspect-ratio: 1.91 / 1;
136+
width: 100%;
137+
height: auto;
138+
object-fit: cover;
139+
}
140+
141+
.work-description {
142+
display: flex;
143+
flex-direction: column;
144+
padding: var(--space-xs) var(--space-s);
145+
overflow-wrap: break-word;
146+
}
147+
148+
.work-url {
149+
display: block;
150+
color: var(--gray-11);
151+
}
152+
}
153+
154+
:global(.dark) [popover] {
155+
background-color: var(--gray-2);
156+
}
157+
</style>
158+
159+
<script>
160+
const cursor = { x: 0, y: 0 };
161+
const popoverSelector = "[popover]:popover-open";
162+
const padding = 8;
163+
const offset = 12;
164+
let activeMode: "pointer" | "keyboard" | null = null;
165+
let activeItem: HTMLElement | null = null;
166+
let anchorRect: DOMRect | null = null;
167+
let isPointerActive = false;
168+
169+
const setPopoverFlip = (popover: HTMLElement) => {
170+
const rect = popover.getBoundingClientRect();
171+
const shouldFlipX =
172+
activeMode === "keyboard"
173+
? false
174+
: cursor.x + offset + rect.width + padding > window.innerWidth;
175+
const shouldFlipY =
176+
activeMode === "keyboard" && anchorRect
177+
? anchorRect.top - offset - rect.height < padding
178+
: cursor.y + offset + rect.height + padding > window.innerHeight;
179+
180+
popover.dataset.flipX = shouldFlipX ? "true" : "false";
181+
popover.dataset.flipY = shouldFlipY ? "true" : "false";
182+
};
183+
184+
const positionOpenPopover = () => {
185+
const popover = document.querySelector<HTMLElement>(popoverSelector);
186+
if (!popover) return;
187+
188+
if (activeMode === "keyboard" && activeItem) {
189+
anchorRect = activeItem.getBoundingClientRect();
190+
}
191+
192+
if (popover.dataset.flipX == null || popover.dataset.flipY == null) {
193+
setPopoverFlip(popover);
194+
}
195+
196+
const rect = popover.getBoundingClientRect();
197+
const maxLeft = window.innerWidth - rect.width - padding;
198+
const maxTop = window.innerHeight - rect.height - padding;
199+
const shouldFlipX = popover.dataset.flipX === "true";
200+
const shouldFlipY = popover.dataset.flipY === "true";
201+
const targetLeft =
202+
activeMode === "keyboard" && anchorRect
203+
? anchorRect.left
204+
: shouldFlipX
205+
? cursor.x - offset - rect.width
206+
: cursor.x + offset;
207+
const targetTop =
208+
activeMode === "keyboard" && anchorRect
209+
? shouldFlipY
210+
? anchorRect.bottom + offset
211+
: anchorRect.top - offset - rect.height
212+
: shouldFlipY
213+
? cursor.y - offset - rect.height
214+
: cursor.y + offset;
215+
const left = Math.min(Math.max(padding, targetLeft), maxLeft);
216+
const top = Math.min(Math.max(padding, targetTop), maxTop);
217+
const scrollLeft = activeMode === "keyboard" ? window.scrollX : 0;
218+
const scrollTop = activeMode === "keyboard" ? window.scrollY : 0;
219+
220+
popover.style.position = activeMode === "keyboard" ? "absolute" : "fixed";
221+
popover.style.left = `${left + scrollLeft}px`;
222+
popover.style.top = `${top + scrollTop}px`;
223+
popover.style.right = "auto";
224+
popover.style.bottom = "auto";
225+
};
226+
227+
const openPopoverForItem = (
228+
item: HTMLElement,
229+
mode: "pointer" | "keyboard",
230+
event?: PointerEvent,
231+
) => {
232+
const popover = item.querySelector("[popover]") as
233+
| (HTMLElement & { showPopover?: () => void; hidePopover?: () => void })
234+
| null;
235+
if (!popover) return;
236+
237+
if (event) {
238+
cursor.x = event.clientX;
239+
cursor.y = event.clientY;
240+
}
241+
242+
activeMode = mode;
243+
activeItem = item;
244+
isPointerActive = mode === "pointer";
245+
anchorRect = mode === "keyboard" ? item.getBoundingClientRect() : null;
246+
if (popover.dataset.mode !== mode) {
247+
delete popover.dataset.flipX;
248+
delete popover.dataset.flipY;
249+
}
250+
popover.dataset.mode = mode;
251+
252+
document
253+
.querySelectorAll("[popover]:popover-open")
254+
.forEach((openPopover) => {
255+
const opened = openPopover as HTMLElement & {
256+
hidePopover?: () => void;
257+
};
258+
if (opened !== popover && typeof opened.hidePopover === "function") {
259+
opened.hidePopover();
260+
}
261+
});
262+
263+
if (typeof popover.showPopover === "function") {
264+
popover.showPopover();
265+
requestAnimationFrame(() => {
266+
setPopoverFlip(popover);
267+
positionOpenPopover();
268+
});
269+
}
270+
};
271+
272+
const closePopoverForItem = (item: HTMLElement) => {
273+
const popover = item.querySelector("[popover]") as
274+
| (HTMLElement & { hidePopover?: () => void })
275+
| null;
276+
if (!popover) return;
277+
delete popover.dataset.flipX;
278+
delete popover.dataset.flipY;
279+
delete popover.dataset.mode;
280+
if (activeItem === item) {
281+
activeMode = null;
282+
activeItem = null;
283+
isPointerActive = false;
284+
anchorRect = null;
285+
}
286+
if (typeof popover.hidePopover === "function") {
287+
popover.hidePopover();
288+
}
289+
};
290+
291+
const bindWorkItemPopovers = (root: ParentNode = document) => {
292+
root.querySelectorAll(".work-item").forEach((item) => {
293+
if (!(item instanceof HTMLElement)) return;
294+
if (item.dataset.popoverBound === "true") return;
295+
item.dataset.popoverBound = "true";
296+
297+
item.addEventListener("pointerenter", (event) => {
298+
if (activeMode === "keyboard" && activeItem) return;
299+
openPopoverForItem(item, "pointer", event);
300+
});
301+
item.addEventListener("pointerleave", () => {
302+
if (activeMode === "keyboard" && activeItem === item) return;
303+
closePopoverForItem(item);
304+
});
305+
306+
const link = item.querySelector("a");
307+
if (link instanceof HTMLElement) {
308+
link.addEventListener("focus", () =>
309+
openPopoverForItem(item, "keyboard"),
310+
);
311+
link.addEventListener("blur", () => closePopoverForItem(item));
312+
}
313+
});
314+
};
315+
316+
document.addEventListener("pointermove", (event) => {
317+
if (!isPointerActive) return;
318+
cursor.x = event.clientX;
319+
cursor.y = event.clientY;
320+
positionOpenPopover();
321+
});
322+
323+
document.addEventListener("scroll", () => {
324+
if (activeMode !== "keyboard") return;
325+
if (!activeItem) return;
326+
positionOpenPopover();
327+
});
328+
329+
document.addEventListener(
330+
"toggle",
331+
(event) => {
332+
if (
333+
event.target instanceof HTMLElement &&
334+
event.target.matches("[popover]")
335+
) {
336+
requestAnimationFrame(positionOpenPopover);
337+
}
338+
},
339+
true,
340+
);
341+
342+
bindWorkItemPopovers();
343+
document.addEventListener("astro:page-load", () => bindWorkItemPopovers());
344+
</script>

src/content.config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,19 @@ export const collections = {
3939
pattern: "**/[^_]*.yml",
4040
base: "./src/content/work",
4141
}),
42-
schema: () =>
42+
schema: ({ image }) =>
4343
z.object({
4444
title: z.string(),
4545
description: z.string().optional(),
4646
year: z.number(),
47-
url: z.string().url().optional(),
47+
url: z.string().url(),
4848
category: z.string().array(),
49+
img: z
50+
.object({
51+
src: image(),
52+
alt: z.string(),
53+
})
54+
.optional(),
4955
}),
5056
}),
5157

src/content/pages/colophon/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ All code is open source and available on [GitHub](https://github.com/evadecker/e
1818

1919
This site is built using [Astro](https://astro.build). Astro handles the content-heavy views (written in [MDX](https://mdxjs.com)), but for more interactive components I use [React](https://react.dev) and [TypeScript](https://www.typescriptlang.org). [Guestbook](/guestbook) entries are stored on [Turso](https://turso.tech/).
2020

21-
Code is edited using [Cursor](https://www.cursor.com/) on a [MacBook Pro](https://www.apple.com/macbook-pro/). My terminal of choice is [Warp](https://www.warp.dev).
21+
Code is edited using [Cursor](https://cursor.com/) on a [MacBook Pro](https://www.apple.com/macbook-pro/). My terminal of choice is [Warp](https://www.warp.dev).
2222

2323
End-to-end tests are written using [Playwright](https://playwright.dev), and I use [Polypane](https://polypane.app) to preview devices, test accessibility, and toggle user preferences like `prefers-reduced-motion`.
2424

25-
Domain registration, hosting, and deployment are via [Netlify](https://netlify.com/). Email forwarding is through [ImprovMX](https://improvmx.com). I send occasional [newsletters](https://buttondown.email/notesfromeva) using [Buttondown](https://buttondown.email).
25+
Domain registration, hosting, and deployment are via [Netlify](https://netlify.com/). Email forwarding is through [ImprovMX](https://improvmx.com). I send occasional [newsletters](https://buttondown.com/notesfromeva) using [Buttondown](https://buttondown.com/).
2626

2727
I shared more about the hardware I use on [Uses This](https://usesthis.com/interviews/eva.decker/).
2828

@@ -43,7 +43,7 @@ I use [Radix Colors](https://www.radix-ui.com/colors) to apply palettes consiste
4343

4444
## Sounds
4545

46-
The toy synth uses samples from [Mixkit](https://mixkit.co/), [Bolder Sounds](https://www.boldersounds.com/index.php?main_page=product_music_info&products_id=71), [Freesound](https://freesound.org/people/Samulis/packs/21029/), [Soundpacks](https://soundpacks.com/free-sound-packs/xylophone-samples-pack/), [Philharmonia Orchestra Sound Samples](https://www.philharmonia.co.uk/explore/sound_samples/banjo), [Precisionsound](https://store.precisionsound.net/shop/peruvian-ocarina/), [HearthSounds](https://github.com/mtimkovich/hearthsounds), and [The Mushroom Kingdom](https://themushroomkingdom.net/media/smw/wav).
46+
The toy synth uses samples from [Mixkit](https://mixkit.co/), [Bolder Sounds](https://www.boldersounds.com/index.php?main_page=product_music_info&products_id=71), [Freesound](https://freesound.org/people/sgossner/packs/21029/), [Soundpacks](https://soundpacks.com/free-sound-packs/xylophone-samples-pack/), [Philharmonia Orchestra Sound Samples](https://www.philharmonia.co.uk/explore/sound_samples/banjo), [Precisionsound](https://store.precisionsound.net/shop/peruvian-ocarina/), [HearthSounds](https://github.com/mtimkovich/hearthsounds), and [The Mushroom Kingdom](https://themushroomkingdom.net/media/smw/wav).
4747

4848
## Energy
4949

0 commit comments

Comments
 (0)