Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159029675c | ||
|
|
60795126a5 | ||
|
|
664614b924 |
@@ -5699,3 +5699,96 @@ test('the verdande door is unaffected by the urd one', async ({ page }) => {
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<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>
|
||||||
@@ -16,14 +16,53 @@
|
|||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { readList } from '$lib/pastelist.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 text = $state('');
|
||||||
let spans = $state([]);
|
let spans = $state([]);
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let input;
|
let input;
|
||||||
|
let mirror;
|
||||||
let focused = $state(false);
|
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
|
// 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
|
// 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
|
// are what Parse recognises as marks; dates it reads as prose, which is why the
|
||||||
@@ -72,6 +111,20 @@
|
|||||||
if (autofocus && input) input.focus();
|
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. */
|
/** Splits the raw text into highlighted and plain runs, by byte offset. */
|
||||||
let segments = $derived.by(() => {
|
let segments = $derived.by(() => {
|
||||||
if (!spans.length) return [{ text, kind: null }];
|
if (!spans.length) return [{ text, kind: null }];
|
||||||
@@ -197,7 +250,7 @@
|
|||||||
<!-- The mirror sits behind the input and must match it exactly: same font,
|
<!-- The mirror sits behind the input and must match it exactly: same font,
|
||||||
same padding, same wrapping. Any difference shows up as highlighting
|
same padding, same wrapping. Any difference shows up as highlighting
|
||||||
that drifts out of alignment as the line grows. -->
|
that drifts out of alignment as the line grows. -->
|
||||||
<div class="mirror" aria-hidden="true">
|
<div class="mirror" aria-hidden="true" bind:this={mirror}>
|
||||||
{#each segments as segment}
|
{#each segments as segment}
|
||||||
{#if segment.kind}
|
{#if segment.kind}
|
||||||
<mark data-kind={segment.kind}>{segment.text}</mark>
|
<mark data-kind={segment.kind}>{segment.text}</mark>
|
||||||
@@ -211,12 +264,14 @@
|
|||||||
bind:this={input}
|
bind:this={input}
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
|
onscroll={syncScroll}
|
||||||
|
oninput={syncScroll}
|
||||||
onfocus={() => (focused = true)}
|
onfocus={() => (focused = true)}
|
||||||
onblur={() => (focused = false)}
|
onblur={() => (focused = false)}
|
||||||
type="text"
|
type="text"
|
||||||
data-quickadd
|
data-quickadd={marked ? '' : undefined}
|
||||||
placeholder={t('task.placeholder')}
|
placeholder={t('task.placeholder')}
|
||||||
aria-label={t('task.new')}
|
aria-label={fieldLabel}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
{onpaste}
|
{onpaste}
|
||||||
@@ -385,8 +440,16 @@
|
|||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
white-space: pre-wrap;
|
/* `pre`, not `pre-wrap`, and that is the whole alignment.
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
An <input type="text"> is single-line by specification and cannot wrap
|
||||||
|
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
|
||||||
|
not drift a little — it puts the tint on a second line that the input does
|
||||||
|
not have, and the highlight ends up under empty space. Rare on a wide
|
||||||
|
desktop field, constant in the dictate sheet on a 390px phone, where a
|
||||||
|
spoken sentence overflows immediately. */
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mirror {
|
.mirror {
|
||||||
@@ -394,6 +457,10 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
|
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
|
||||||
|
has to be hidden rather than visible, or the long tail of a line paints
|
||||||
|
out past the field's edge. */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tint is behind the glyphs, not on them: colouring the text itself would
|
/* The tint is behind the glyphs, not on them: colouring the text itself would
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ export const da = {
|
|||||||
'task.pastedAdd': 'Opret {n} opgaver',
|
'task.pastedAdd': 'Opret {n} opgaver',
|
||||||
'task.pastedCancel': 'Fortryd',
|
'task.pastedCancel': 'Fortryd',
|
||||||
'task.new': 'Ny opgave',
|
'task.new': 'Ny opgave',
|
||||||
|
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
|
||||||
|
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
|
||||||
|
// det, fordi en knap med en mikrofon på lover noget andet.
|
||||||
|
'task.speak': 'Tal en opgave ind',
|
||||||
|
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
|
||||||
'task.syntaxProject': 'projekt',
|
'task.syntaxProject': 'projekt',
|
||||||
'task.syntaxSection': 'sektion',
|
'task.syntaxSection': 'sektion',
|
||||||
'task.syntaxLabel': 'etiket',
|
'task.syntaxLabel': 'etiket',
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export const en = {
|
|||||||
'task.pastedAdd': 'Add {n} tasks',
|
'task.pastedAdd': 'Add {n} tasks',
|
||||||
'task.pastedCancel': 'Cancel',
|
'task.pastedCancel': 'Cancel',
|
||||||
'task.new': 'New task',
|
'task.new': 'New task',
|
||||||
|
'task.speak': 'Speak a task',
|
||||||
|
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
|
||||||
'task.syntaxProject': 'project',
|
'task.syntaxProject': 'project',
|
||||||
'task.syntaxSection': 'section',
|
'task.syntaxSection': 'section',
|
||||||
'task.syntaxLabel': 'label',
|
'task.syntaxLabel': 'label',
|
||||||
|
|||||||
@@ -86,9 +86,26 @@ class AppState {
|
|||||||
urdFace = $state(false);
|
urdFace = $state(false);
|
||||||
|
|
||||||
enterUrd() {
|
enterUrd() {
|
||||||
this.urdFace = true;
|
this.setDoor('urd');
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveUrd() {
|
||||||
|
this.setDoor('verdande');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilken dør dette vindue sidst kom ind ad.
|
||||||
|
*
|
||||||
|
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
|
||||||
|
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
|
||||||
|
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
|
||||||
|
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
|
||||||
|
* tiden.
|
||||||
|
*/
|
||||||
|
setDoor(door) {
|
||||||
|
this.urdFace = door === 'urd';
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('verdande:door', 'urd');
|
sessionStorage.setItem('verdande:door', door);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
|
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
|
||||||
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
|
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import DictateSheet from '$lib/components/DictateSheet.svelte';
|
||||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import NoConnection from '$lib/components/NoConnection.svelte';
|
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||||
@@ -57,6 +58,25 @@
|
|||||||
app.load();
|
app.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvilken dør dette vindue er inde ad.
|
||||||
|
*
|
||||||
|
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
|
||||||
|
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
|
||||||
|
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
|
||||||
|
* vinduet åbnede med.
|
||||||
|
*
|
||||||
|
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
|
||||||
|
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
|
||||||
|
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
|
||||||
|
* hver sin dør — men en browserfane springer mellem dem hele tiden.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const here = $page.url.pathname;
|
||||||
|
if (here === '/urd') app.enterUrd();
|
||||||
|
else if (here === '/') app.leaveUrd();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opgavernes sider findes ikke i noter-udgaven.
|
* Opgavernes sider findes ikke i noter-udgaven.
|
||||||
*
|
*
|
||||||
@@ -295,6 +315,15 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
|
||||||
|
komponenten.
|
||||||
|
|
||||||
|
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
|
||||||
|
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
|
||||||
|
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
|
||||||
|
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
|
||||||
|
<DictateSheet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandPalette bind:open={paletteOpen} />
|
<CommandPalette bind:open={paletteOpen} />
|
||||||
|
|||||||
@@ -7,25 +7,18 @@
|
|||||||
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
|
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
|
||||||
* går videre til noterne.
|
* går videre til noterne.
|
||||||
*
|
*
|
||||||
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
|
* Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
|
||||||
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
|
* hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
|
||||||
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
|
* med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
|
||||||
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
|
* halvdel bliver vedligeholdt.
|
||||||
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
|
|
||||||
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
|
|
||||||
* igen.
|
|
||||||
*
|
*
|
||||||
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
|
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
|
||||||
* der kun omdirigerer.
|
* der kun omdirigerer.
|
||||||
*/
|
*/
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => goto('/noter', { replaceState: true }));
|
||||||
app.enterUrd();
|
|
||||||
goto('/noter', { replaceState: true });
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
|
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
|
||||||
|
|||||||
Reference in New Issue
Block a user