fix(forms): Safari focused none of them, and a board could not make a section

Two reports, one sentence between them: "sections have no function, because you
cannot create more than one."

`autofocus` does not work in Safari for anything inserted after the page has
loaded, and every inline form here is inserted after the page has loaded — rename
a project, add a section, name a group. In Safari they all opened with no cursor
in them and what you typed went nowhere. That does not look like a bug. It looks
like the feature does not work. `use:focusOnMount` replaces all seventeen.

And the board had no way to add a section at all. The button lived only in the
list view, which is the one place nobody looks for it: a column on a board *is* a
section, so that is where you go to make one.

Chasing this turned up a third. The task drawer's three fields save on blur, and
closing the drawer *is* a blur — the input is being unmounted and the browser
fires blur on the way out. If the task left the store in the same moment, `task`
was already null and the handler threw `Cannot read properties of null`, stopping
the page. It only became reproducible once focus actually worked.

Fields are 16px on a touch device now, whatever the component asked for. Below
16px, iOS Safari zooms in when a field takes focus and does not zoom back out —
so the whole interface is left too large and has to be pinched back by hand, once
per field. The type scale tops out at 15px, so every input in the app did it.

The sidebar's resize handle sat at `right: -3px`. An absolutely positioned child
counts towards its container's scrollable overflow, and `overflow-y: auto`
resolves the other axis to `auto` too — so those three pixels gave the whole menu
a horizontal scrollbar on every desktop, always. The account name could do it as
well: `overflow: hidden` with no `min-width: 0` cannot shrink, so the row grew
instead of ellipsing.

There is a WebKit pass in the suite now, over one test. The rest stays Chromium
on purpose — a smoke test exists to catch a build that does not work, and three
engines find the same bug three times — but focus is the exception, and it took a
person using the app to find that out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:05:02 +02:00
co-authored by Claude Opus 5
parent 1da7ea64bf
commit eaff890860
9 changed files with 575 additions and 18 deletions
+89 -2
View File
@@ -9,11 +9,36 @@
* them — and it gives keyboard users the browser's own behaviour for free.
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { api, humanMessage } from '$lib/api.js';
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
import { focusOnMount } from '$lib/focus.js';
import TaskRow from './TaskRow.svelte';
let { project, sections, canEdit } = $props();
let { project, sections, canEdit, onsectionadded } = $props();
// --- adding a column ------------------------------------------------------------
//
// A column on a board *is* a section, so this is where somebody looks for one.
// It was only in the list view, which meant a board — the view whose entire
// shape is its sections — had no way to grow one. Reported as "sections have no
// function: there is no way to create them", which from inside a board is
// exactly true.
let adding = $state(false);
let name = $state('');
async function addSection(event) {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
adding = false;
name = '';
try {
onsectionadded?.(await api.createSection(project.id, trimmed));
} catch (e) {
app.toast(humanMessage(e));
}
}
let draggingId = $state(null);
let overColumn = $state(null);
@@ -123,6 +148,31 @@
</div>
</section>
{/each}
{#if canEdit}
<section class="column adder">
{#if adding}
<form onsubmit={addSection}>
<input
bind:value={name}
use:focusOnMount
placeholder="Sektionens navn"
aria-label="Ny sektion"
onblur={() => !name.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{:else}
<button
class="add"
onclick={() => {
adding = true;
name = '';
}}>+ Tilføj sektion</button
>
{/if}
</section>
{/if}
</div>
<style>
@@ -137,6 +187,43 @@
scroll-snap-type: x proximity;
}
/* Narrower than a real column and without its background: it is an invitation
at the end of the row, not a column with nothing in it. */
.adder {
background: none;
border: 1px dashed var(--line);
min-width: 180px;
padding: var(--s2);
}
.adder .add {
width: 100%;
padding: var(--s2);
font-size: var(--text-sm);
color: var(--ink-faint);
border-radius: var(--radius);
text-align: left;
}
.adder .add:hover {
color: var(--ink);
background: var(--surface);
}
.adder input {
width: 100%;
padding: var(--s2);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
}
.adder input:focus {
border-color: var(--accent);
}
.column {
flex: 0 0 300px;
max-width: 300px;
-1
View File
@@ -14,7 +14,6 @@
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
+2 -2
View File
@@ -18,6 +18,7 @@
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
@@ -168,10 +169,9 @@
<label>
Kode
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={code}
autofocus
use:focusOnMount
inputmode="numeric"
autocomplete="one-time-code"
placeholder="123456"
+19 -3
View File
@@ -12,6 +12,7 @@
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
let { task, onclose } = $props();
@@ -96,8 +97,21 @@
.catch(() => {});
});
/** Sends a patch through the store, so the row behind the drawer updates too. */
/**
* Sends a patch through the store, so the row behind the drawer updates too.
*
* `task` can be null by the time this runs. The three fields below save on
* blur, and closing the drawer *is* a blur — the input is being unmounted, and
* the browser fires blur on the way out. If the task left `app.tasks` in the
* same moment (it was completed, it was moved, another browser deleted it),
* `app.detailTask` is already null and this is a handler for a task that is no
* longer there.
*
* It threw `Cannot read properties of null` and stopped the page. Nothing to
* save is not an error; it is the ordinary end of an edit nobody made.
*/
async function save(patch) {
if (!task) return;
await app.update(task.id, patch);
}
@@ -114,6 +128,7 @@
* worse than one that does nothing.
*/
async function saveContent() {
if (!task) return;
const trimmed = content.trim();
if (!trimmed || trimmed === task.content) {
content = task.content;
@@ -181,11 +196,12 @@
}
function saveDescription() {
if (description === (task.description ?? '')) return;
if (!task || description === (task.description ?? '')) return;
save({ description });
}
function saveLabels() {
if (!task) return;
const list = labels
.split(',')
.map((l) => l.trim())
@@ -358,9 +374,9 @@
<div class="scroll">
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<input
class="title"
use:focusOnMount
bind:value={content}
onblur={saveContent}
aria-label="Opgavens tekst"
+57 -1
View File
@@ -83,7 +83,7 @@
<span class="description">{task.description}</span>
{/if}
{#if assignee || due || task.labels?.length || task.recurrence_text}
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
@@ -112,6 +112,36 @@
{task.recurrence_text}
</span>
{/if}
<!-- What is underneath, said as a fraction rather than a total: "3" tells
you there is something there, "1/3" tells you whether there is
anything left to do, which is the question the list is for. -->
{#if task.subtask_count}
<span
class="badge"
class:all-done={task.subtask_done === task.subtask_count}
title="{task.subtask_done} af {task.subtask_count} undertasks er lukket"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6h11" />
<path d="M9 12h11" />
<path d="M9 18h11" />
<path d="M4 6l1 1 2-2" />
<path d="M4 12l1 1 2-2" />
<path d="M4 18l1 1 2-2" />
</svg>
{task.subtask_done}/{task.subtask_count}
</span>
{/if}
{#if task.attachment_count}
<span class="badge" title="{task.attachment_count} vedhæftet">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
/>
</svg>
{task.attachment_count}
</span>
{/if}
{#each task.labels ?? [] as label}
<span class="label">{label}</span>
{/each}
@@ -291,6 +321,32 @@
flex: none;
}
/* A mark, not a chip: it sits in the same row as the date and the labels and
must not out-shout them. The task's own text is the thing being read. */
.badge {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--ink-faint);
flex: none;
}
.badge svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Everything underneath is closed. Worth saying, because "3/3" and "1/3" are
the two answers you actually look for and only one of them is good news. */
.badge.all-done {
color: var(--accent);
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
+26
View File
@@ -0,0 +1,26 @@
/**
* Focuses an element the moment it is inserted.
*
* <input use:focusOnMount />
*
* Replaces the `autofocus` attribute, which does not work in Safari for anything
* added after the page has loaded. Every inline form in this app is added after
* the page has loaded — the one that renames a project, the one that adds a
* section, the one that names a group — so in Safari they all appeared with no
* cursor in them. What you typed went nowhere.
*
* That is worse than it sounds, because it does not look like a bug. It looks like
* the feature does not work: "sections have no function, because you cannot create
* more than one" was this, and only this.
*
* The second attempt on the next frame is for the case where the element is
* inserted inside something that is still settling — a drawer sliding in, a list
* re-keying. Focusing an element the browser is still laying out can silently do
* nothing, and there is no event to wait for that means "now".
*/
export function focusOnMount(node) {
node.focus?.();
if (document.activeElement !== node) {
requestAnimationFrame(() => node.focus?.());
}
}