feat(tasks): make a URL in a task's title or description clickable

A task is plain text — the title is an input, the description a textarea —
so a pasted URL just sat there as words. It is now caught at display time
and shown as a link in the list and on the board, without the stored text
ever being anything but plain text.

The link cannot live inside the row's open-button (an <a> in a <button> is
invalid), so the body is a container with the open action as an overlay
behind it: plain text and the meta row still open the task, links are
raised above and take their own click. Conservative about what counts —
an explicit http(s):// or a bare www. host, not every dotted word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-08-27 16:17:56 +02:00
co-authored by Claude Opus 4.8
parent 923f911472
commit 9d4a13c2c2
3 changed files with 160 additions and 6 deletions
+23
View File
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
expect(trouble).toEqual([]);
});
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
// takes. The link is made at display time — the stored title is still plain text.
await box.fill('læs https://example.com/artikel i dag');
await box.press('Enter');
const row = page.locator('.row').filter({ hasText: 'læs' });
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
await expect(link).toHaveAttribute('rel', /noopener/);
// Clicking anywhere but the link still opens the task — the open-overlay sits
// behind the text, the link above it.
await row.getByRole('button', { name: /læs https/ }).click();
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
+69 -6
View File
@@ -11,6 +11,7 @@
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { repeatLabel } from '$lib/when.js';
import { linkify } from '$lib/linkify.js';
let { task, onedit } = $props();
@@ -99,13 +100,38 @@
</svg>
</button>
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
want the click to mean something else. -->
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
<span class="content">{task.content}</span>
<!-- A URL pasted into the title or the description is clickable, without the
text ever having been anything but plain text. The link cannot live inside
the open-button — an <a> in a <button> is invalid — so the whole body is a
container and the button is an overlay behind it: plain text (below it)
still opens the task, links (raised above it) open the page. -->
<div class="body">
<!-- Opening the detail drawer is the default. `onedit` is for the callers
that want the click to mean something else. -->
<button
class="open"
aria-label={task.content}
onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}
></button>
<span class="content"
>{#each linkify(task.content) as run}{#if run.href}<a
class="tlink"
href={run.href}
target="_blank"
rel="noopener noreferrer">{run.text}</a
>{:else}{run.text}{/if}{/each}</span
>
{#if task.description}
<span class="description">{task.description}</span>
<span class="description"
>{#each linkify(task.description) as run}{#if run.href}<a
class="tlink"
href={run.href}
target="_blank"
rel="noopener noreferrer">{run.text}</a
>{:else}{run.text}{/if}{/each}</span
>
{/if}
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
@@ -186,7 +212,7 @@
{/each}
</span>
{/if}
</button>
</div>
</div>
<style>
@@ -301,6 +327,7 @@
}
.body {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
@@ -309,12 +336,48 @@
min-width: 0;
}
/* The click target for opening the task, stretched behind everything. Plain
text and the meta row sit at the base level, so a click on them lands here;
only the links are raised above it. */
.open {
position: absolute;
inset: 0;
z-index: 1;
cursor: pointer;
background: none;
border: 0;
padding: 0;
margin: 0;
border-radius: var(--radius-sm);
}
.open:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.content {
color: var(--ink);
line-height: 1.45;
overflow-wrap: anywhere;
}
/* Raised above the open-overlay so the link takes the click, while the rest of
the line still opens the task. */
.tlink {
position: relative;
z-index: 2;
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
overflow-wrap: anywhere;
}
.tlink:hover {
text-decoration-thickness: 2px;
}
.description {
font-size: var(--text-sm);
color: var(--ink-muted);
+68
View File
@@ -0,0 +1,68 @@
/**
* Splits a plain string into runs of text and the URLs inside it, so a task's
* title or description can be shown with the links clickable without the text
* ever having been anything but plain text on disk.
*
* A task is not rich text — the title is an `<input>` and the description a
* `<textarea>` — so there is nowhere to *store* a link. What there is instead is
* a person who pasted one in and expects to be able to click it. This finds the
* ones that are unambiguously links and leaves everything else exactly as typed.
*
* Deliberately conservative about what counts as a link: an explicit
* `http://`/`https://`, or a bare `www.` host. "example.com" on its own is not
* matched — most of those are sentences ("the file.txt is here"), and a false
* link in a title is worse than a real one left plain.
*/
// A trailing `.,;:!?` or a closing bracket is almost always punctuation the
// sentence owns rather than part of the URL, so the run stops before it. A `)`
// is only dropped when the URL holds no `(` of its own — Wikipedia-style links
// carry balanced parentheses that belong to the address.
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>]+/gi;
function trimTrailing(url) {
let end = url.length;
while (end > 0) {
const ch = url[end - 1];
if ('.,;:!?"\''.includes(ch)) {
end--;
continue;
}
if (ch === ')' && !url.slice(0, end).includes('(')) {
end--;
continue;
}
break;
}
return url.slice(0, end);
}
/**
* @param {string} text
* @returns {Array<{ text: string, href?: string }>} runs in order; a run with an
* `href` is a link, one without is plain text. An empty or link-free string
* comes back as a single text run (possibly empty) so callers can render it the
* same way regardless.
*/
export function linkify(text) {
const value = text ?? '';
const runs = [];
let last = 0;
for (const match of value.matchAll(URL_RE)) {
const raw = trimTrailing(match[0]);
if (!raw) continue;
const start = match.index;
if (start > last) runs.push({ text: value.slice(last, start) });
const href = raw.startsWith('www.') ? `https://${raw}` : raw;
runs.push({ text: raw, href });
last = start + raw.length;
}
if (last < value.length) runs.push({ text: value.slice(last) });
if (!runs.length) runs.push({ text: value });
return runs;
}