/** * 狭い画面で、キーボードの焦点が帯の外にある操作子を引き込むこと(issue #1379)。 * * ワークスペースの帯の右側(名前・鉛筆・顔・ゴミ箱)は横に流れる箱に入っている。 * 指やマウスなら横になぞって出せるが、**焦点を移しただけでは箱が動かず**、削除 * ボタンが帯からはみ出したまま押される画面があった(430 ピクセル前後。はみ出し * 始める幅は文字の計量で機械ごとに前後する)。 * * ここで見るのは 3 つ。 * 1. Tab で焦点を移したら、**手で目一杯なぞったときと同じところまで**見える * 2. 縦は動かない(`block: 'nearest'`。外すとページ全体が縦に飛ぶ) * 3. **マウスで押したときは箱が動かない**(押した瞬間にずれると押し間違える) * * **「割合 1.00」を基準にしない。**流れる箱の内側の幅が削除ボタンより狭い画面が * あり(実測で箱 27px / ボタン 28px)、そこでは指でどれだけなぞっても 1 にならない。 * 中身の幅はフォントの計量で機械ごとに変わるので、絶対値を焼き込むと別の機械で * 嘘になる。基準は**利用者が手で到達できるいちばん良い位置**に取る。 * * jsdom は幅を測れないので、1 と 3 の「本当に見えているか」は実機でしか分からない。 * 配線(帯の行に処理が付いているか)は SpaceDetail.focusScroll.test.tsx が見る。 */ import { test, expect, type Page } from '@playwright/test'; /** 案件ワークスペースを 1 つ作って開く(削除ボタンは案件ワークスペースにだけ出る)。 */ async function createAndOpenCaseSpace(page: Page): Promise { await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const title = `帯の焦点-${Date.now()}`; await titleInput.fill(title); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const row = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: title }); await expect(row).toBeVisible(); await row.click(); await expect(page.getByTestId('space-detail')).toBeVisible(); } /** 幅を変えたら 1 フレーム置いてから測る(space-header-badge.spec.ts と同じ理由)。 */ async function resizeAndSettle(page: Page, width: number): Promise { await page.setViewportSize({ width, height: 720 }); await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); } interface Measured { /** 削除ボタンのうち、流れる箱の中に見えている割合。1 = 全部見えている。 */ ratio: number; /** 横に流れる箱をどれだけなぞった状態か。 */ scrollLeft: number; /** これ以上なぞれない位置(`scrollWidth - clientWidth`)。 */ maxScrollLeft: number; /** 帯の上端(縦に飛んでいないかを見る)。 */ rowTop: number; /** ページ全体の縦位置。 */ pageScrollTop: number; /** いま焦点があるのが削除ボタンか。 */ deleteFocused: boolean; /** 削除ボタンの右端(はみ出し量を報告に書くため)。 */ right: number; } async function measure(page: Page): Promise { return page.evaluate(() => { const btn = document.querySelector('[data-testid="space-delete"]'); if (!btn) throw new Error('削除ボタンが出ていない'); const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement; const row = box.parentElement!; const rr = row.getBoundingClientRect(); // 切り取られるのは**流れる箱の縁**であって、帯の行の縁ではない(行には右余白が // あるので、行で測ると数ピクセルぶん甘く出る)。 const cr = box.getBoundingClientRect(); const br = btn.getBoundingClientRect(); const seen = Math.max(0, Math.min(br.right, cr.right) - Math.max(br.left, cr.left)); return { ratio: Number((seen / br.width).toFixed(3)), scrollLeft: Number(box.scrollLeft.toFixed(1)), maxScrollLeft: Number((box.scrollWidth - box.clientWidth).toFixed(1)), rowTop: Number(rr.top.toFixed(1)), pageScrollTop: Number(document.scrollingElement!.scrollTop.toFixed(1)), deleteFocused: document.activeElement === btn, right: Number(br.right.toFixed(1)), }; }); } /** なぞった位置を 0 に戻す(前の幅で引き込んだ分を持ち越さないため)。 */ async function resetScroll(page: Page): Promise { await page.evaluate(() => { (document.querySelector('[data-testid="space-header-meta"]') as HTMLElement).scrollLeft = 0; }); } /** * 手で目一杯なぞったときに、削除ボタンがどこまで見えるかを測る。 * * これが**利用者に到達できるいちばん良い位置**であり、引き込みの合否はここを基準に * 判定する。絶対値 1.00 は基準にできない(冒頭の説明を参照)。 */ async function measureManualBest(page: Page): Promise { await page.evaluate(() => { const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement; box.scrollLeft = box.scrollWidth; }); return measure(page); } /** * 本物の Tab で削除ボタンまで進む。 * * **`locator.focus()` を使わない。**あれは押鍵を発生させないので、 * 「キーボードのときだけ引き込む」判定を通り抜けてしまい、判定が壊れていても * 緑のままになる。 */ async function tabToDeleteButton(page: Page): Promise { for (let i = 1; i <= 40; i++) { await page.keyboard.press('Tab'); const onDelete = await page.evaluate( () => document.activeElement?.getAttribute('data-testid') === 'space-delete', ); if (onDelete) return i; } throw new Error('Tab を 40 回押しても削除ボタンに届かなかった'); } /** 帯の中の、押しても画面が変わらないところへ焦点を戻す(Tab の出発点)。 */ async function resetFocusToTabStrip(page: Page): Promise { await page.getByTestId('space-tab-chat').click(); } const WIDTHS = [429, 430, 435, 439, 445]; /** * 測り方の丸め(`toFixed(3)`)とサブピクセルのぶんの遊び。 * 引き込みが手なぞりと同じ位置に着いても、割合が 0.001 単位でずれることがある。 */ const RATIO_EPSILON = 0.01; /** * 削除ボタンが実際にはみ出す幅を 1 つ選ぶ。 * * **430 を決め打ちにしない。**はみ出し始める幅は文字の計量で機械ごとに動くので、 * 決め打ちにすると別の機械では「はみ出していないところ」を測って空振りする。 */ async function findOverflowWidth(page: Page): Promise { for (const width of WIDTHS) { await resizeAndSettle(page, width); await resetFocusToTabStrip(page); await resetScroll(page); const m = await measure(page); if (m.ratio < 1) return width; } throw new Error('どの幅でも削除ボタンがはみ出さない(この網の前提が崩れている)'); } test.describe('ワークスペースの帯: キーボードの焦点は画面外の操作子を引き込む', () => { test('Tab で削除ボタンへ移ると、手でなぞれるところまで引き込む(縦は動かない)', async ({ page }) => { await createAndOpenCaseSpace(page); for (const width of WIDTHS) { await resizeAndSettle(page, width); await resetFocusToTabStrip(page); await resetScroll(page); const before = await measure(page); // 手で目一杯なぞった結果を先に測る。これが合否の基準。 const manual = await measureManualBest(page); await resetScroll(page); await tabToDeleteButton(page); const after = await measure(page); // eslint-disable-next-line no-console -- 実測値を報告に残す console.log( `[#1379] ${width}px 焦点前 割合=${before.ratio} なぞり=${before.scrollLeft} 右端=${before.right}` + ` / 手なぞり最大 割合=${manual.ratio} なぞり=${manual.scrollLeft}(上限=${manual.maxScrollLeft})` + ` / 焦点後 割合=${after.ratio} なぞり=${after.scrollLeft} 右端=${after.right}`, ); expect(after.deleteFocused, `${width}px で削除ボタンに焦点が来ていない`).toBe(true); expect( after.ratio, `${width}px で、手でなぞれるところまで引き込めていない(手なぞり最大 ${manual.ratio})`, ).toBeGreaterThanOrEqual(Math.min(1, manual.ratio) - RATIO_EPSILON); // 縦は 1 ピクセルも動かさない。 expect(after.rowTop, `${width}px で帯が縦に動いた`).toBe(before.rowTop); expect(after.pageScrollTop, `${width}px でページが縦に飛んだ`).toBe(before.pageScrollTop); // はみ出していた幅では、実際に引き込んでいること(網が空振りしていない証拠)。 if (before.ratio < 1) { expect(after.scrollLeft, `${width}px ではみ出していたのに箱が動いていない`).toBeGreaterThan( before.scrollLeft, ); } } }); test('はみ出す幅が実在する(この網が空振りしていないこと)', async ({ page }) => { // **この網は引き込みの仕組みを判定していない。**焦点を 1 度も動かさないので、 // 引き込みを丸ごと外しても緑のまま。ここで確かめるのは上の網の前提—— // 「焦点を当てなければ実際にはみ出す幅がある」——だけ。 await createAndOpenCaseSpace(page); const overflowed: number[] = []; for (const width of WIDTHS) { await resizeAndSettle(page, width); await resetFocusToTabStrip(page); await resetScroll(page); const before = await measure(page); if (before.ratio < 1) overflowed.push(width); } // eslint-disable-next-line no-console -- 実測値を報告に残す console.log(`[#1379] 焦点なしではみ出す幅: ${overflowed.join(', ') || 'なし'}`); expect(overflowed.length, 'どの幅でもはみ出さないなら、上の網は何も守っていない').toBeGreaterThan(0); }); test('同じ場面で、キーボードなら動きマウスなら動かない', async ({ page }) => { await createAndOpenCaseSpace(page); // はみ出す幅を実測で選ぶ(決め打ちにすると別の機械で空振りする)。 const width = await findOverflowWidth(page); // --- キーボードの側。ここが無いと「いつでも動かない」と区別が付かない --- // マウス側だけを見る網は、引き込みを丸ごと外しても緑になる(動かないのが // 当たり前だから)。同じ幅・同じボタンで「押鍵なら動く」を対にして測る。 await resetFocusToTabStrip(page); await resetScroll(page); const beforeKey = await measure(page); await tabToDeleteButton(page); const afterKey = await measure(page); // --- マウスの側 --- await resetFocusToTabStrip(page); await resetScroll(page); const beforeMouse = await measure(page); // **`locator.click()` を使わない。**Playwright は押す前に要素を画面内へ // 引き込むので、こちらの処理が何もしなくても箱が動いてしまう。見えている // ところを座標で押す(=利用者が実際にできること)。 const spot = await page.evaluate(() => { const btn = document.querySelector('[data-testid="space-delete"]')!.getBoundingClientRect(); const box = document .querySelector('[data-testid="space-header-meta"]')! .getBoundingClientRect(); const right = Math.min(btn.right, box.right); return { x: (btn.left + right) / 2, y: btn.top + btn.height / 2 }; }); await page.mouse.move(spot.x, spot.y); await page.mouse.down(); const onPress = await measure(page); await page.mouse.up(); // eslint-disable-next-line no-console -- 実測値を報告に残す console.log( `[#1379] ${width}px キーボード なぞり=${beforeKey.scrollLeft}→${afterKey.scrollLeft}` + ` / マウス なぞり=${beforeMouse.scrollLeft}→${onPress.scrollLeft} 焦点=${onPress.deleteFocused}`, ); expect(afterKey.deleteFocused, 'Tab で削除ボタンに焦点が来ていない').toBe(true); expect( afterKey.scrollLeft, 'キーボードでも箱が動いていない(引き込みが効いていない)', ).toBeGreaterThan(beforeKey.scrollLeft); expect(onPress.deleteFocused, 'マウスで押しても焦点が来ていない(この網は空振り)').toBe(true); expect(onPress.scrollLeft, 'マウスで押した瞬間に箱が動いた').toBe(beforeMouse.scrollLeft); // 押せていること(見えている端を押しただけで確認の段に進む)。 await expect(page.getByTestId('space-delete-confirm')).toBeVisible(); }); });