282 lines
13 KiB
TypeScript
282 lines
13 KiB
TypeScript
/**
|
||
* 狭い画面で、キーボードの焦点が帯の外にある操作子を引き込むこと(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<void> {
|
||
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<void> {
|
||
await page.setViewportSize({ width, height: 720 });
|
||
await page.evaluate(
|
||
() =>
|
||
new Promise<void>((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<Measured> {
|
||
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<void> {
|
||
await page.evaluate(() => {
|
||
(document.querySelector('[data-testid="space-header-meta"]') as HTMLElement).scrollLeft = 0;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 手で目一杯なぞったときに、削除ボタンがどこまで見えるかを測る。
|
||
*
|
||
* これが**利用者に到達できるいちばん良い位置**であり、引き込みの合否はここを基準に
|
||
* 判定する。絶対値 1.00 は基準にできない(冒頭の説明を参照)。
|
||
*/
|
||
async function measureManualBest(page: Page): Promise<Measured> {
|
||
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<number> {
|
||
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<void> {
|
||
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<number> {
|
||
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();
|
||
});
|
||
});
|