diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 8c2fd90..5715c2d 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -5793,3 +5793,96 @@ test('en note oprettet fra et projekt åbner også med markøren i overskriften' expect(trouble).toEqual([]); }); + +/** + * Tal en opgave ind. + * + * The button does not do speech recognition and is not meant to: it puts the + * quick-add field in front of the thumb so the keyboard's own dictation can be + * used. So what there is to test is the field — that it is reachable, that it ends + * up with the caret in it, and that what lands there becomes a task. + * + * What a browser cannot be asked: whether iOS opened its keyboard. That happens + * only when focus() is called inside the user gesture, and no test can see the + * difference. What a test CAN see is the precondition — that the field is already + * in the document before the button is pressed — and that is asserted below, + * because it is exactly what a well-meaning `{#if open}` would take away. + */ +const PHONE = { width: 390, height: 844 }; + +test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.setViewportSize(PHONE); + await page.goto('/'); + + const knap = page.getByRole('button', { name: 'Tal en opgave ind' }); + await expect(knap).toBeVisible(); + + // Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes + // synkront, og den eneste del af iOS-kravet en prøve kan se. + // + // `toHaveCount` og ikke `count()`: den første venter, den anden er et + // øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når + // `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde. + await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1); + + await knap.click(); + + // Markøren er i skuffens felt — ikke i sidens eget. + const fokusIskuffen = await page.evaluate( + () => !!document.activeElement?.closest('.sheet') + ); + expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true); + + // Og det, der dikteres, bliver læst af den samme parser som alt andet. + const felt = page.locator('.sheet input[type="text"]'); + await felt.fill('ring til tandlægen i dag p1'); + await felt.press('Enter'); + + // "i dag", af samme grund som de andre prøver her siger det: dette er + // I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt + // fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet. + await expect(page.getByText('ring til tandlægen').first()).toBeVisible(); + // Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter. + await expect(page.locator('.sheet.open')).toHaveCount(0); + expect(trouble).toEqual([]); +}); + +test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => { + // Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer + // FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers + // begynder et tastetryk at skrive i en kasse, ingen kan se. + await page.setViewportSize(PHONE); + await page.goto('/'); + + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1); + const mærketErIskuffen = await page.evaluate( + () => !!document.querySelector('[data-quickadd]')?.closest('.sheet') + ); + expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false); + + await page.keyboard.press('k'); + await expect(page.locator('[data-quickadd]')).toHaveValue('k'); +}); + +test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => { + // Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i + // bunden er til tommelfingeren. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden(); +}); + +test('knappen findes ikke i urd-ansigtet', async ({ page }) => { + // Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at + // skrue på en variabel, så det er den vej en bruger faktisk kommer. + await page.setViewportSize(PHONE); + await page.goto('/urd'); + await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0); + + // Kontrollen: samme telefon, den anden dør, og så er den der. + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible(); +}); diff --git a/web/src/lib/components/DictateSheet.svelte b/web/src/lib/components/DictateSheet.svelte new file mode 100644 index 0000000..58ce0aa --- /dev/null +++ b/web/src/lib/components/DictateSheet.svelte @@ -0,0 +1,243 @@ + + + + + +{#if !app.showsUrd} + +{/if} + + + + + + diff --git a/web/src/lib/components/QuickAdd.svelte b/web/src/lib/components/QuickAdd.svelte index 7366b99..0131628 100644 --- a/web/src/lib/components/QuickAdd.svelte +++ b/web/src/lib/components/QuickAdd.svelte @@ -16,14 +16,53 @@ import { app } from '$lib/stores.svelte.js'; import { t } from '$lib/i18n.svelte.js'; import { readList } from '$lib/pastelist.js'; - let { projectId = undefined, onadded = undefined, autofocus = false } = $props(); + let { + projectId = undefined, + onadded = undefined, + autofocus = false, + /** + * Whether this box carries the `data-quickadd` marker. + * + * The layout's type-anywhere shortcut finds the field with + * `document.querySelector('[data-quickadd]')`, which returns the FIRST match + * in the document. So a second box on the page — the dictate sheet's — has + * to stay unmarked, or the shortcut starts typing into a box nobody can see. + */ + marked = true, + /** + * The field's accessible name. + * + * A prop because the dictate sheet puts a second one of these on the page, + * and two fields answering to "Ny opgave" is a screen reader reading the + * same name twice with no way to tell which is which. They are different + * affordances — one is the box on the page, the other is the thing the + * round button opens — so they say different names. + */ + label = undefined + } = $props(); + + let fieldLabel = $derived(label ?? t('task.new')); let text = $state(''); let spans = $state([]); let submitting = $state(false); let input; + let mirror; let focused = $state(false); + /** + * Keeps the tint under the right characters when the line is longer than the + * field. + * + * The input scrolls horizontally and the mirror has to scroll with it. Done on + * both `scroll` and `input` because typing at the end of a long line moves the + * caret — and therefore the scroll — without ever firing a scroll event in some + * browsers. + */ + function syncScroll() { + if (mirror && input) mirror.scrollLeft = input.scrollLeft; + } + // Kept beside the parser's own vocabulary on purpose: a legend that promises // something internal/quickadd cannot read is worse than no legend. These four // are what Parse recognises as marks; dates it reads as prose, which is why the @@ -72,6 +111,20 @@ if (autofocus && input) input.focus(); }); + /** + * Focus the field from outside, synchronously. + * + * This exists because of iOS and nothing else. Safari opens the on-screen + * keyboard only when `focus()` is called INSIDE the user gesture that asked for + * it — so the `autofocus` effect above, which runs after the click handler has + * returned, gives the field a caret and no keyboard. For the dictate button that + * is the whole feature failing quietly: the sheet opens, nothing to dictate + * into, and there is no error anywhere. + */ + export function focusField() { + input?.focus(); + } + /** Splits the raw text into highlighted and plain runs, by byte offset. */ let segments = $derived.by(() => { if (!spans.length) return [{ text, kind: null }]; @@ -197,7 +250,7 @@ -