super-productivity/e2e/tests/task-basic/subtask-inline-input.spec.ts
Johannes Millan 6236dc7458
fix(tasks): add sub-task submit button and commit-on-blur on touch (#8860)
Android/mobile soft keyboards hide the Enter key or deliver it as a composing
keydown that onKeydown filters out (to protect CJK candidate confirmation), so
the inline sub-task draft could not be committed at all: neither Enter, tapping
away, nor any on-screen control saved the text.

- Add an always-visible submit button beside the input as an accessible,
  discoverable commit target (mouse-clickable, screen-reader labelled). Its
  mousedown is preventDefaulted so a desktop click keeps input focus, commits,
  and keeps the field open for rapid entry — mirroring the main add-task bar.
  On touch the tap commits via the blur path below.
- Commit the draft on blur, but only on touch (isTouchActive): the natural
  mobile "done" gesture is tapping away, and the soft-keyboard Enter is
  unreliable there (#8791). Desktop keeps click-away-to-cancel — Enter and the
  button are the reliable commit paths — so blur no longer silently creates a
  task, and a user can move to the button without the draft being committed out
  from under them. Escape still discards on all devices.

Both commit paths read the live input value, so IME/predictive-text buffering
no longer strands the text.

Closes #8856. Supersedes the standalone #8791 commit-on-blur branch.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:16:42 +02:00

137 lines
4.8 KiB
TypeScript

import { test, expect } from '../../fixtures/test.fixture';
test.describe('Subtask inline input', () => {
test('keeps the draft input focused for rapid subtask creation', async ({
page,
workViewPage,
taskPage,
}) => {
await workViewPage.waitForTaskList();
await workViewPage.addTask('Parent task');
const parentTask = taskPage.getTaskByText('Parent task');
await expect(parentTask).toBeVisible();
await parentTask.focus();
await page.keyboard.press('a');
const draftInput = parentTask.locator('.e2e-add-subtask-input');
await expect(draftInput).toBeVisible();
await expect(draftInput).toBeFocused();
await draftInput.fill('1 subtask');
await page.keyboard.press('Enter');
await expect(parentTask.locator('.sub-tasks task')).toHaveCount(1);
await expect(parentTask.locator('.sub-tasks task task-title')).toContainText([
'1 subtask',
]);
await expect(draftInput).toBeVisible();
await expect(draftInput).toBeFocused();
await expect(draftInput).toHaveValue('');
await draftInput.fill('2 subtask');
await page.keyboard.press('Enter');
await expect(parentTask.locator('.sub-tasks task')).toHaveCount(2);
await expect(parentTask.locator('.sub-tasks task task-title')).toContainText([
'1 subtask',
'2 subtask',
]);
await expect(draftInput).toBeFocused();
await page.keyboard.press('Escape');
await expect(parentTask.locator('.e2e-add-subtask-input')).toHaveCount(0);
await expect(parentTask.locator('.sub-tasks task')).toHaveCount(2);
});
test('cancels a typed draft subtask on Escape', async ({
page,
workViewPage,
taskPage,
}) => {
await workViewPage.waitForTaskList();
await workViewPage.addTask('Parent task');
const parentTask = taskPage.getTaskByText('Parent task');
await expect(parentTask).toBeVisible();
await parentTask.focus();
await page.keyboard.press('a');
const draftInput = parentTask.locator('.e2e-add-subtask-input');
await expect(draftInput).toBeFocused();
await draftInput.fill('Canceled subtask');
await page.keyboard.press('Escape');
await expect(parentTask.locator('.e2e-add-subtask-input')).toHaveCount(0);
await expect(parentTask.locator('.sub-tasks task')).toHaveCount(0);
await expect(parentTask).not.toContainText('Canceled subtask');
// Escape returns focus to the task row so keyboard navigation continues.
await expect(parentTask).toBeFocused();
});
test('returns focus to the originating subtask on Escape', async ({
page,
workViewPage,
taskPage,
}) => {
await workViewPage.waitForTaskList();
await workViewPage.addTask('Parent task');
const parentTask = taskPage.getTaskByText('Parent task');
await expect(parentTask).toBeVisible();
// Create one subtask, then close the draft input.
await parentTask.focus();
await page.keyboard.press('a');
const draftInput = parentTask.locator('.e2e-add-subtask-input');
await draftInput.fill('First subtask');
await page.keyboard.press('Enter');
await expect(parentTask.locator('.sub-tasks task')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(parentTask.locator('.e2e-add-subtask-input')).toHaveCount(0);
// Now open the draft from the subtask, then cancel: focus must return to the
// subtask it was opened from, not the parent row.
const subTask = parentTask.locator('.sub-tasks task').first();
await subTask.focus();
await page.keyboard.press('a');
await expect(parentTask.locator('.e2e-add-subtask-input')).toBeFocused();
await page.keyboard.press('Escape');
await expect(parentTask.locator('.e2e-add-subtask-input')).toHaveCount(0);
await expect(subTask).toBeFocused();
});
// On desktop (this suite runs mouse-primary Chrome), click-away cancels the
// draft — commit-on-blur is scoped to touch (#8791/#8856), where the
// soft-keyboard Enter is unreliable. Desktop commits via Enter or the button.
test('discards a typed draft when it loses focus on desktop', async ({
page,
workViewPage,
taskPage,
}) => {
await workViewPage.waitForTaskList();
await workViewPage.addTask('Parent task');
const parentTask = taskPage.getTaskByText('Parent task');
await expect(parentTask).toBeVisible();
await parentTask.focus();
await page.keyboard.press('a');
const draftInput = parentTask.locator('.e2e-add-subtask-input');
await expect(draftInput).toBeFocused();
await draftInput.fill('Blurred subtask');
await page.locator('body').click({ position: { x: 10, y: 10 } });
await expect(parentTask.locator('.e2e-add-subtask-input')).toHaveCount(0);
await expect(parentTask.locator('.sub-tasks task')).toHaveCount(0);
await expect(parentTask).not.toContainText('Blurred subtask');
});
});