Skip to content

Commit d0c5978

Browse files
committed
Add prev/next navigation to screenshot dialog
Show one image at a time with prev/next buttons and a counter instead of stacking all images in a scrollable view. Remove position: relative that interfered with native dialog centering.
1 parent de67e76 commit d0c5978

4 files changed

Lines changed: 103 additions & 14 deletions

File tree

src/design/components/lab-card/index.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,34 @@ export function LabCard({ lab, basePath = '/' }: { lab: FeaturedLab; basePath?:
9090
{screenshotLinks.map(({ link, id }) => (
9191
<screenshot-dialog>
9292
<dialog id={id} class="screenshot-dialog">
93-
<button class="screenshot-dialog__close" data-close-dialog aria-label="Close" type="button">
94-
&times;
95-
</button>
93+
<div class="screenshot-dialog__header">
94+
<span class="screenshot-dialog__counter" aria-live="polite"></span>
95+
<button class="screenshot-dialog__close" data-close-dialog aria-label="Close" type="button">
96+
&times;
97+
</button>
98+
</div>
9699
<div class="screenshot-dialog__images">
97-
{link.images.map((img) => (
100+
{link.images.map((img, i) => (
98101
<img
99102
class="screenshot-dialog__img"
100103
src={url(img.src, basePath)}
101104
alt={img.alt}
102105
loading="lazy"
106+
data-index={i}
107+
hidden={i > 0}
103108
/>
104109
))}
105110
</div>
111+
{link.images.length > 1 ? (
112+
<div class="screenshot-dialog__nav">
113+
<button class="screenshot-dialog__nav-btn" data-prev type="button" aria-label="Previous screenshot">
114+
&larr; Prev
115+
</button>
116+
<button class="screenshot-dialog__nav-btn" data-next type="button" aria-label="Next screenshot">
117+
Next &rarr;
118+
</button>
119+
</div>
120+
) : null}
106121
</dialog>
107122
</screenshot-dialog>
108123
))}

src/design/components/lab-card/styles.css

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,17 +125,25 @@
125125
border-radius: var(--radius-sm);
126126
padding: var(--space-6);
127127
overflow-y: auto;
128-
position: relative;
129128
}
130129

131130
.screenshot-dialog::backdrop {
132131
background: rgb(0 0 0 / 0.6);
133132
}
134133

134+
.screenshot-dialog__header {
135+
display: flex;
136+
justify-content: space-between;
137+
align-items: center;
138+
margin-block-end: var(--space-4);
139+
}
140+
141+
.screenshot-dialog__counter {
142+
font-size: var(--step--1);
143+
color: var(--color-ink-subtle);
144+
}
145+
135146
.screenshot-dialog__close {
136-
position: absolute;
137-
inset-block-start: var(--space-3);
138-
inset-inline-end: var(--space-3);
139147
background: none;
140148
border: none;
141149
font-size: var(--step-3);
@@ -144,6 +152,7 @@
144152
color: var(--color-ink-subtle);
145153
padding: var(--space-1) var(--space-2);
146154
border-radius: var(--radius-sm);
155+
margin-inline-start: auto;
147156
}
148157

149158
.screenshot-dialog__close:hover,
@@ -154,8 +163,7 @@
154163

155164
.screenshot-dialog__images {
156165
display: flex;
157-
flex-direction: column;
158-
gap: var(--space-4);
166+
justify-content: center;
159167
}
160168

161169
.screenshot-dialog__img {
@@ -164,4 +172,32 @@
164172
border-radius: var(--radius-sm);
165173
box-shadow: var(--shadow-card);
166174
}
175+
176+
.screenshot-dialog__nav {
177+
display: flex;
178+
justify-content: space-between;
179+
margin-block-start: var(--space-4);
180+
}
181+
182+
.screenshot-dialog__nav-btn {
183+
background: none;
184+
border: 1px solid var(--color-surface-alt);
185+
border-radius: var(--radius-sm);
186+
padding: var(--space-2) var(--space-4);
187+
cursor: pointer;
188+
font: inherit;
189+
font-size: var(--step--1);
190+
color: var(--color-link);
191+
}
192+
193+
.screenshot-dialog__nav-btn:hover,
194+
.screenshot-dialog__nav-btn:focus-visible {
195+
background: var(--color-surface-alt);
196+
color: var(--color-link-hover);
197+
}
198+
199+
.screenshot-dialog__nav-btn:disabled {
200+
opacity: 0.4;
201+
cursor: default;
202+
}
167203
}

src/design/components/screenshot-dialog/client.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,59 @@
11
class ScreenshotDialogElement extends HTMLElement {
22
private dialog: HTMLDialogElement | null = null
3+
private images: HTMLImageElement[] = []
4+
private current = 0
35

46
connectedCallback() {
57
this.dialog = this.querySelector('dialog')
68
if (!this.dialog) return
79

10+
this.images = Array.from(this.dialog.querySelectorAll('.screenshot-dialog__img'))
811
this.dialog.addEventListener('click', this.handleBackdropClick)
912

10-
const closeBtn = this.dialog.querySelector('[data-close-dialog]')
11-
closeBtn?.addEventListener('click', () => this.dialog?.close())
13+
this.dialog.querySelector('[data-close-dialog]')
14+
?.addEventListener('click', () => this.dialog?.close())
15+
16+
this.dialog.querySelector('[data-prev]')
17+
?.addEventListener('click', () => this.show(this.current - 1))
18+
19+
this.dialog.querySelector('[data-next]')
20+
?.addEventListener('click', () => this.show(this.current + 1))
1221

1322
// Find the button that opens this dialog (outside this element, in the card)
1423
const dialogId = this.dialog.id
1524
if (dialogId) {
1625
const opener = document.querySelector(`[data-open-dialog="${dialogId}"]`)
17-
opener?.addEventListener('click', () => this.dialog?.showModal())
26+
opener?.addEventListener('click', () => {
27+
this.show(0)
28+
this.dialog?.showModal()
29+
})
1830
}
1931
}
2032

2133
disconnectedCallback() {
2234
this.dialog?.removeEventListener('click', this.handleBackdropClick)
2335
}
2436

37+
private show(index: number) {
38+
if (index < 0 || index >= this.images.length) return
39+
this.current = index
40+
41+
for (const img of this.images) {
42+
img.hidden = true
43+
}
44+
this.images[index].hidden = false
45+
46+
const counter = this.dialog?.querySelector('.screenshot-dialog__counter')
47+
if (counter && this.images.length > 1) {
48+
counter.textContent = `${index + 1} of ${this.images.length}`
49+
}
50+
51+
const prev = this.dialog?.querySelector('[data-prev]') as HTMLButtonElement | null
52+
const next = this.dialog?.querySelector('[data-next]') as HTMLButtonElement | null
53+
if (prev) prev.disabled = index === 0
54+
if (next) next.disabled = index === this.images.length - 1
55+
}
56+
2557
handleBackdropClick = (e: Event) => {
2658
if (e.target === this.dialog) {
2759
this.dialog.close()

tests/views/components.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,19 @@ describe('LabCard', () => {
186186
expect(html).not.toContain('href="/assets/images/')
187187
})
188188

189-
test('screenshot link renders a dialog with images', async () => {
189+
test('screenshot link renders a dialog with images and nav buttons', async () => {
190190
const html = await renderToHtml(<LabCard lab={withScreenshots} />)
191191
expect(html).toContain('<dialog')
192192
expect(html).toContain('src="/assets/images/messaging-dashboard.png"')
193193
expect(html).toContain('alt="Dashboard"')
194194
expect(html).toContain('src="/assets/images/messaging-get-started.png"')
195195
expect(html).toContain('alt="Get started"')
196+
// First image visible, second hidden
197+
expect(html).toContain('data-index="0"')
198+
expect(html).toContain('data-index="1"')
199+
// Prev/next nav buttons
200+
expect(html).toContain('data-prev')
201+
expect(html).toContain('data-next')
196202
})
197203

198204
test('screenshot image src respects basePath', async () => {

0 commit comments

Comments
 (0)