mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
fix(web): 44px touch targets round 2: group archive button, board lane +/⋯, card tags (TASK-3311) (#1698)
* fix(web): 44px touch targets round 2: group archive button reachable on touch, board lane + and ⋯, card tags (TASK-3311) * fix(web): card tags are visibly 28 tall with a 16px row gap; the extender gives the 44 (TASK-3311, Dave's ruling)
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { browserLogin } from './lib/collab-helpers';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import type { SuiteFixture } from './fixtures';
|
||||
|
||||
/**
|
||||
* TASK-3311 — touch targets at phone width, round 2 (split out of TASK-2244):
|
||||
* 1. the list group header's archive button was 18x14 and opacity 0 until
|
||||
* hover, so on touch it could not be seen or reached;
|
||||
* 2. the board lane + and ⋯ were 32x32;
|
||||
* 3. the card tag buttons were small pills.
|
||||
* Scope as TASK-2244: <=768px reaches 44x44, desktop unchanged. Controls that
|
||||
* visibly grow are proved by boundingBox; invisible extenders by elementFromPoint,
|
||||
* from both sides (the area hits the control, and no neighbour control loses
|
||||
* area to it).
|
||||
*/
|
||||
|
||||
const MIN = 44;
|
||||
|
||||
function authHeaders(fixture: SuiteFixture) {
|
||||
return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** A collection of the test's own with two open items, the first carrying two tags. */
|
||||
async function seed(request: APIRequestContext, fixture: SuiteFixture, tag: string) {
|
||||
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
const schema = JSON.stringify({
|
||||
fields: [{ key: 'status', label: 'Status', type: 'select', options: ['open', 'done'], default: 'open', terminal_options: ['done'] }]
|
||||
});
|
||||
const coll = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
|
||||
headers: authHeaders(fixture),
|
||||
data: { name: `T3311 ${tag} ${stamp}`, prefix: `TR${stamp.slice(-5)}`, schema }
|
||||
});
|
||||
expect(coll.ok(), await coll.text()).toBeTruthy();
|
||||
const collSlug = (await coll.json()).slug as string;
|
||||
const titles = ['a', 'b'].map((x) => `T3311 ${tag} ${x} ${stamp}`);
|
||||
for (const [i, title] of titles.entries()) {
|
||||
const res = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`, {
|
||||
headers: authHeaders(fixture),
|
||||
data: {
|
||||
title,
|
||||
fields: JSON.stringify({ status: 'open' }),
|
||||
content: '',
|
||||
...(i === 0 ? { tags: JSON.stringify(['t3311a', 't3311b']) } : {})
|
||||
}
|
||||
});
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
}
|
||||
return { collSlug, titles };
|
||||
}
|
||||
|
||||
type Box = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/** Sample `box` every 4px; `want` says whether a hit on `target` is sought. */
|
||||
async function probe(target: Locator, box: Box, want: 'hits' | 'avoids') {
|
||||
return target.evaluate(
|
||||
(el, [b, mode]) => {
|
||||
const bad: string[] = [];
|
||||
for (let x = b.x + 0.5; x < b.x + b.w; x += 4) {
|
||||
for (let y = b.y + 0.5; y < b.y + b.h; y += 4) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const onEl = !!hit && (hit === el || el.contains(hit));
|
||||
if (onEl !== (mode === 'hits')) bad.push(`${Math.round(x)},${Math.round(y)}→${hit?.className || hit?.tagName}`);
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
},
|
||||
[box, want] as const
|
||||
);
|
||||
}
|
||||
|
||||
async function open(page: Page, fixture: SuiteFixture, collSlug: string, view: 'list' | 'board') {
|
||||
await browserLogin(page);
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/${collSlug}?view=${view}`);
|
||||
}
|
||||
|
||||
// ── 1. the list group header's archive button ─────────────────────────────
|
||||
|
||||
test('TASK-3311: on touch the group archive button is visible and tappable across 44x44', async ({
|
||||
page,
|
||||
fixture,
|
||||
request
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile-chromium', 'the ruling is <=768px on touch');
|
||||
const { collSlug, titles } = await seed(request, fixture, 'archive');
|
||||
await open(page, fixture, collSlug, 'list');
|
||||
const btn = page.locator('.archive-group-btn').first();
|
||||
await expect(btn).toHaveCount(1);
|
||||
const opacity = await btn.evaluate((el) => Number(getComputedStyle(el).opacity));
|
||||
expect(opacity, 'hidden until a hover a touch device cannot give').toBeGreaterThan(0.5);
|
||||
await expect(btn).toHaveAttribute('aria-label', /^Archive all /);
|
||||
|
||||
const b = (await btn.boundingBox())!;
|
||||
const area = { x: b.x + b.width / 2 - MIN / 2, y: b.y + b.height / 2 - MIN / 2, w: MIN, h: MIN };
|
||||
expect(await probe(btn, area, 'hits'), 'points in the 44x44 area that miss the archive button').toEqual([]);
|
||||
|
||||
// The first row under the header: none of its controls may lose area to it.
|
||||
const row = page.locator('.list-row, .item-row, .list-item').filter({ hasText: titles[1] }).first();
|
||||
if ((await row.count()) > 0) {
|
||||
for (const c of await row.locator('button, a').all()) {
|
||||
const cb = await c.boundingBox();
|
||||
if (!cb) continue;
|
||||
expect(await probe(btn, { x: cb.x, y: cb.y, w: cb.width, h: cb.height }, 'avoids'), 'row control points on the archive button').toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2. the board lane + and ⋯ ─────────────────────────────────────────────
|
||||
|
||||
test('TASK-3311: the board lane + and ⋯ are at least 44x44 on mobile', async ({ page, fixture, request }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile-chromium', 'the ruling is <=768px');
|
||||
const { collSlug } = await seed(request, fixture, 'lane');
|
||||
await open(page, fixture, collSlug, 'board');
|
||||
for (const [name, sel] of [
|
||||
['lane +', '.lane-add-btn'],
|
||||
['lane ⋯', '.lane-menu-btn']
|
||||
] as const) {
|
||||
const btn = page.locator(sel).first();
|
||||
await expect(btn, `${name} is rendered`).toBeVisible();
|
||||
const b = (await btn.boundingBox())!;
|
||||
expect.soft(b.width, `${name} width`).toBeGreaterThanOrEqual(MIN);
|
||||
expect.soft(b.height, `${name} height`).toBeGreaterThanOrEqual(MIN);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 3. the card tags ──────────────────────────────────────────────────────
|
||||
|
||||
for (const view of ['list', 'board'] as const) {
|
||||
test(`TASK-3311: each card tag is tappable across 44x44, and no tag or the status chip loses area (${view})`, async ({
|
||||
page,
|
||||
fixture,
|
||||
request
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile-chromium', 'the ruling is <=768px');
|
||||
const { collSlug, titles } = await seed(request, fixture, `tags-${view}`);
|
||||
await open(page, fixture, collSlug, view);
|
||||
const c = page.locator('.item-card').filter({ has: page.locator('.card-title', { hasText: titles[0] }) });
|
||||
await expect(c).toBeVisible();
|
||||
const tags = c.locator('.card-tag');
|
||||
await expect(tags).toHaveCount(2);
|
||||
const chip = c.locator('.card-meta button', { hasText: /^\s*open\s*$/i });
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const tag = tags.nth(i);
|
||||
const b = (await tag.boundingBox())!;
|
||||
expect.soft(b.width, `tag ${i} width`).toBeGreaterThanOrEqual(MIN);
|
||||
const area = { x: b.x, y: b.y + b.height / 2 - MIN / 2, w: b.width, h: MIN };
|
||||
expect(await probe(tag, area, 'hits'), `points of tag ${i}'s 44-tall area that miss it`).toEqual([]);
|
||||
// Neither the other tag nor the status chip may lose area to this tag.
|
||||
const others: Array<[string, Locator]> = [
|
||||
[`tag ${1 - i}`, tags.nth(1 - i)],
|
||||
['status chip', chip]
|
||||
];
|
||||
for (const [name, n] of others) {
|
||||
if ((await n.count()) === 0) continue;
|
||||
const nb = (await n.boundingBox())!;
|
||||
expect(await probe(tag, { x: nb.x, y: nb.y, w: nb.width, h: nb.height }, 'avoids'), `points of ${name} that land on tag ${i}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── desktop is unchanged ──────────────────────────────────────────────────
|
||||
|
||||
test('TASK-3311: desktop lane buttons, archive button and tags are unchanged', async ({ page, fixture, request }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop');
|
||||
const { collSlug, titles } = await seed(request, fixture, 'desktop');
|
||||
await open(page, fixture, collSlug, 'board');
|
||||
const lane = (await page.locator('.lane-add-btn').first().boundingBox())!;
|
||||
expect(lane.height, 'desktop lane + stays 28 tall').toBe(28);
|
||||
const tag = (await page.locator('.item-card').filter({ has: page.locator('.card-title', { hasText: titles[0] }) }).locator('.card-tag').first().boundingBox())!;
|
||||
expect(tag.height, 'desktop tag keeps its pill height').toBeLessThan(28);
|
||||
|
||||
await open(page, fixture, collSlug, 'list');
|
||||
const btn = page.locator('.archive-group-btn').first();
|
||||
await expect(btn).toHaveCount(1);
|
||||
// A mouse device still reveals it on hover only.
|
||||
expect(await btn.evaluate((el) => Number(getComputedStyle(el).opacity))).toBe(0);
|
||||
});
|
||||
@@ -1246,10 +1246,13 @@
|
||||
/* The lane menu panel + its drill-down styles live in
|
||||
LaneActionsMenu.svelte (TASK-1672). */
|
||||
|
||||
/* Phone width: 44x44 touch targets (TASK-3311; TASK-2244 took the strip
|
||||
and cards to the same size). They grow visibly: the lane header has the
|
||||
room, and a visible target is one a thumb can aim at. */
|
||||
@media (max-width: 768px) {
|
||||
.lane-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@
|
||||
<div class="card-tags">
|
||||
{#each tags as tag, i (i)}
|
||||
<button type="button" class="card-tag" onclick={(e) => openTag(e, tag)} title="View items tagged “{tag}”">
|
||||
{tag}
|
||||
<span class="card-tag-text">{tag}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -687,6 +687,12 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
/* The ellipsis clips the TEXT, not the button: a clipping button would clip
|
||||
its own phone-width touch extender (TASK-3311). */
|
||||
.card-tag-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -696,6 +702,33 @@
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* Phone width (TASK-3311): each tag is a 44x44 touch target. It grows
|
||||
visibly to 44 wide and 28 tall (Dave's ruling: not bigger pills), and an
|
||||
invisible extender adds the last 16px, 8 above and 8 below. That fills the
|
||||
16px gap between wrapped rows and the card's 8px gap above the row (the
|
||||
status chip's extender grows UP, away from the tags), so no neighbouring
|
||||
control loses area to it. */
|
||||
@media (max-width: 768px) {
|
||||
.card-tags {
|
||||
row-gap: 16px;
|
||||
}
|
||||
.card-tag {
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.card-tag::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 44px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.card-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -539,6 +539,7 @@
|
||||
<button
|
||||
class="archive-group-btn"
|
||||
title="Archive all {formatLaneLabel(groupName).toLowerCase()} items"
|
||||
aria-label="Archive all {formatLaneLabel(groupName).toLowerCase()} items"
|
||||
onclick={(e) => { e.stopPropagation(); confirmArchiveGroup = groupName; }}
|
||||
>🗃</button>
|
||||
{/if}
|
||||
@@ -694,6 +695,35 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Hover-revealed, so a keyboard user and a touch user need their own way
|
||||
to see it (TASK-3311): focus reveals it everywhere, and a device with no
|
||||
hover shows it at the resting opacity the card's copy button uses. */
|
||||
.archive-group-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.archive-group-btn {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
/* Phone width: a 44x44 touch target through an invisible extender, so the
|
||||
group header keeps its height (TASK-3311). The header's other contents
|
||||
are the label and the count, which are not controls. */
|
||||
@media (max-width: 768px) {
|
||||
.archive-group-btn {
|
||||
position: relative;
|
||||
}
|
||||
.archive-group-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.archive-group-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
|
||||
Reference in New Issue
Block a user