diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml
index 5ee3f2f..85a0c53 100644
--- a/.forgejo/workflows/deploy.yml
+++ b/.forgejo/workflows/deploy.yml
@@ -30,8 +30,11 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Check
- run: npm run check
+ - name: Lint and static checks
+ run: npm run lint:check
+
+ - name: Unit tests
+ run: npm test
- name: Build
run: npm run build
@@ -65,5 +68,4 @@ jobs:
- name: Copy build to host pages mount
run: |
apt update && apt install -y rsync
- mkdir -p /pages/photos
- rsync -a --delete dist/ /pages/photos/
+ rsync -a --delete --mkpath --chmod=ugo=rwx dist/ /pages/photos/
diff --git a/e2e/gallery.spec.ts b/e2e/gallery.spec.ts
new file mode 100644
index 0000000..6b436e0
--- /dev/null
+++ b/e2e/gallery.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test';
+
+test('renders the gallery and supports keyboard navigation', async ({
+ page,
+}) => {
+ await page.goto('/');
+
+ await expect(page.locator('.thumbnail')).toHaveCount(39);
+ await expect(page.locator('#frame-image')).toBeVisible();
+
+ const firstAlt = await page.locator('#frame-image').getAttribute('alt');
+
+ await page.locator('.thumbnail').first().focus();
+ await page.keyboard.press('ArrowRight');
+
+ await expect(page.locator('.thumbnail[aria-current="true"]')).toHaveCount(1);
+ await expect(page.locator('#frame-image')).not.toHaveAttribute(
+ 'alt',
+ firstAlt ?? ''
+ );
+});
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..d71063c
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: 'e2e',
+ timeout: 30_000,
+ use: {
+ baseURL: 'http://127.0.0.1:4173',
+ trace: 'on-first-retry',
+ },
+ webServer: {
+ command: 'npm run build && npm run preview',
+ url: 'http://127.0.0.1:4173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png
new file mode 100644
index 0000000..e69ae96
Binary files /dev/null and b/public/android-chrome-192x192.png differ
diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png
new file mode 100644
index 0000000..4621452
Binary files /dev/null and b/public/android-chrome-512x512.png differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..4a71769
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 0000000..6415d26
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 0000000..dba2959
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..eb6309f
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/site.webmanifest b/public/site.webmanifest
index 7679ff5..7029842 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -7,9 +7,20 @@
"theme_color": "#a63446",
"icons": [
{
- "src": "favicon.svg",
- "sizes": "64x64",
- "type": "image/svg+xml"
+ "src": "android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
}
]
}
diff --git a/src/index.html b/src/index.html
index 79201b6..c3da238 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1,24 +1,76 @@
-
-
-
-
- Photos - András Schmelczer
-
-
-
-
-
-
-
-
-
-
-
-
![opened photo]()
-
-
-
-
-
+
+
+
+
+
+ Photos - András Schmelczer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/index.scss b/src/index.scss
index aedb4e0..557b91c 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -1,239 +1,273 @@
-@mixin center-children() {
- display: flex;
- justify-content: center;
- align-items: center;
-}
+$breakpoint: 900px;
-@mixin portrait() {
- @media (max-width: 900px) {
- @content;
- }
+:root {
+ --color-text: #fff;
+ --color-ink: #202124;
+ --color-accent: #a63446;
+ --color-accent-strong: #7c2130;
+ --color-background: #eee;
+ --color-focus: #ffd166;
+ --border-width: 5px;
+ --space-xs: 0.375rem;
+ --space-s: 0.75rem;
+ --space-m: 1.5rem;
}
-@mixin landscape() {
- @media (min-width: 900px) {
- @content;
- }
-}
-
-$text-color: #ffffff;
-$accent-color: #a63446;
-$background-color: #eeeeee;
-$border-width: 5px;
-$normal-margin: 24px;
-$small-margin: 6px;
-
*,
*::before,
*::after {
box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-:focus {
- outline: $accent-color solid $border-width * 1.1;
- outline-offset: -$border-width * 1.1;
-}
-
-img {
- border: $border-width solid $text-color;
- box-sizing: content-box;
- width: 100%;
- height: auto;
- display: block;
}
html {
- height: 100%;
- overflow-x: hidden;
- background-color: $background-color;
+ min-height: 100%;
+ background: var(--color-background);
+ color: var(--color-ink);
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
- body {
- display: flex;
- overflow: hidden;
+body {
+ min-height: 100dvh;
+ margin: 0;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+}
- @include portrait {
- height: 100%;
- flex-direction: column;
- }
+img {
+ display: block;
+ max-inline-size: 100%;
+ block-size: auto;
+}
- @include landscape {
- &::-webkit-scrollbar-track,
- &::-webkit-scrollbar {
- background-color: transparent;
- width: 12px;
- }
- &::-webkit-scrollbar-thumb {
- background-color: $accent-color;
- border-radius: 1000px;
- }
- }
- h1,
- header {
- font-family: 'Roboto', serif;
- font-weight: 100;
+:focus {
+ outline: none;
+}
- @include landscape {
- font-size: 3rem;
- }
+:focus-visible {
+ outline: 3px solid var(--color-focus);
+ outline-offset: 4px;
+}
- @include portrait {
- font-size: 2rem;
- }
- }
+.site-header {
+ display: grid;
+ place-items: center;
+ background: var(--color-accent);
+ color: var(--color-text);
+ padding: var(--space-m) var(--space-s);
+}
- header {
- @include center-children;
- box-sizing: content-box;
- height: 100%;
- position: relative;
+.site-header h1 {
+ margin: 0;
+ font-size: clamp(1.5rem, 4vmin, 3rem);
+ font-weight: 300;
+ letter-spacing: 0;
+ line-height: 1;
+ writing-mode: vertical-rl;
+ transform: rotate(180deg);
+ white-space: nowrap;
+}
- @include portrait {
- width: 100%;
- height: 2.15ch;
- padding: $normal-margin 0;
- }
+.gallery {
+ min-width: 0;
+ min-height: 100dvh;
+ display: grid;
+ grid-template-columns: minmax(5rem, 10vmin) minmax(0, 1fr) minmax(
+ 7rem,
+ 14vmin
+ );
+ position: relative;
+}
- @include landscape {
- width: 2.15ch;
- height: 100vh;
- padding: 0 $normal-margin;
- }
+.rail {
+ min-height: 0;
+ max-height: 100dvh;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-s);
+ padding: var(--space-m) var(--space-s);
+ scrollbar-color: var(--color-accent) transparent;
+}
- h1 {
- color: $text-color;
- background-color: $accent-color;
- padding: $normal-margin 0;
+.thumbnail {
+ display: block;
+ flex: 0 0 auto;
+ border: var(--border-width) solid var(--color-text);
+ background: var(--color-text);
+ color: inherit;
+ box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
+}
- white-space: nowrap;
- user-select: none;
- z-index: 2;
- position: fixed;
+.thumbnail:hover,
+.thumbnail.is-selected {
+ border-color: var(--color-accent);
+}
- text-align: center;
+.thumbnail:focus-visible {
+ outline: 3px solid var(--color-focus);
+ outline-offset: 3px;
+ box-shadow:
+ 0 0 0 6px var(--color-accent-strong),
+ 0 0 0 1px rgb(32 33 36 / 16%);
+}
- @include portrait {
- width: 100%;
- }
+.thumbnail--portrait {
+ inline-size: 8vmin;
+}
- @include landscape {
- width: 101vh;
- transform: rotate(-90deg);
- }
- }
- }
+.thumbnail--landscape {
+ inline-size: 12vmin;
+}
- main#images {
- flex: 1;
- display: flex;
- justify-content: space-between;
+.thumbnail picture,
+.thumbnail img {
+ inline-size: 100%;
+}
- @include portrait {
- flex-direction: column;
- }
+.frame {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ padding: var(--space-m);
+}
- .cluster {
- display: flex;
- justify-content: space-between;
+.frame-content {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ place-items: center;
+}
- img {
- cursor: pointer;
- }
+.frame-figure {
+ min-width: 0;
+ margin: 0;
+ display: grid;
+ justify-items: center;
+ gap: var(--space-s);
+}
- portraits > img {
- @include portrait {
- height: 12vmin;
- width: auto;
- }
- @include landscape {
- width: 8vmin;
- }
- }
+.frame-figure picture {
+ display: grid;
+ place-items: center;
+ min-width: 0;
+}
- landscapes > img {
- @include portrait {
- height: 8vmin;
- width: auto;
- }
- @include landscape {
- width: 12vmin;
- }
- }
+.frame-figure img {
+ inline-size: auto;
+ max-inline-size: 100%;
+ max-block-size: calc(100dvh - 5rem);
+ border: var(--border-width) solid var(--color-text);
+ background: var(--color-text);
+ object-fit: contain;
+ box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
+}
- img {
- @include portrait {
- margin: 0 $small-margin;
- }
- }
+.frame-figure figcaption {
+ max-inline-size: min(42rem, 100%);
+ color: var(--color-ink);
+ font-size: 0.95rem;
+ line-height: 1.35;
+ text-align: center;
+}
- @include portrait {
- width: 100%;
- overflow: auto;
- margin: $small-margin 0;
- padding: $small-margin 0;
- object-fit: contain;
+.slideshow-toggle {
+ display: none;
+ justify-self: end;
+ margin-block-start: var(--space-s);
+ border: 0;
+ border-radius: 4px;
+ padding: 0.45rem 0.75rem;
+ background: var(--color-accent);
+ color: var(--color-text);
+ font: inherit;
+ cursor: pointer;
+}
- :first-child {
- margin-left: 2 * $small-margin;
- }
+.js .slideshow-toggle {
+ display: block;
+}
- &:last-child {
- margin-right: 2 * $small-margin !important;
- }
- }
+.slideshow-toggle:hover {
+ background: var(--color-accent-strong);
+}
- @include landscape {
- flex-direction: column;
- margin: $normal-margin;
-
- img {
- margin: $small-margin 0;
- }
-
- :first-child {
- margin-top: 0;
- }
-
- :last-child {
- margin-bottom: 0;
- }
- }
- }
-
- #frame-container {
- @include center-children;
- margin: 0 $border-width;
-
- flex: 1;
- @include landscape {
- }
-
- @include portrait {
- position: relative;
- }
-
- img {
- height: auto;
- width: auto;
-
- top: 50%;
- transform: translateY(-50%) translateX(-50%);
-
- @include portrait {
- position: absolute;
- box-sizing: border-box;
- max-width: 100% !important; // undo js
- max-height: 100%;
- left: 50% !important; // undo js
- }
-
- @include landscape {
- position: fixed;
- max-height: 90vh;
- }
- }
- }
- }
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ }
+}
+
+@media (width < $breakpoint) {
+ body {
+ min-height: 100dvh;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .site-header {
+ padding: var(--space-s) var(--space-m);
+ }
+
+ .site-header h1 {
+ writing-mode: horizontal-tb;
+ transform: none;
+ font-size: 2rem;
+ }
+
+ .gallery {
+ min-height: 0;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ }
+
+ .rail {
+ max-height: none;
+ min-width: 0;
+ overflow: auto hidden;
+ flex-direction: row;
+ justify-content: flex-start;
+ padding: var(--space-xs) var(--space-s);
+ }
+
+ .rail--portraits {
+ grid-row: 1;
+ }
+
+ .rail--landscapes {
+ grid-row: 3;
+ }
+
+ .thumbnail {
+ inline-size: auto;
+ }
+
+ .thumbnail--portrait img {
+ block-size: 12vmin;
+ inline-size: auto;
+ }
+
+ .thumbnail--landscape img {
+ block-size: 8vmin;
+ inline-size: auto;
+ }
+
+ .frame {
+ grid-row: 2;
+ padding: var(--space-s);
+ }
+
+ .frame-figure img {
+ max-block-size: calc(100dvh - 12rem);
}
}
diff --git a/src/photo-catalog.json b/src/photo-catalog.json
new file mode 100644
index 0000000..3a61a8c
--- /dev/null
+++ b/src/photo-catalog.json
@@ -0,0 +1,197 @@
+[
+ {
+ "file": "_DSC1190-Edit.jpg",
+ "alt": "Portrait of a man in a burgundy sleeveless shirt standing with folded arms above a city view.",
+ "caption": "Portrait above the city"
+ },
+ {
+ "file": "_DSC1484.jpg",
+ "alt": "A man smiling while drying a plate in a kitchen.",
+ "caption": "Kitchen portrait"
+ },
+ {
+ "file": "_DSC1834.jpg",
+ "alt": "A person sitting near dark rocks while waves crash into a sea cave.",
+ "caption": "Watching the surf"
+ },
+ {
+ "file": "_DSC2313.jpg",
+ "alt": "Portrait of a man in a teal coat standing among autumn leaves.",
+ "caption": "Autumn portrait"
+ },
+ {
+ "file": "_DSC2362.jpg",
+ "alt": "Portrait of a man with glasses leaning on a railing in a park.",
+ "caption": "Park portrait"
+ },
+ {
+ "file": "_DSC2674.jpg",
+ "alt": "A person in a hooded jacket walking down a narrow city street.",
+ "caption": "City walk"
+ },
+ {
+ "file": "_DSC8718-Edit.jpg",
+ "alt": "Defocused city lights glowing across a dark night skyline.",
+ "caption": "Night lights"
+ },
+ {
+ "file": "adam.jpg",
+ "alt": "A man standing against a dark background in a black graphic T-shirt.",
+ "caption": "Adam"
+ },
+ {
+ "file": "dorka-1.jpg",
+ "alt": "A woman in a white knit hat sitting outdoors on an autumn day.",
+ "caption": "Dorka"
+ },
+ {
+ "file": "kezdetazarovonalbol.jpg",
+ "alt": "A small plant stem silhouetted against warm yellow and orange light.",
+ "caption": "Small stem in warm light"
+ },
+ {
+ "file": "nap-2.jpg",
+ "alt": "The sun setting behind a mountain ridge over a field.",
+ "caption": "Sunset field"
+ },
+ {
+ "file": "nyomtatas-8.jpg",
+ "alt": "Close-up of ice crystals and frozen texture in blue light.",
+ "caption": "Ice crystals"
+ },
+ {
+ "file": "nyomtatas-10.jpg",
+ "alt": "Warm abstract light passing through dried plants.",
+ "caption": "Dried plants in light"
+ },
+ {
+ "file": "web-1-2.jpg",
+ "alt": "A city panorama seen through a coin-operated viewing telescope.",
+ "caption": "City lookout"
+ },
+ {
+ "file": "web-1.jpg",
+ "alt": "Black and white stage scene with dancers performing before an audience.",
+ "caption": "Stage performance"
+ },
+ {
+ "file": "web-2-2.jpg",
+ "alt": "Two zebras walking across a black and white landscape.",
+ "caption": "Zebras"
+ },
+ {
+ "file": "web-2.jpg",
+ "alt": "Coastal town and hillside beside a blue sea under a bright sky.",
+ "caption": "Coastal town"
+ },
+ {
+ "file": "web-3-2.jpg",
+ "alt": "A woman walking away on a beach while carrying a bag.",
+ "caption": "Beach walk"
+ },
+ {
+ "file": "web-3.jpg",
+ "alt": "Black and white lake scene with a small boat under a bright sun.",
+ "caption": "Boat on the lake"
+ },
+ {
+ "file": "web-4-2.jpg",
+ "alt": "Black and white seascape with waves rolling toward weathered posts.",
+ "caption": "Sea posts"
+ },
+ {
+ "file": "web-5.jpg",
+ "alt": "Candles glowing in the foreground with soft golden lights behind them.",
+ "caption": "Candles"
+ },
+ {
+ "file": "web-6.jpg",
+ "alt": "Aerial view over a city and river beneath broken clouds.",
+ "caption": "City and river"
+ },
+ {
+ "file": "web-7.jpg",
+ "alt": "The Eiffel Tower lit at night against a dark sky.",
+ "caption": "Eiffel Tower at night"
+ },
+ {
+ "file": "web-8.jpg",
+ "alt": "Bare winter trees fading into a pale snowy background.",
+ "caption": "Winter trees"
+ },
+ {
+ "file": "web-10.jpg",
+ "alt": "A person in a dark jacket sitting on a bench and looking over water.",
+ "caption": "Looking across the water"
+ },
+ {
+ "file": "web-11.jpg",
+ "alt": "Black and white close-up portrait of a dog looking into the camera.",
+ "caption": "Dog portrait"
+ },
+ {
+ "file": "web-12.jpg",
+ "alt": "Two people sitting by water under a vivid orange sunset.",
+ "caption": "Sunset by the water"
+ },
+ {
+ "file": "web-13.jpg",
+ "alt": "Portrait of a woman standing beside a tree in green summer light.",
+ "caption": "Tree portrait"
+ },
+ {
+ "file": "web-14.jpg",
+ "alt": "Golden sunlight reflecting on rippled water.",
+ "caption": "Sunlit ripples"
+ },
+ {
+ "file": "web-17.jpg",
+ "alt": "A small green plant shoot backlit by bright sunlight.",
+ "caption": "Backlit sprout"
+ },
+ {
+ "file": "web-18.jpg",
+ "alt": "Birds flying around pale stone sculpture details.",
+ "caption": "Birds and stone"
+ },
+ {
+ "file": "web-19.jpg",
+ "alt": "A bridge at night with blue lights and long yellow light trails.",
+ "caption": "Bridge light trails"
+ },
+ {
+ "file": "web-20.jpg",
+ "alt": "A seated man in a dark coat photographed at night with city lights behind him.",
+ "caption": "Night portrait"
+ },
+ {
+ "file": "web-22.jpg",
+ "alt": "Silhouettes seen through large windows during a pink and blue sunset.",
+ "caption": "Window silhouettes"
+ },
+ {
+ "file": "web-23.jpg",
+ "alt": "Tall clock tower and stone architecture with a bird flying nearby.",
+ "caption": "Clock tower"
+ },
+ {
+ "file": "web-24.jpg",
+ "alt": "Portrait of a woman at night with bridge lights behind her.",
+ "caption": "Bridge portrait"
+ },
+ {
+ "file": "web-25.jpg",
+ "alt": "Portrait of a smiling man at night with blurred city lights behind him.",
+ "caption": "Smiling night portrait"
+ },
+ {
+ "file": "web-26.jpg",
+ "alt": "Tall bridge lamps receding into a pale sky.",
+ "caption": "Bridge lamps"
+ },
+ {
+ "file": "web-42.jpg",
+ "alt": "Warm sunset over a grassy field with low hills in the distance.",
+ "caption": "Sunset hills"
+ }
+]
diff --git a/src/photo-types.ts b/src/photo-types.ts
new file mode 100644
index 0000000..d9cf124
--- /dev/null
+++ b/src/photo-types.ts
@@ -0,0 +1,28 @@
+export type PhotoOrientation = 'landscape' | 'portrait';
+export type ImageMimeType = 'image/avif' | 'image/webp' | 'image/jpeg';
+
+export type ImageVariant = {
+ readonly src: string;
+ readonly width: number;
+ readonly height: number;
+};
+
+export type ImageSource = {
+ readonly type: ImageMimeType;
+ readonly variants: readonly ImageVariant[];
+ readonly srcset: string;
+};
+
+export type Photo = {
+ readonly id: string;
+ readonly file: string;
+ readonly order: number;
+ readonly alt: string;
+ readonly caption: string;
+ readonly orientation: PhotoOrientation;
+ readonly width: number;
+ readonly height: number;
+ readonly aspectRatio: number;
+ readonly sources: readonly ImageSource[];
+ readonly fallback: ImageVariant;
+};
diff --git a/src/photos.test.ts b/src/photos.test.ts
new file mode 100644
index 0000000..0629f71
--- /dev/null
+++ b/src/photos.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it, vi } from 'vitest';
+import { PhotoGallery, wrapIndex } from './photos';
+import type { Photo } from './photo-types';
+
+const variant = (name: string) => ({
+ src: `static/photos/${name}.jpg`,
+ width: 320,
+ height: 240,
+});
+
+const photo = (id: string, alt: string): Photo => ({
+ id,
+ file: `${id}.jpg`,
+ order: 1,
+ alt,
+ caption: alt,
+ orientation: 'landscape',
+ width: 320,
+ height: 240,
+ aspectRatio: 1.3333,
+ sources: [
+ {
+ type: 'image/avif',
+ variants: [variant(`${id}.avif`)],
+ srcset: `static/photos/${id}.avif 320w`,
+ },
+ {
+ type: 'image/webp',
+ variants: [variant(`${id}.webp`)],
+ srcset: `static/photos/${id}.webp 320w`,
+ },
+ {
+ type: 'image/jpeg',
+ variants: [variant(`${id}.jpg`)],
+ srcset: `static/photos/${id}.jpg 320w`,
+ },
+ ],
+ fallback: variant(`${id}.jpg`),
+});
+
+const setup = () => {
+ vi.stubGlobal('CSS', {
+ escape: (value: string) => value,
+ });
+ vi.stubGlobal('matchMedia', () => ({
+ matches: false,
+ media: '(prefers-reduced-motion: reduce)',
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+ window.HTMLElement.prototype.scrollIntoView = vi.fn();
+
+ document.body.innerHTML = `
+
+ One
+ Two
+
+
+
+ `;
+
+ const gallery = document.querySelector('#gallery');
+ const frame = document.querySelector('#frame');
+ const toggle = document.querySelector('#toggle');
+
+ if (!gallery || !frame || !toggle) {
+ throw new Error('Test DOM failed to initialize.');
+ }
+
+ return {
+ gallery,
+ frame,
+ toggle,
+ photos: [photo('one', 'First photo'), photo('two', 'Second photo')],
+ };
+};
+
+describe('wrapIndex', () => {
+ it('wraps backward to the last item', () => {
+ expect(wrapIndex(-1, 4)).toBe(3);
+ });
+
+ it('wraps forward to the first item', () => {
+ expect(wrapIndex(4, 4)).toBe(0);
+ });
+
+ it('rejects empty collections', () => {
+ expect(() => wrapIndex(0, 0)).toThrow('without items');
+ });
+});
+
+describe('PhotoGallery', () => {
+ it('updates the frame and selected state when a thumbnail is clicked', () => {
+ const { gallery, frame, toggle, photos } = setup();
+ new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false });
+
+ gallery
+ .querySelector('[data-photo-id="two"]')
+ ?.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true })
+ );
+
+ expect(frame.querySelector('img')?.alt).toBe('Second photo');
+ expect(
+ gallery
+ .querySelector('[data-photo-id="two"]')
+ ?.getAttribute('aria-current')
+ ).toBe('true');
+ });
+
+ it('uses scoped arrow-key navigation', () => {
+ const { gallery, frame, toggle, photos } = setup();
+ new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false });
+
+ gallery.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+
+ expect(frame.querySelector('img')?.alt).toBe('Second photo');
+ });
+});
diff --git a/src/photos.ts b/src/photos.ts
index 7493b39..6c575b1 100644
--- a/src/photos.ts
+++ b/src/photos.ts
@@ -1,109 +1,273 @@
-import { ResponsiveImage } from './model/responsive-image';
-import { createImage } from './helper/create-image';
+import type { ImageSource, Photo } from './photo-types';
-export class Photos {
- private readonly images: Array;
- private _selectedId: number;
- private timerId: NodeJS.Timeout = null;
- private shouldNotChange = false;
+const AUTO_ADVANCE_DELAY_MS = 7000;
+const FRAME_SIZES =
+ '(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
- public constructor(
- images: Array,
- private readonly landscapesContainer: HTMLElement,
- private readonly portraitsContainer: HTMLElement,
- private readonly pictureFrame: HTMLImageElement
- ) {
- this.images = this.convertImages(images);
- this.placeImages();
- this.addEventListeners();
- this.startAutoChange();
- this.selectedId = 0;
+type GalleryOptions = {
+ readonly photos: readonly Photo[];
+ readonly gallery: HTMLElement;
+ readonly frame: HTMLElement;
+ readonly toggle: HTMLButtonElement;
+ readonly autoAdvance?: boolean;
+};
+
+type SelectOptions = {
+ readonly focusThumbnail?: boolean;
+ readonly pause?: boolean;
+};
+
+export const requiredElement = (
+ selector: string,
+ constructor: { new (...args: never[]): T }
+): T => {
+ const element = document.querySelector(selector);
+
+ if (!(element instanceof constructor)) {
+ throw new Error(`Required element not found: ${selector}`);
}
- private get selectedId(): number {
- return this._selectedId;
+ return element;
+};
+
+export const wrapIndex = (value: number, count: number): number => {
+ if (count <= 0) {
+ throw new Error('Cannot wrap an index without items.');
}
- private set selectedId(value: number) {
- if (value < 0) {
- value += this.images.length - 1;
+ return ((value % count) + count) % count;
+};
+
+const createSource = (source: ImageSource): HTMLSourceElement => {
+ const element = document.createElement('source');
+ element.type = source.type;
+ element.srcset = source.srcset;
+ element.sizes = FRAME_SIZES;
+
+ return element;
+};
+
+const createFrameFigure = (photo: Photo): HTMLElement => {
+ const figure = document.createElement('figure');
+ figure.className = 'frame-figure';
+
+ const picture = document.createElement('picture');
+
+ for (const source of photo.sources.filter(
+ ({ type }) => type !== 'image/jpeg'
+ )) {
+ picture.append(createSource(source));
+ }
+
+ const jpeg = photo.sources.find(({ type }) => type === 'image/jpeg');
+ const image = document.createElement('img');
+ image.id = 'frame-image';
+ image.src = photo.fallback.src;
+ image.srcset = jpeg?.srcset ?? '';
+ image.sizes = FRAME_SIZES;
+ image.width = photo.width;
+ image.height = photo.height;
+ image.alt = photo.alt;
+ image.decoding = 'async';
+ image.fetchPriority = 'high';
+
+ picture.append(image);
+
+ const caption = document.createElement('figcaption');
+ caption.id = 'frame-caption';
+ caption.textContent = photo.caption;
+
+ figure.append(picture, caption);
+
+ return figure;
+};
+
+export class PhotoGallery {
+ private selectedIndex = 0;
+ private timerId: number | null = null;
+ private readonly thumbnails: HTMLAnchorElement[];
+ private readonly reducedMotion = window.matchMedia(
+ '(prefers-reduced-motion: reduce)'
+ );
+
+ public constructor(private readonly options: GalleryOptions) {
+ if (options.photos.length === 0) {
+ throw new Error('The gallery needs at least one photo.');
}
- this._selectedId = value % this.images.length;
- this.loadToFrame(this.images[this.selectedId]);
- this.images[this.selectedId].focus();
- this.shouldNotChange = true;
- }
- private convertImages(images: Array) {
- let portraits = 0; // evens
- let landscapes = 1; // odds
-
- const imgs = images.map((image, i) =>
- createImage(
- image,
- 'a photo',
- Photos.isLandscape(image) ? (landscapes += 2) : (portraits += 2)
- )
+ this.thumbnails = this.options.photos.map((photo) =>
+ this.thumbnailFor(photo)
);
+ this.addEventListeners();
+ this.select(0);
- return imgs.sort((a, b) => a.tabIndex - b.tabIndex);
+ if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
+ this.startAutoAdvance();
+ } else {
+ this.updateToggle(false);
+ }
}
- private placeImages() {
- this.images.forEach(img => {
- if (Photos.isLandscape(img)) {
- img.classList.add('landscape');
- this.landscapesContainer.appendChild(img);
- } else {
- img.classList.add('portrait');
- this.portraitsContainer.appendChild(img);
+ private addEventListeners(): void {
+ this.options.gallery.addEventListener('click', (event) => {
+ const thumbnail = (event.target as Element).closest(
+ '[data-photo-id]'
+ );
+
+ if (!thumbnail) {
+ return;
}
- });
- }
- private addEventListeners() {
- window.document.addEventListener('keydown', e => {
- switch (e.key) {
+ event.preventDefault();
+ this.selectById(thumbnail.dataset.photoId, { pause: true });
+ });
+
+ this.options.gallery.addEventListener('keydown', (event) => {
+ if (!this.options.gallery.contains(event.target as Node)) {
+ return;
+ }
+
+ switch (event.key) {
case 'ArrowLeft':
- this.selectedId--;
+ event.preventDefault();
+ this.select(this.selectedIndex - 1, {
+ focusThumbnail: true,
+ pause: true,
+ });
break;
case 'ArrowRight':
- this.selectedId++;
+ event.preventDefault();
+ this.select(this.selectedIndex + 1, {
+ focusThumbnail: true,
+ pause: true,
+ });
+ break;
+ case 'Home':
+ event.preventDefault();
+ this.select(0, { focusThumbnail: true, pause: true });
+ break;
+ case 'End':
+ event.preventDefault();
+ this.select(this.options.photos.length - 1, {
+ focusThumbnail: true,
+ pause: true,
+ });
break;
- case 'Tab':
- if (this.timerId !== null) {
- clearInterval(this.timerId);
- this.timerId = null;
- }
}
});
- window.document.addEventListener(
- 'scroll',
- () => (this.shouldNotChange = true)
+ this.options.gallery.addEventListener('pointerdown', () =>
+ this.pauseAutoAdvance()
+ );
+ this.options.gallery.addEventListener(
+ 'wheel',
+ () => this.pauseAutoAdvance(),
+ { passive: true }
+ );
+ this.options.gallery.addEventListener(
+ 'touchstart',
+ () => this.pauseAutoAdvance(),
+ { passive: true }
);
- this.images.forEach((image, i) =>
- image.addEventListener('click', () => (this.selectedId = i))
- );
- }
-
- private startAutoChange() {
- this.timerId = setInterval(() => {
- if (!this.shouldNotChange) {
- this.selectedId++;
+ this.options.toggle.addEventListener('click', () => {
+ if (this.timerId === null) {
+ this.startAutoAdvance();
+ } else {
+ this.pauseAutoAdvance();
}
- this.shouldNotChange = false;
- }, 7000);
+ });
+
+ this.reducedMotion.addEventListener('change', () => {
+ if (this.reducedMotion.matches) {
+ this.pauseAutoAdvance();
+ }
+ });
}
- private loadToFrame(img: HTMLImageElement) {
- this.pictureFrame.src = img.src;
+ private thumbnailFor(photo: Photo): HTMLAnchorElement {
+ const thumbnail = this.options.gallery.querySelector(
+ `[data-photo-id="${CSS.escape(photo.id)}"]`
+ );
+
+ if (!thumbnail) {
+ throw new Error(`Missing thumbnail for ${photo.id}.`);
+ }
+
+ return thumbnail;
}
- private static isLandscape(
- image: HTMLImageElement | ResponsiveImage
- ): boolean {
- return image.width > image.height;
+ private selectById(id: string | undefined, options: SelectOptions): void {
+ const index = this.options.photos.findIndex((photo) => photo.id === id);
+
+ if (index >= 0) {
+ this.select(index, options);
+ }
+ }
+
+ private select(value: number, options: SelectOptions = {}): void {
+ this.selectedIndex = wrapIndex(value, this.options.photos.length);
+ const photo = this.options.photos[this.selectedIndex];
+
+ if (!photo) {
+ throw new Error('Selected photo is missing.');
+ }
+
+ this.options.frame.replaceChildren(createFrameFigure(photo));
+ this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false);
+
+ if (options.pause) {
+ this.pauseAutoAdvance();
+ }
+ }
+
+ private updateSelectedThumbnail(photo: Photo, shouldFocus: boolean): void {
+ for (const thumbnail of this.thumbnails) {
+ const selected = thumbnail.dataset.photoId === photo.id;
+ if (selected) {
+ thumbnail.setAttribute('aria-current', 'true');
+ } else {
+ thumbnail.removeAttribute('aria-current');
+ }
+ thumbnail.classList.toggle('is-selected', selected);
+
+ if (selected && shouldFocus) {
+ thumbnail.focus({ preventScroll: true });
+ thumbnail.scrollIntoView({
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ }
+ }
+ }
+
+ private startAutoAdvance(): void {
+ this.pauseAutoAdvance(false);
+
+ if (this.reducedMotion.matches) {
+ this.updateToggle(false);
+ return;
+ }
+
+ this.timerId = window.setInterval(() => {
+ this.select(this.selectedIndex + 1);
+ }, AUTO_ADVANCE_DELAY_MS);
+ this.updateToggle(true);
+ }
+
+ private pauseAutoAdvance(updateToggle = true): void {
+ if (this.timerId !== null) {
+ window.clearInterval(this.timerId);
+ this.timerId = null;
+ }
+
+ if (updateToggle) {
+ this.updateToggle(false);
+ }
+ }
+
+ private updateToggle(isRunning: boolean): void {
+ this.options.toggle.setAttribute('aria-pressed', String(isRunning));
+ this.options.toggle.textContent = isRunning ? 'Pause' : 'Play';
}
}
diff --git a/stylelint.config.js b/stylelint.config.js
new file mode 100644
index 0000000..d14edcb
--- /dev/null
+++ b/stylelint.config.js
@@ -0,0 +1,9 @@
+export default {
+ extends: ['stylelint-config-standard-scss'],
+ rules: {
+ 'selector-id-pattern': null,
+ 'selector-class-pattern':
+ '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)?$',
+ 'scss/dollar-variable-pattern': null,
+ },
+};
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..fa7e277
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ include: ['src/**/*.test.ts'],
+ },
+});
diff --git a/webpack.config.js b/webpack.config.js
deleted file mode 100644
index 2d527fc..0000000
--- a/webpack.config.js
+++ /dev/null
@@ -1,174 +0,0 @@
-const path = require('path');
-const HtmlWebpackPlugin = require('html-webpack-plugin');
-const { CleanWebpackPlugin } = require('clean-webpack-plugin');
-const TerserJSPlugin = require('terser-webpack-plugin');
-const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
-const MiniCssExtractPlugin = require('mini-css-extract-plugin');
-const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
-const Sharp = require('responsive-loader/sharp');
-const Sass = require('sass');
-
-const isProduction = process.env.NODE_ENV === 'production';
-
-module.exports = {
- watchOptions: {
- ignored: /node_modules/,
- },
- devServer: {
- host: '0.0.0.0',
- disableHostCheck: true,
- },
- optimization: {
- minimizer: [
- new TerserJSPlugin({
- sourceMap: !isProduction,
- }),
- new OptimizeCSSAssetsPlugin({}),
- ],
- },
- plugins: [
- new CleanWebpackPlugin(),
- new HtmlWebpackPlugin({
- xhtml: true,
- template: './src/index.html',
- minify: {
- collapseWhitespace: true,
- removeComments: true,
- removeRedundantAttributes: true,
- removeScriptTypeAttributes: true,
- removeStyleLinkTypeAttributes: true,
- useShortDoctype: true,
- },
- inlineSource: '.(js|css)$',
- }),
- new HtmlWebpackInlineSourcePlugin(),
- new MiniCssExtractPlugin({
- filename: '[name].[contenthash].css',
- chunkFilename: '[id].[contenthash].css',
- }),
- ],
- entry: {
- index: './src/index.ts',
- },
- module: {
- rules: [
- {
- test: /\.(jpe?g|png)$/i,
- loader: 'responsive-loader',
- options: {
- adapter: Sharp,
- quality: 90,
- outputPath: 'static/',
- sizes: [300, 800, 1200, 2000],
- placeholder: false,
- },
- },
- {
- test: /\.(webm|mp4|gif)$/i,
- use: [
- {
- loader: 'file-loader',
- query: {
- outputPath: 'static/',
- },
- },
- {
- loader: 'image-webpack-loader',
- options: {
- disable: !isProduction,
- mozjpeg: {
- progressive: true,
- quality: 65,
- },
- optipng: {
- enabled: true,
- },
- pngquant: {
- quality: [0.65, 0.9],
- speed: 4,
- },
- gifsicle: {
- interlaced: false,
- },
- webp: {
- quality: 65,
- },
- },
- },
- ],
- },
- {
- test: /\.svg$/,
- loader: 'svg-url-loader',
- options: {
- limit: 10 * 1024,
- noquotes: true,
- },
- },
- {
- test: /\.(pdf)$/i,
- use: {
- loader: 'file-loader',
- query: {
- outputPath: 'static/',
- name: '[name].[ext]',
- },
- },
- },
- {
- test: /no-change.*(ico|html|txt)$/i,
- use: {
- loader: 'file-loader',
- query: {
- outputPath: '/',
- name: '[name].[ext]',
- },
- },
- },
- {
- test: /\.scss$/i,
- use: [
- MiniCssExtractPlugin.loader,
- 'css-loader',
- 'postcss-loader',
- {
- loader: 'resolve-url-loader',
- options: {
- keepQuery: true,
- },
- },
- {
- loader: 'sass-loader',
- options: {
- sourceMap: true,
- implementation: Sass,
- },
- },
- ],
- },
- {
- test: /\.(woff2?|ttf|eot|svg)(?:[?#].+)?$/,
- use: {
- loader: 'file-loader',
- options: {
- name: '[name].[ext]',
- outputPath: 'static/fonts/',
- },
- },
- include: /fonts/,
- },
- {
- test: /\.ts$/,
- use: 'ts-loader',
- exclude: /node_modules/,
- },
- ],
- },
- resolve: {
- extensions: ['.ts', '.js'],
- },
- output: {
- filename: '[name].[contenthash].js',
- path: path.resolve(__dirname, 'dist'),
- },
-};