TIdy up
This commit is contained in:
parent
35a962935c
commit
1914991250
19 changed files with 991 additions and 487 deletions
|
|
@ -30,8 +30,11 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Check
|
- name: Lint and static checks
|
||||||
run: npm run check
|
run: npm run lint:check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
@ -65,5 +68,4 @@ jobs:
|
||||||
- name: Copy build to host pages mount
|
- name: Copy build to host pages mount
|
||||||
run: |
|
run: |
|
||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
mkdir -p /pages/photos
|
rsync -a --delete --mkpath --chmod=ugo=rwx dist/ /pages/photos/
|
||||||
rsync -a --delete dist/ /pages/photos/
|
|
||||||
|
|
|
||||||
21
e2e/gallery.spec.ts
Normal file
21
e2e/gallery.spec.ts
Normal file
|
|
@ -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 ?? ''
|
||||||
|
);
|
||||||
|
});
|
||||||
22
playwright.config.ts
Normal file
22
playwright.config.ts
Normal file
|
|
@ -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'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
BIN
public/android-chrome-192x192.png
Normal file
BIN
public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 KiB |
BIN
public/android-chrome-512x512.png
Normal file
BIN
public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 538 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -7,9 +7,20 @@
|
||||||
"theme_color": "#a63446",
|
"theme_color": "#a63446",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.svg",
|
"src": "android-chrome-192x192.png",
|
||||||
"sizes": "64x64",
|
"sizes": "192x192",
|
||||||
"type": "image/svg+xml"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
src/index.html
100
src/index.html
|
|
@ -1,24 +1,76 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>Photos - András Schmelczer</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="description" content="A very simple page where I showcase some of my photos. Although I am not a professional photographer, I truly love capturing joyous moments.">
|
<title>Photos - András Schmelczer</title>
|
||||||
<meta name="theme-color" content="#A63446">
|
<meta
|
||||||
<meta name="viewport" content="initial-scale=1.0" />
|
name="description"
|
||||||
|
content="A small photo portfolio by András Schmelczer, with portraits, landscapes, city scenes, and quiet details."
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
|
/>
|
||||||
</head>
|
<meta name="theme-color" content="#a63446" />
|
||||||
<body>
|
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||||
<header>
|
<link rel="canonical" href="__SITE_URL__" />
|
||||||
<h1>András Schmelczer</h1>
|
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||||
</header>
|
<link rel="icon" href="favicon.ico" sizes="any" />
|
||||||
<main id="images">
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
|
||||||
<section id="portraits" class="cluster"></section>
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
|
||||||
<div id="frame-container">
|
<link rel="manifest" href="site.webmanifest" />
|
||||||
<img src="" alt="opened photo" id="frame-image"/>
|
|
||||||
</div>
|
<meta property="og:type" content="website" />
|
||||||
<section id="landscapes" class="cluster"></section>
|
<meta property="og:url" content="__SITE_URL__" />
|
||||||
</main>
|
<meta property="og:title" content="Photos - András Schmelczer" />
|
||||||
</body>
|
<meta
|
||||||
</html>
|
property="og:description"
|
||||||
|
content="Portraits, landscapes, city scenes, and quiet details photographed by András Schmelczer."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="__OG_IMAGE_URL__" />
|
||||||
|
<meta property="og:image:alt" content="Photo portfolio preview" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Photos - András Schmelczer" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Portraits, landscapes, city scenes, and quiet details photographed by András Schmelczer."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="__OG_IMAGE_URL__" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<h1>András Schmelczer</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="gallery" class="gallery" aria-label="Photo gallery">
|
||||||
|
<section
|
||||||
|
id="portraits"
|
||||||
|
class="rail rail--portraits"
|
||||||
|
aria-label="Portrait photos"
|
||||||
|
>
|
||||||
|
<!-- gallery:portraits -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="frame" aria-live="polite">
|
||||||
|
<div id="frame-content" class="frame-content">
|
||||||
|
<!-- gallery:frame -->
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="slideshow-toggle"
|
||||||
|
class="slideshow-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="landscapes"
|
||||||
|
class="rail rail--landscapes"
|
||||||
|
aria-label="Landscape photos"
|
||||||
|
>
|
||||||
|
<!-- gallery:landscapes -->
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/index.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
||||||
440
src/index.scss
440
src/index.scss
|
|
@ -1,239 +1,273 @@
|
||||||
@mixin center-children() {
|
$breakpoint: 900px;
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin portrait() {
|
:root {
|
||||||
@media (max-width: 900px) {
|
--color-text: #fff;
|
||||||
@content;
|
--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,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: border-box;
|
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 {
|
html {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
overflow-x: hidden;
|
background: var(--color-background);
|
||||||
background-color: $background-color;
|
color: var(--color-ink);
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
min-height: 100dvh;
|
||||||
overflow: hidden;
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
@include portrait {
|
img {
|
||||||
height: 100%;
|
display: block;
|
||||||
flex-direction: column;
|
max-inline-size: 100%;
|
||||||
}
|
block-size: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@include landscape {
|
:focus {
|
||||||
&::-webkit-scrollbar-track,
|
outline: none;
|
||||||
&::-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;
|
|
||||||
|
|
||||||
@include landscape {
|
:focus-visible {
|
||||||
font-size: 3rem;
|
outline: 3px solid var(--color-focus);
|
||||||
}
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@include portrait {
|
.site-header {
|
||||||
font-size: 2rem;
|
display: grid;
|
||||||
}
|
place-items: center;
|
||||||
}
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: var(--space-m) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
.site-header h1 {
|
||||||
@include center-children;
|
margin: 0;
|
||||||
box-sizing: content-box;
|
font-size: clamp(1.5rem, 4vmin, 3rem);
|
||||||
height: 100%;
|
font-weight: 300;
|
||||||
position: relative;
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@include portrait {
|
.gallery {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
height: 2.15ch;
|
min-height: 100dvh;
|
||||||
padding: $normal-margin 0;
|
display: grid;
|
||||||
}
|
grid-template-columns: minmax(5rem, 10vmin) minmax(0, 1fr) minmax(
|
||||||
|
7rem,
|
||||||
|
14vmin
|
||||||
|
);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
@include landscape {
|
.rail {
|
||||||
width: 2.15ch;
|
min-height: 0;
|
||||||
height: 100vh;
|
max-height: 100dvh;
|
||||||
padding: 0 $normal-margin;
|
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 {
|
.thumbnail {
|
||||||
color: $text-color;
|
display: block;
|
||||||
background-color: $accent-color;
|
flex: 0 0 auto;
|
||||||
padding: $normal-margin 0;
|
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;
|
.thumbnail:hover,
|
||||||
user-select: none;
|
.thumbnail.is-selected {
|
||||||
z-index: 2;
|
border-color: var(--color-accent);
|
||||||
position: fixed;
|
}
|
||||||
|
|
||||||
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 {
|
.thumbnail--portrait {
|
||||||
width: 100%;
|
inline-size: 8vmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include landscape {
|
.thumbnail--landscape {
|
||||||
width: 101vh;
|
inline-size: 12vmin;
|
||||||
transform: rotate(-90deg);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main#images {
|
.thumbnail picture,
|
||||||
flex: 1;
|
.thumbnail img {
|
||||||
display: flex;
|
inline-size: 100%;
|
||||||
justify-content: space-between;
|
}
|
||||||
|
|
||||||
@include portrait {
|
.frame {
|
||||||
flex-direction: column;
|
min-width: 0;
|
||||||
}
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
padding: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
.cluster {
|
.frame-content {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
justify-content: space-between;
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
.frame-figure {
|
||||||
cursor: pointer;
|
min-width: 0;
|
||||||
}
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
&#portraits > img {
|
.frame-figure picture {
|
||||||
@include portrait {
|
display: grid;
|
||||||
height: 12vmin;
|
place-items: center;
|
||||||
width: auto;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@include landscape {
|
|
||||||
width: 8vmin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&#landscapes > img {
|
.frame-figure img {
|
||||||
@include portrait {
|
inline-size: auto;
|
||||||
height: 8vmin;
|
max-inline-size: 100%;
|
||||||
width: auto;
|
max-block-size: calc(100dvh - 5rem);
|
||||||
}
|
border: var(--border-width) solid var(--color-text);
|
||||||
@include landscape {
|
background: var(--color-text);
|
||||||
width: 12vmin;
|
object-fit: contain;
|
||||||
}
|
box-shadow: 0 0 0 1px rgb(32 33 36 / 16%);
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.frame-figure figcaption {
|
||||||
@include portrait {
|
max-inline-size: min(42rem, 100%);
|
||||||
margin: 0 $small-margin;
|
color: var(--color-ink);
|
||||||
}
|
font-size: 0.95rem;
|
||||||
}
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
@include portrait {
|
.slideshow-toggle {
|
||||||
width: 100%;
|
display: none;
|
||||||
overflow: auto;
|
justify-self: end;
|
||||||
margin: $small-margin 0;
|
margin-block-start: var(--space-s);
|
||||||
padding: $small-margin 0;
|
border: 0;
|
||||||
object-fit: contain;
|
border-radius: 4px;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
:first-child {
|
.js .slideshow-toggle {
|
||||||
margin-left: 2 * $small-margin;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
.slideshow-toggle:hover {
|
||||||
margin-right: 2 * $small-margin !important;
|
background: var(--color-accent-strong);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@include landscape {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
flex-direction: column;
|
*,
|
||||||
margin: $normal-margin;
|
*::before,
|
||||||
|
*::after {
|
||||||
img {
|
scroll-behavior: auto !important;
|
||||||
margin: $small-margin 0;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:first-child {
|
@media (width < $breakpoint) {
|
||||||
margin-top: 0;
|
body {
|
||||||
}
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
:last-child {
|
grid-template-columns: 1fr;
|
||||||
margin-bottom: 0;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
.site-header {
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
#frame-container {
|
}
|
||||||
@include center-children;
|
|
||||||
margin: 0 $border-width;
|
.site-header h1 {
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
flex: 1;
|
transform: none;
|
||||||
@include landscape {
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include portrait {
|
.gallery {
|
||||||
position: relative;
|
min-height: 0;
|
||||||
}
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
img {
|
}
|
||||||
height: auto;
|
|
||||||
width: auto;
|
.rail {
|
||||||
|
max-height: none;
|
||||||
top: 50%;
|
min-width: 0;
|
||||||
transform: translateY(-50%) translateX(-50%);
|
overflow: auto hidden;
|
||||||
|
flex-direction: row;
|
||||||
@include portrait {
|
justify-content: flex-start;
|
||||||
position: absolute;
|
padding: var(--space-xs) var(--space-s);
|
||||||
box-sizing: border-box;
|
}
|
||||||
max-width: 100% !important; // undo js
|
|
||||||
max-height: 100%;
|
.rail--portraits {
|
||||||
left: 50% !important; // undo js
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include landscape {
|
.rail--landscapes {
|
||||||
position: fixed;
|
grid-row: 3;
|
||||||
max-height: 90vh;
|
}
|
||||||
}
|
|
||||||
}
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
src/photo-catalog.json
Normal file
197
src/photo-catalog.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
28
src/photo-types.ts
Normal file
28
src/photo-types.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
129
src/photos.test.ts
Normal file
129
src/photos.test.ts
Normal file
|
|
@ -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 = `
|
||||||
|
<main id="gallery">
|
||||||
|
<a href="static/photos/one.jpg" data-photo-id="one">One</a>
|
||||||
|
<a href="static/photos/two.jpg" data-photo-id="two">Two</a>
|
||||||
|
</main>
|
||||||
|
<section id="frame"></section>
|
||||||
|
<button id="toggle" type="button">Play</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const gallery = document.querySelector<HTMLElement>('#gallery');
|
||||||
|
const frame = document.querySelector<HTMLElement>('#frame');
|
||||||
|
const toggle = document.querySelector<HTMLButtonElement>('#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<HTMLAnchorElement>('[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');
|
||||||
|
});
|
||||||
|
});
|
||||||
322
src/photos.ts
322
src/photos.ts
|
|
@ -1,109 +1,273 @@
|
||||||
import { ResponsiveImage } from './model/responsive-image';
|
import type { ImageSource, Photo } from './photo-types';
|
||||||
import { createImage } from './helper/create-image';
|
|
||||||
|
|
||||||
export class Photos {
|
const AUTO_ADVANCE_DELAY_MS = 7000;
|
||||||
private readonly images: Array<HTMLImageElement>;
|
const FRAME_SIZES =
|
||||||
private _selectedId: number;
|
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
|
||||||
private timerId: NodeJS.Timeout = null;
|
|
||||||
private shouldNotChange = false;
|
|
||||||
|
|
||||||
public constructor(
|
type GalleryOptions = {
|
||||||
images: Array<ResponsiveImage>,
|
readonly photos: readonly Photo[];
|
||||||
private readonly landscapesContainer: HTMLElement,
|
readonly gallery: HTMLElement;
|
||||||
private readonly portraitsContainer: HTMLElement,
|
readonly frame: HTMLElement;
|
||||||
private readonly pictureFrame: HTMLImageElement
|
readonly toggle: HTMLButtonElement;
|
||||||
) {
|
readonly autoAdvance?: boolean;
|
||||||
this.images = this.convertImages(images);
|
};
|
||||||
this.placeImages();
|
|
||||||
this.addEventListeners();
|
type SelectOptions = {
|
||||||
this.startAutoChange();
|
readonly focusThumbnail?: boolean;
|
||||||
this.selectedId = 0;
|
readonly pause?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requiredElement = <T extends Element>(
|
||||||
|
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 element;
|
||||||
return this._selectedId;
|
};
|
||||||
|
|
||||||
|
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) {
|
return ((value % count) + count) % count;
|
||||||
if (value < 0) {
|
};
|
||||||
value += this.images.length - 1;
|
|
||||||
|
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<ResponsiveImage>) {
|
this.thumbnails = this.options.photos.map((photo) =>
|
||||||
let portraits = 0; // evens
|
this.thumbnailFor(photo)
|
||||||
let landscapes = 1; // odds
|
|
||||||
|
|
||||||
const imgs = images.map((image, i) =>
|
|
||||||
createImage(
|
|
||||||
image,
|
|
||||||
'a photo',
|
|
||||||
Photos.isLandscape(image) ? (landscapes += 2) : (portraits += 2)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
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() {
|
private addEventListeners(): void {
|
||||||
this.images.forEach(img => {
|
this.options.gallery.addEventListener('click', (event) => {
|
||||||
if (Photos.isLandscape(img)) {
|
const thumbnail = (event.target as Element).closest<HTMLAnchorElement>(
|
||||||
img.classList.add('landscape');
|
'[data-photo-id]'
|
||||||
this.landscapesContainer.appendChild(img);
|
);
|
||||||
} else {
|
|
||||||
img.classList.add('portrait');
|
if (!thumbnail) {
|
||||||
this.portraitsContainer.appendChild(img);
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private addEventListeners() {
|
event.preventDefault();
|
||||||
window.document.addEventListener('keydown', e => {
|
this.selectById(thumbnail.dataset.photoId, { pause: true });
|
||||||
switch (e.key) {
|
});
|
||||||
|
|
||||||
|
this.options.gallery.addEventListener('keydown', (event) => {
|
||||||
|
if (!this.options.gallery.contains(event.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
this.selectedId--;
|
event.preventDefault();
|
||||||
|
this.select(this.selectedIndex - 1, {
|
||||||
|
focusThumbnail: true,
|
||||||
|
pause: true,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
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;
|
break;
|
||||||
case 'Tab':
|
|
||||||
if (this.timerId !== null) {
|
|
||||||
clearInterval(this.timerId);
|
|
||||||
this.timerId = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.document.addEventListener(
|
this.options.gallery.addEventListener('pointerdown', () =>
|
||||||
'scroll',
|
this.pauseAutoAdvance()
|
||||||
() => (this.shouldNotChange = true)
|
);
|
||||||
|
this.options.gallery.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
() => this.pauseAutoAdvance(),
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
this.options.gallery.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
() => this.pauseAutoAdvance(),
|
||||||
|
{ passive: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.images.forEach((image, i) =>
|
this.options.toggle.addEventListener('click', () => {
|
||||||
image.addEventListener('click', () => (this.selectedId = i))
|
if (this.timerId === null) {
|
||||||
);
|
this.startAutoAdvance();
|
||||||
}
|
} else {
|
||||||
|
this.pauseAutoAdvance();
|
||||||
private startAutoChange() {
|
|
||||||
this.timerId = setInterval(() => {
|
|
||||||
if (!this.shouldNotChange) {
|
|
||||||
this.selectedId++;
|
|
||||||
}
|
}
|
||||||
this.shouldNotChange = false;
|
});
|
||||||
}, 7000);
|
|
||||||
|
this.reducedMotion.addEventListener('change', () => {
|
||||||
|
if (this.reducedMotion.matches) {
|
||||||
|
this.pauseAutoAdvance();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadToFrame(img: HTMLImageElement) {
|
private thumbnailFor(photo: Photo): HTMLAnchorElement {
|
||||||
this.pictureFrame.src = img.src;
|
const thumbnail = this.options.gallery.querySelector<HTMLAnchorElement>(
|
||||||
|
`[data-photo-id="${CSS.escape(photo.id)}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!thumbnail) {
|
||||||
|
throw new Error(`Missing thumbnail for ${photo.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumbnail;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isLandscape(
|
private selectById(id: string | undefined, options: SelectOptions): void {
|
||||||
image: HTMLImageElement | ResponsiveImage
|
const index = this.options.photos.findIndex((photo) => photo.id === id);
|
||||||
): boolean {
|
|
||||||
return image.width > image.height;
|
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
stylelint.config.js
Normal file
9
stylelint.config.js
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue