sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+281
View File
@@ -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();
});
});