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 - - - - - - - -
-

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'), - }, -};