Files
verdande/web/src/lib/components/DictateSheet.svelte
T
kristianwindandClaude Opus 5 60795126a5 fix(opgaver): skuffens knapper kan ikke tabbes til, mens den er usynlig
`inert` på skuffen, når den er lukket, så dens felt og knapper er ude af
tabrækkefølgen og ude af tilgængelighedstræet.

Det støder mod funktionen selv: et inert element kan ikke fokuseres, og
fokus i samme hug som klikket er præcis det, iOS kræver for at åbne
tastaturet. Attributten sættes reaktivt af `inert={!open}`, og Sveltes
opdateringer er asynkrone, så den er der stadig i det øjeblik handleren
kører.

Derfor fjernes den med hånden i `start()`, før fokus sættes, og den
reaktive binding lægger den tilbage ved lukning. En imperativ DOM-ændring
midt i en deklarativ komponent ser ud som sjusk, og uden en grund skrevet
ved siden af ville den næste læser helt rimeligt rydde den op.

Plantet ved at fjerne linjen: prøven går rød med "markøren skal stå i
skuffens felt efter trykket — Expected: true, Received: false". Linjen
bærer noget.

Luk-knappens `tabindex` er fjernet som overflødig; inert dækker hele træet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:04:27 +02:00

244 lines
6.6 KiB
Svelte

<script>
/**
* Tal en opgave ind.
*
* En stor rund knap i bunden af skærmen, 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, og det er en datavej, der skal forklares på privatlivssiden for at
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
* forstod — så "ring til tandlægen i morgen kl 10 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.
*/
import QuickAdd from './QuickAdd.svelte';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let open = $state(false);
let box;
let sheet;
/**
* Åbner og sætter fokus I SAMME HUG som klikket.
*
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
* frem for efter næste opdatering.
*
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
* og uden pointer-events frem for skjult.
*/
function start() {
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
// åbne tastaturet.
//
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
// tabbes til, mens den er usynlig, på hver eneste side.
sheet?.removeAttribute('inert');
open = true;
box?.focusField();
}
function close() {
open = false;
// Tastaturet bliver stående, hvis feltet beholder fokus.
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
}
function onkeydown(event) {
if (event.key === 'Escape' && open) {
event.preventDefault();
close();
}
}
</script>
<svelte:window {onkeydown} />
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
bredde, ikke skal kunne tabe et felt, der har fokus. -->
{#if !app.showsUrd}
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
og navnet står i aria-label. -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
<path d="M12 17.5V21" />
</svg>
</button>
{/if}
<div
class="scrim"
class:open
aria-hidden="true"
onclick={close}
role="presentation"
></div>
<div
class="sheet"
class:open
bind:this={sheet}
role="dialog"
aria-modal="false"
aria-label={t('task.speak')}
aria-hidden={!open}
inert={!open}
>
<div class="grab" aria-hidden="true"></div>
<p class="hint">{t('task.speakHint')}</p>
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
Se kommentaren ved `marked` i QuickAdd. -->
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
<button class="done" onclick={close}>{t('detail.close')}</button>
</div>
<style>
.fab {
position: fixed;
right: max(var(--s4), env(safe-area-inset-right));
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 40;
width: 64px;
height: 64px;
border: none;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow-lg);
display: grid;
place-items: center;
cursor: pointer;
transition:
transform var(--fast) var(--ease-out),
opacity var(--fast) var(--ease-out);
}
.fab svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.fab:active {
transform: scale(0.94);
}
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
.fab.away {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.scrim {
position: fixed;
inset: 0;
z-index: 44;
background: rgb(0 0 0 / 0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--medium) var(--ease-out);
}
.scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
inset: auto 0 0 0;
z-index: 45;
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-lg);
display: grid;
gap: var(--s2);
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
fokuseres i samme hug som klikket. Se `start()`. */
opacity: 0;
transform: translateY(100%);
pointer-events: none;
transition:
transform var(--medium) var(--ease-out),
opacity var(--medium) var(--ease-out);
}
.sheet.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.grab {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--line-strong);
justify-self: center;
}
.hint {
margin: 0;
color: var(--ink-muted);
font-size: 0.85rem;
text-align: center;
}
.done {
justify-self: center;
background: none;
border: none;
color: var(--ink-muted);
padding: var(--s2);
cursor: pointer;
}
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
på. Knappen er til tommelfingeren. */
@media (min-width: 821px) {
.fab,
.scrim,
.sheet {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.scrim,
.sheet {
transition: none;
}
}
</style>