個人開発の小さなサイトですが、作品の一覧から画像をタップすると、全画面で拡大して見られる表示を付けておりました。作品を見ていただく場所なので、拡大表示の操作感は後回しにできません。
見直してみると、直すべきところは一つではありませんでした。閉じるボタンが作品に重なること、背面がスクロールしてしまうこと、キーボードで操作できないことです。三つを順に直した記録と、その途中で「dialog 要素に載せ替えれば全部片づく」という予想が外れた箇所を、お伝えします。
三つの症状は、別々の原因でした
最初に整理したのは、症状と、疑っていた原因の組み合わせです。
| 症状 | 疑った原因 | 確認の方法 |
|---|---|---|
| Esc を押しても閉じない | キー入力を受ける処理がない | Esc を押して開閉の状態を見る |
| 開いたまま Tab を押すと、背面の部品に移る | 背面を操作対象から外していない | Tab を数回押して、フォーカスの行き先を記録する |
| 開いたまま画面をなぞると、背面が動く | 背面のスクロールを止めていない | ホイールを回して scrollY の変化を見る |
| 閉じるボタンが画像に乗る | ボタンを画像の上に重ねている | 画像とボタンの矩形が重なる面積を測る |
見た目の確認だけだと、四つ目以外は気づきにくいのが厄介でした。マウスで開閉している限り、キーボードの問題は表に出ません。
そこで、症状を目で探すのをやめて、数えることにしました。
古い作りを、手元の確認ページで数えました
実サイトの条件は持ち込まず、必要な部品だけに削った確認ページを二つ作りました。一つは古い作り、もう一つは直したあとの作りです。古い作りは、全画面の div を重ねて、その中に画像と閉じるボタンを置いただけです。
<!-- 古い作り: 全画面の div に画像と閉じるボタンを重ねるだけ -->
<div id="ov"><button id="close" aria-label="close">×</button><img id="big" alt=""></div>
<style>
#ov{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none}
#ov.open{display:block}
#ov img{position:absolute;inset:0;margin:auto;width:auto;height:auto;
max-width:100%;max-height:100%;object-fit:contain}
#close{position:absolute;top:12px;right:12px;width:44px;height:44px;z-index:2}
</style>縦長(800×2000)と横長(2400×900)の画像を、あえて一枚ずつ用意しています。普通の縦横比(2対3)の画像だと、画面の端まで届かないので、ボタンが重なりません。重なりは「画面いっぱいに広がる画像」でだけ起きます。ここで一度、確認用の画像を間違えていて、最初は重なりがゼロと出ました。
数えるのは、次のスクリプトです。Playwright の Chromium で動かし、見た目ではなく数字だけを出します。
// usage: node lightbox-check.js <file-or-url> <overlaySelector>
const { chromium } = require('playwright');
const path = require('path');
const VIEWPORTS = [
{ tag: 'phone-portrait', width: 390, height: 844 },
{ tag: 'phone-landscape', width: 844, height: 390 },
{ tag: 'desktop', width: 1280, height: 720 },
];
// 二つの矩形の重なり面積(px²)。0 なら重なっていません
const overlap = (a, b) => {
const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
return Math.round(Math.max(0, w) * Math.max(0, h));
};
(async () => {
const [target, overlay] = process.argv.slice(2);
const url = target.startsWith('http') ? target : 'file://' + path.resolve(target);
const browser = await chromium.launch();
for (const vp of VIEWPORTS) {
const page = await (await browser.newContext({ viewport: vp })).newPage();
await page.goto(url);
const thumbs = await page.locator('[data-full]').count();
const row = { viewport: vp.tag, worstOverlapPx2: 0 };
for (let i = 0; i < thumbs; i++) {
await page.evaluate((n) => document.querySelectorAll('[data-full]')[n].click(), i);
await page.waitForTimeout(120);
const img = await page.locator('#big').boundingBox();
const btn = await page.locator('#close').boundingBox();
row.worstOverlapPx2 = Math.max(row.worstOverlapPx2, overlap(img, btn));
if (i === thumbs - 2) {
// 開いている間: Tab を 4 回押して、フォーカスが重ね表示の内側にとどまるか
let inside = 0;
for (let k = 0; k < 4; k++) {
await page.keyboard.press('Tab');
inside += await page.evaluate((sel) => document.querySelector(sel).contains(document.activeElement) ? 1 : 0, overlay);
}
row.tabInsideOf4 = inside;
// 開いている間: ホイールで背面が動くか
await page.evaluate(() => scrollTo(0, 0));
await page.mouse.move(vp.width / 2, vp.height / 2);
await page.mouse.wheel(0, 600);
await page.waitForTimeout(150);
row.scrollWhileOpen = await page.evaluate(() => scrollY);
// Esc のあと: 閉じたか、背面は動くか
await page.evaluate(() => scrollTo(0, 0));
await page.keyboard.press('Escape');
await page.waitForTimeout(120);
row.closedByEsc = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el.tagName === 'DIALOG' ? !el.open : !el.classList.contains('open');
}, overlay);
await page.mouse.wheel(0, 600);
await page.waitForTimeout(150);
row.scrollAfterEsc = await page.evaluate(() => scrollY);
} else {
await page.evaluate((sel) => {
const el = document.querySelector(sel);
el.tagName === 'DIALOG' ? el.close() : el.classList.remove('open');
}, overlay);
}
}
console.log(JSON.stringify(row));
}
await browser.close();
})();何を数えるかを先に決めてあるので、確認用のページを差し替えるだけで、同じ指標を繰り返し取れます。i === thumbs - 2 の分岐は、最後の一枚(横長)を避けて、縦長の画像で状態を測るためのものです。
古い作りの結果は、次のとおりでした。
| 表示環境 | 重なり(最大) | Tab で内側にとどまった回数 | 開いている間の scrollY | Esc で閉じたか |
|---|---|---|---|---|
| スマホ縦(390×844) | 1311 px² | 4回中0回 | 600 | 閉じない |
| スマホ横(844×390) | 847 px² | 4回中0回 | 600 | 閉じない |
| デスクトップ(1280×720) | 0 px² | 4回中0回 | 600 | 閉じない |
デスクトップで重なりがゼロなのは、画像が画面の端まで届かないからです。重なりは、画面いっぱいに広がる細長い画像をスマホで開いたときにだけ出る、ということが数字で確かめられました。