feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde.
Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
egen diktering kører på enheden på nyere telefoner. En talegenkendelse
bygget ind her ville sende lyden fra et selvhostet program videre til Apple
eller Google — en datavej, der skulle forklares på privatlivssiden, for at
spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic
i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele
brugerne op efter hvem de har en nøgle hos.
Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så
"ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og
intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke
tale: den gør ikke selv noget ved lyden, den stiller feltet frem.
## Det ene, funktionen hænger på
Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den
brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect,
altså efter klikket er returneret — det giver feltet en markør og intet
tastatur, og der er ingen fejl nogen steder.
Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og
opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre
gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR
klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på;
plantet som `{#if open}` går den rød med Expected: 1, Received: 0.
## To kollisioner, hvor jeg kun så den ene
Skuffen lægger et felt mere på siden, og det rammer to ting:
- `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med
querySelector — altså FØRSTE match. Set på forhånd og løst med en
`marked`-prop.
- Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på
`getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun
et prøveproblem: to felter med samme navn læses op to gange af en
skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det
ene er kassen på siden, det andet er det, knappen åbner — så de har hver
sit navn nu.
## Og en fejl, skærmbilledet fandt
Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg
blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et
<input type="text"> er enkeltlinje per specifikation og kan ikke ombryde,
uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret:
"same wrapping. Any difference shows up as highlighting that drifts out of
alignment as the line grows."
Sjældent synligt på et bredt felt, konstant på 390px med en dikteret
sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft.
Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et
tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
807aa83e58
commit
664614b924
@@ -5620,3 +5620,96 @@ test('the verdande door is unaffected by the urd one', async ({ page }) => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user