feat(groups): omdøb med dobbeltklik, og slet der hvor man kan se hvad man sletter
Release / Build and publish image (push) Failing after 9m59s

De to ord "Omdøb" og "Slet" lå oven på gruppens navn, hver gang musen var i
nærheden. De er væk fra sidebjælken. Omdøbning starter nu ved dobbeltklik på
rækken — eller F2, hvis man har den i fokus og ikke vil bruge musen. Sletning er
flyttet til gruppens egen side, hvor projekterne, den ikke tager med sig, står på
skærmen ovenover knappen.

Dobbeltklik på et navn, der også er et link, er ikke gratis. Første forsøg lod
det første klik navigere, og så åbnede siden mens omdøbningsformularen kom frem —
farveprikkerne flyttede sig ud under markøren, hvilket testen fangede som "element
is not stable". Navigationen venter derfor to tiendedele på et muligt andet klik.
Det er under grænsen for et dobbeltklik og over grænsen for at mærke det.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-18 20:08:24 +02:00
co-authored by Claude Opus 5
parent bd5e86dd03
commit c151d6c98c
3 changed files with 80 additions and 58 deletions
+8 -3
View File
@@ -363,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Colour, which is chosen while renaming rather than from the row. Both are
// edits to the same thing, and a heading carrying four controls crowded the
// name it exists to show.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
// Double-click on the row opens it; the two words that used to sit on top of
// the name are gone from the sidebar entirely.
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
await sidebar.getByRole('button', { name: 'Petrol' }).click();
// Still open: picking a colour must not close the form under the pointer, which
// is what a plain blur-to-cancel does in the two browsers that do not focus a
@@ -407,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
@@ -416,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
// Deleting is on the group's own page now, where the projects it will not take
// with it are on the screen above the button.
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await page.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
+35 -55
View File
@@ -105,17 +105,18 @@
await app.renameGroup(group.id, name);
}
async function removeGroup(group) {
const count = inGroup(group.id).length;
// Says what happens to the projects, because "slet gruppen" reads like it
// might take them with it. It does not: only the heading goes.
const kept =
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
const question = t('sidebar.deleteGroup', { name: group.name });
if (!confirm(count ? `${question} ${kept}` : question)) return;
await app.deleteGroup(group.id);
/** Double-click, or F2 with the row focused. The colour is chosen in the same
* form, because it is the same edit to the same thing. */
/** Cleared by a second click, which means the double was a rename. */
let navTimer;
function startRename(group) {
clearTimeout(navTimer);
renamingGroup = group.id;
groupName = group.name;
}
// --- resizing -------------------------------------------------------------------
let resizing = $state(false);
@@ -547,6 +548,13 @@
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
ondblclick={() => startRename(group)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(group);
}
}}
>
{#if renamingGroup === group.id}
<!-- Renaming is where the colour is chosen too.
@@ -618,7 +626,24 @@
</svg>
</button>
<h2 class="fold">
<a href="/gruppe/{group.id}" onclick={onnavigate}>
<!-- Navigation waits two tenths of a second for a second click. A
link cannot know a double is coming without pausing, and the
alternative was worse than the pause: the first click opened the
page while the rename form was appearing, and the colour swatches
moved out from under the pointer. Two tenths is under the double
click threshold and over the threshold of noticing. -->
<a
href="/gruppe/{group.id}"
onclick={(e) => {
e.preventDefault();
clearTimeout(navTimer);
if (e.detail > 1) return;
navTimer = setTimeout(() => {
onnavigate();
goto(`/gruppe/${group.id}`);
}, 200);
}}
>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
@@ -628,14 +653,6 @@
</a>
</h2>
<span class="count">{inside.length}</span>
<button
class="group-action"
onclick={() => {
renamingGroup = group.id;
groupName = group.name;
}}>{t('sidebar.rename')}</button
>
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
{/if}
</div>
@@ -1013,43 +1030,6 @@
/* Hidden until the heading is hovered, like the section actions on a project
page: two buttons permanently beside a label stop it reading as a label. */
/* Hidden but still in the flow, these two reserved their own width at all
times — enough that ARBEJDE was drawn as "ARBE". They are lifted out of the
flow and sit over the right end of the row, so the name has the whole line
until somebody actually reaches for them. */
.group-action {
position: absolute;
right: var(--s2);
font-size: var(--text-xs);
color: var(--ink-faint);
padding: 0 var(--s1);
flex: none;
opacity: 0;
background: var(--surface);
transition: opacity var(--fast) var(--ease);
}
.group-action.remove {
right: var(--s2);
}
.group-action:not(.remove) {
right: calc(var(--s2) + 3.2em);
}
.folder-head:hover .group-action,
.group-action:focus-visible {
opacity: 1;
}
.group-action:hover {
color: var(--ink);
}
.group-action.remove:hover {
color: var(--danger);
}
.folder-head .count {
margin-left: auto;
}
+37
View File
@@ -12,6 +12,7 @@
* projects answers is what is left.
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
@@ -29,6 +30,18 @@
load(id);
});
/** Says what happens to the projects, because "delete the group" reads like it
* might take them with it. It does not: only the heading goes. */
async function removeGroup() {
const count = projects.length;
const kept =
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
const question = t('sidebar.deleteGroup', { name: group.name });
if (!confirm(count ? `${question} ${kept}` : question)) return;
await app.deleteGroup(group.id);
goto('/');
}
async function load(groupId) {
if (!groupId) return;
status = 'loading';
@@ -208,6 +221,14 @@
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
</label>
</section>
<!-- Deleting lives here rather than in the sidebar, where two words sat on
top of the group's name and where you could press them without having
seen what the group holds. Here the projects are on the screen above
it, which is the whole argument for the button being here. -->
<section class="danger">
<button class="remove" onclick={removeGroup}>{t('sidebar.delete')}</button>
</section>
{/if}
</div>
@@ -230,6 +251,22 @@
gap: var(--s3);
}
.danger {
margin-top: var(--s4);
padding-top: var(--s4);
border-top: 1px solid var(--line);
}
.danger .remove {
font-size: var(--text-sm);
color: var(--ink-faint);
padding: var(--s2) 0;
}
.danger .remove:hover {
color: var(--danger);
}
h1 {
font-size: var(--text-2xl);
min-width: 0;