This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 狭い画面で、キーボードの焦点が帯の外にある操作子を引き込むこと(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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user