Fix scrollbars and enter to submit
All checks were successful
CI / Frontend lint (push) Successful in 24s
CI / Frontend unit tests (push) Successful in 24s
CI / Backend tests (push) Successful in 25s
CI / Frontend build (push) Successful in 24s
Docker / build-and-push (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 51s
All checks were successful
CI / Frontend lint (push) Successful in 24s
CI / Frontend unit tests (push) Successful in 24s
CI / Backend tests (push) Successful in 25s
CI / Frontend build (push) Successful in 24s
Docker / build-and-push (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 51s
This commit is contained in:
parent
688bc0cfe9
commit
948b49bb49
4 changed files with 143 additions and 8 deletions
109
frontend/e2e/tasks-overflow.spec.ts
Normal file
109
frontend/e2e/tasks-overflow.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guard for the tasks accordion ("todos") double-scrollbar bug.
|
||||||
|
*
|
||||||
|
* When the pending-task list is tall it must show EXACTLY ONE scrollbar (inside
|
||||||
|
* the white card), not two. The cause was two nested scroll containers both
|
||||||
|
* firing: the `lt-tasks` host (overflow:auto) AND the inner `.container` card
|
||||||
|
* (overflow-y:auto). The fix makes the host a height-bounding flex column that
|
||||||
|
* only CLIPS, leaving the inner card as the sole scroller.
|
||||||
|
*
|
||||||
|
* Seeds many pending tasks via a direct PUT (far more robust than driving the
|
||||||
|
* carousel ~18 times), then reloads so the store renders the seeded tree. All
|
||||||
|
* ids are generated here in Node — `crypto.randomUUID()` throws in the page
|
||||||
|
* because the dev origin is plain HTTP (not a secure context).
|
||||||
|
*/
|
||||||
|
test('tasks accordion with many tasks shows a single scrollbar', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// init() mints + registers a token on load; wait for it to land.
|
||||||
|
await page.waitForFunction(() => !!localStorage.getItem('life-towers.token.v4'), null, {
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a tree: one page, one tower, many PENDING (is_done:false) tasks.
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const tree = {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
name: 'Hobbies',
|
||||||
|
hide_create_tower_button: false,
|
||||||
|
keep_tasks_open: false,
|
||||||
|
default_date_from: null,
|
||||||
|
default_date_to: null,
|
||||||
|
towers: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
name: 'Reading',
|
||||||
|
base_color: { h: 0.92, s: 0.7, l: 0.55 },
|
||||||
|
blocks: Array.from({ length: 18 }, (_, i) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
tag: 'novel',
|
||||||
|
description: `Pending task ${i + 1} — read another chapter tonight`,
|
||||||
|
is_done: false,
|
||||||
|
difficulty: 1,
|
||||||
|
created_at: now - i * 3600,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// PUT the tree (unguarded — no If-Match), then reload to render it.
|
||||||
|
const status = await page.evaluate(async (body) => {
|
||||||
|
const res = await fetch('api/v1/data', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('life-towers.token.v4')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
}, tree);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('lt-tower', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Open the accordion (collapsed by default since keep_tasks_open is false).
|
||||||
|
await page.locator('lt-tasks .header').first().click();
|
||||||
|
await page.waitForTimeout(400); // expand animation (200ms) + buffer
|
||||||
|
|
||||||
|
// Measure the scroll topology in the accordion subtree.
|
||||||
|
const m = await page.locator('lt-tasks').first().evaluate((host) => {
|
||||||
|
const card = host.querySelector('.container') as HTMLElement;
|
||||||
|
const cs = (el: Element) => getComputedStyle(el);
|
||||||
|
const overflows = (el: HTMLElement) => el.scrollHeight > el.clientHeight + 1;
|
||||||
|
return {
|
||||||
|
hostOverflowY: cs(host).overflowY,
|
||||||
|
cardOverflowY: cs(card).overflowY,
|
||||||
|
cardOverflows: overflows(card),
|
||||||
|
hostHasOwnOverflow: host.scrollHeight > host.clientHeight + 1,
|
||||||
|
hostHeight: host.getBoundingClientRect().height,
|
||||||
|
cardHeight: card.getBoundingClientRect().height,
|
||||||
|
viewport30vh: window.innerHeight * 0.3,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The list must actually overflow, otherwise the test proves nothing.
|
||||||
|
expect(m.cardOverflows).toBe(true);
|
||||||
|
|
||||||
|
// Exactly one scroller: the inner card. The host only clips.
|
||||||
|
expect(m.hostOverflowY).toBe('hidden');
|
||||||
|
expect(m.cardOverflowY).toBe('auto');
|
||||||
|
// The host has no overflowing content of its own → no second scrollbar.
|
||||||
|
expect(m.hostHasOwnOverflow).toBe(false);
|
||||||
|
// The card shrank to fit within the host's bound (not clipped past it).
|
||||||
|
expect(m.cardHeight).toBeLessThanOrEqual(m.hostHeight + 1);
|
||||||
|
// Host height is capped by min(30vh, 45%) → at most ~30vh.
|
||||||
|
expect(m.hostHeight).toBeLessThanOrEqual(m.viewport30vh + 2);
|
||||||
|
|
||||||
|
await page.locator('lt-tasks .container').first().screenshot({
|
||||||
|
path: 'visuals/04d-tasks-accordion-overflow-single-scrollbar.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -187,6 +187,7 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
||||||
maxlength="10000"
|
maxlength="10000"
|
||||||
[value]="newValue().description"
|
[value]="newValue().description"
|
||||||
(input)="updateNewDescription($any($event.target).value)"
|
(input)="updateNewDescription($any($event.target).value)"
|
||||||
|
(keydown.enter)="onNewDescriptionEnter($event)"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<label class="done-checkbox">
|
<label class="done-checkbox">
|
||||||
|
|
@ -414,6 +415,13 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
// The global reset (styles.scss) zeroes padding, so the focus outline
|
||||||
|
// hugs the text. Re-pad so the outline clears the description text.
|
||||||
|
// box-sizing: border-box (forms.scss) keeps the outer size unchanged.
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.done-checkbox {
|
.done-checkbox {
|
||||||
@include medium-text();
|
@include medium-text();
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -779,6 +787,18 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare Enter in the create-card description submits the new task and exits.
|
||||||
|
* Angular's `keydown.enter` pseudo-event matches *only* unmodified Enter, so
|
||||||
|
* Ctrl+Enter / Shift+Enter never reach here — they fall through to the
|
||||||
|
* textarea's default behaviour and insert a newline.
|
||||||
|
*/
|
||||||
|
onNewDescriptionEnter(event: Event): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.submitNew();
|
||||||
|
}
|
||||||
|
|
||||||
submitNew(): void {
|
submitNew(): void {
|
||||||
const v = this.newValue();
|
const v = this.newValue();
|
||||||
if (!v.tag) return;
|
if (!v.tag) return;
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,12 @@ export function taskListMaxHeight(expanded: boolean): string {
|
||||||
padding: calc(var(--small-padding) / 2);
|
padding: calc(var(--small-padding) / 2);
|
||||||
margin: calc(var(--small-padding) / 2);
|
margin: calc(var(--small-padding) / 2);
|
||||||
|
|
||||||
max-height: 30vh;
|
// Height is bounded by the host (lt-tasks) flex column, which clips but
|
||||||
|
// does not scroll. As the sole scroller, this card shrinks to that
|
||||||
|
// bound (min-height: 0) and scrolls a tall list inside itself — one
|
||||||
|
// scrollbar, sitting within the white card.
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
|
||||||
|
|
@ -339,19 +339,20 @@ export function selectVisibleStyledBlocks(
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
max-height: min(30vh, 45%);
|
max-height: min(30vh, 45%);
|
||||||
overflow: auto;
|
// The host only bounds the accordion's height and CLIPS — it must
|
||||||
display: block;
|
// not scroll. Scrolling lives solely on the inner card
|
||||||
|
// (tasks.component .container), so a tall task list shows ONE
|
||||||
|
// scrollbar (inside the card), not two. Flex column + the card's
|
||||||
|
// min-height: 0 lets the card shrink to this bound and scroll.
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
max-height: min(25vh, 45%);
|
max-height: min(25vh, 45%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
|
||||||
max-height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-zone {
|
.stack-zone {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue