From bb25b94f9690bb1db1db5fb604275dc20375c191 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 18:10:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(sidebar):=20en=20gruppe=20st=C3=A5r=20nu=20?= =?UTF-8?q?hvor=20projekterne=20st=C3=A5r,=20og=20hedder=20hele=20sit=20na?= =?UTF-8?q?vn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To fejl i samme række. "Omdøb" og "Slet" lå i flowet og optog deres bredde også usynlige, så ARBEJDE blev tegnet som "ARBE"; de ligger nu over rækkens højre ende og dukker op ved hover. Og chevronen sad før navnet og skubbede en gruppe toogtyve pixels længere ind end de projekter, den indeholder — så en gruppe så ud til at ligge under dem. Den deler nu plads med gruppens prik: prikken hviler, chevronen tager over ved hover, og bliver stående mens gruppen er foldet. De sidste otte pixels sad i a-reglen, som giver hvert link i sidebjælken sin egen venstrepolstring oven i rækkens. Prøven, der holdt øje med rækken, målte at alt lå side om side — hvilket var netop den geometri, der skulle laves om. Den måler nu det, der blev bedt om: at navnet kan læses helt, og at gruppens prik står præcis hvor et projekts gør. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 53 ++++++++++++++--------- web/src/lib/components/Sidebar.svelte | 61 ++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index e19220f..6d8fdcd 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -302,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me await expect(heading).toBeVisible(); await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible(); - // The words in the heading must not sit on top of each other. + // A group's name must be readable in full, and must start where a project's + // name starts. // - // Everything else here clicks by accessible name, which is exactly why this - // needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for - // the "+" icon beside "Projekter", overflowed it, and were drawn overlapping. - // Both buttons still worked, so every assertion in this file passed. - const boxes = await sidebar.locator('.folder-head').evaluate((el) => - // Links as well as buttons: the group's name is a link to its page now, and - // a measurement that skipped it would miss the widest thing in the row. - [...el.querySelectorAll('button, a')].map((b) => { - const r = b.getBoundingClientRect(); - return { text: b.textContent.trim(), left: r.left, right: r.right }; - }) - ); - for (let i = 1; i < boxes.length; i++) { - expect( - boxes[i].left, - `"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet` - ).toBeGreaterThanOrEqual(boxes[i - 1].right); - } + // Both halves are here because both were once broken at the same time and for + // different reasons. "Omdøb" and "Slet" sat in the flow reserving their width + // even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron + // sat before the name, pushing a group 22px further in than the projects it + // contains — so a group read as if it were filed under them. Every assertion + // in this file passed through both, because both buttons still worked. + const shape = await sidebar.locator('.folder-head').evaluate((el) => { + const link = el.querySelector('h2 a'); + const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() }; + // The last child of the link is the name itself; the dot is a span before it. + return { + clipped: link.scrollWidth > link.clientWidth + 1, + left: name.left, + right: link.getBoundingClientRect().right, + count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity + }; + }); + + expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false); + expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right); + + // Measured dot to dot, not box to box: both rows begin with the same coloured + // mark, and it is the only thing in them that means the same on both sides. + // Comparing the group's inner link with the project's outer box reported the + // row's own padding as a misalignment. + const projectLeft = await sidebar + .getByRole('link', { name: 'Regnskab' }) + .evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left); + expect( + Math.abs(shape.left - projectLeft), + `gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne` + ).toBeLessThanOrEqual(2); // Dragged in, then dragged back out onto "Projekter" — which is the reason // that heading is a drop target at all: with every project filed away there diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 950c88f..a85cda9 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -939,14 +939,30 @@ /* The chevron on its own, so the name beside it can be a link to the group's page. Sized as a target rather than as a glyph: 11px of arrow is something you miss. */ + /* The chevron sits on top of the group's dot rather than before it. In the flow + it pushed the name 22px further in than a project's, so groups read as if + they were filed under the projects above them — the opposite of what they + are. Out of the flow, a group's name starts exactly where a project's does. + The dot is the resting state; the chevron takes over on hover or focus, and + stays put while the group is folded, so a folded group still shows the way + back open. */ .chevron-button { - flex: none; + position: absolute; + left: calc(var(--s2) - 5px); width: 18px; height: 18px; display: grid; place-items: center; border-radius: var(--radius-sm); color: inherit; + opacity: 0; + background: var(--surface); + } + + .folder-head:hover .chevron-button, + .chevron-button:focus-visible, + .chevron-button[aria-expanded='false'] { + opacity: 1; } .chevron-button:hover { @@ -997,15 +1013,30 @@ /* 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; @@ -1020,7 +1051,33 @@ } .folder-head .count { - margin-left: 0; + margin-left: auto; + } + + /* Without min-width a flex item refuses to shrink below its content, so a long + group name pushed the count off the row instead of ending in an ellipsis. */ + .folder-head h2 { + min-width: 0; + flex: 1; + } + + /* No left padding of its own: the row already has the same padding as a project + row, and the anchor's would be added on top — which is where the last eight + pixels of the old indent were hiding. Vertical padding stays, so the heading + keeps its height. */ + .folder-head h2 a { + display: flex; + align-items: center; + gap: var(--s2); + min-width: 0; + padding: var(--s2) 0; + } + + .folder-head h2 a :global(*:last-child), + .folder-head h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* The group's own mark, between the chevron and the name — inside the fold