fix(projects): the header stops crushing the project's name
"GarageRisteriet" rendered as "GarageRist / eriet" — broken mid-word. A name split
mid-syllable does not read as a wrap, it reads as a rendering fault.
Two causes. The row carried eight controls beside the heading — colour, show-done,
three view buttons, share, history, delete — and the heading was the only flexible
thing in it, so it was what gave way. And `overflow-wrap: anywhere` breaks at the
first opportunity rather than the last necessary one, so what gave way, gave way
inside a word.
Everything but the view switcher is behind one button now. What stays out is what
you use while working — which view you are in. What moves in is what you do *to* a
project rather than in it, and most of it once ever. The heading gets the row back,
and `break-word` only splits a word that could not fit on a line of its own.
The menu closes on Escape and on a click anywhere else, listened for in the capture
phase so a click whose own handler stops propagation still closes it. A menu you
can only leave by pressing its own button again is one you click twice to get out
of.
Three more untranslated strings turned up on the way: "Fjern {member.name}", "Luk
menu" and "En slettet konto". The guard missed them because none of its words
covered them — it knows fjern, opret, vælg, luk, tilføj, ryd, gem, slet and omdøb
now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab5e3e35a6
commit
9cc7df69f0
+71
-7
@@ -19,6 +19,25 @@ import { USER } from './user.js';
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
* The header used to carry eight controls in one row — title, colour, show-done,
|
||||
* three view buttons, share, history, delete — and a project name is not a short
|
||||
* word, so the heading was what gave way. Everything but the view switcher moved
|
||||
* behind one button; this is how a test reaches them.
|
||||
*/
|
||||
async function projectAction(page, name) {
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
// `exact` only means anything for a string. The share item reads "Delt · 2" once
|
||||
// somebody is in the project, so callers pass a regex for that one.
|
||||
const item =
|
||||
name instanceof RegExp
|
||||
? page.getByRole('menuitem', { name })
|
||||
: page.getByRole('menuitem', { name, exact: true });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
@@ -224,7 +243,7 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||
await projectAction(page, 'Slet projektet');
|
||||
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||
|
||||
@@ -540,7 +559,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
|
||||
@@ -779,7 +798,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// controls below only exist once it has.
|
||||
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
|
||||
await page.getByLabel('Rolle').selectOption('viewer');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
@@ -804,7 +823,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// The owner promotes them from the member list. The owner's own row is text,
|
||||
// not a dropdown: ownership is transferred, not granted.
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Delt/ }).click();
|
||||
await projectAction(page, /Delt/);
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
|
||||
@@ -832,7 +851,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
).toHaveCount(0);
|
||||
|
||||
// The log has recorded all of this since the beginning and nothing showed it.
|
||||
await page.getByRole('button', { name: 'Historik' }).click();
|
||||
await projectAction(page, 'Historik');
|
||||
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
||||
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
||||
|
||||
@@ -1184,7 +1203,7 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
// And it can be looked at again, under its own heading at the bottom.
|
||||
await page.getByRole('button', { name: 'Vis færdige' }).click();
|
||||
await projectAction(page, 'Vis færdige');
|
||||
const done = page.locator('section.done');
|
||||
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
|
||||
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
|
||||
@@ -1196,7 +1215,7 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
await page.reload();
|
||||
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Skjul færdige' }).click();
|
||||
await projectAction(page, 'Skjul færdige');
|
||||
await expect(page.locator('section.done')).toHaveCount(0);
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
@@ -1272,6 +1291,51 @@ test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms',
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project header keeps its name readable.
|
||||
*
|
||||
* It was a flex row of eight controls and a heading, and the heading was the part
|
||||
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
|
||||
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
|
||||
* rather than the last necessary one. A name split mid-syllable reads as a
|
||||
* rendering fault, not as a wrap.
|
||||
*
|
||||
* Everything but the view switcher lives behind one button now. This measures the
|
||||
* heading rather than counting controls, because the thing being protected is that
|
||||
* the name is legible — not any particular arrangement of the row.
|
||||
*/
|
||||
test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('GarageRisteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// One line. The name is fifteen characters and the row now has room for it,
|
||||
// which is the whole point of moving the rest behind a button.
|
||||
const box = await heading.boundingBox();
|
||||
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
|
||||
|
||||
// And the actions are still reachable, just one click further in.
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
|
||||
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Escape closes it — a menu you can only leave by pressing its own button again
|
||||
// is one you click twice to get out of.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
|
||||
@@ -596,5 +596,10 @@ export const da = {
|
||||
'push.upToDate': 'Ingen nyere version fundet.',
|
||||
|
||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||
'project.roleFor': 'Rolle for {name}'
|
||||
'project.roleFor': 'Rolle for {name}',
|
||||
|
||||
'project.more': 'Flere handlinger',
|
||||
'project.removeMember': 'Fjern {name}',
|
||||
|
||||
'nav.closeMenu': 'Luk menu'
|
||||
};
|
||||
|
||||
@@ -591,5 +591,10 @@ export const en = {
|
||||
'push.upToDate': 'No newer version found.',
|
||||
|
||||
'view.nothingMoreToday': 'Nothing more today.',
|
||||
'project.roleFor': 'Role for {name}'
|
||||
'project.roleFor': 'Role for {name}',
|
||||
|
||||
'project.more': 'More actions',
|
||||
'project.removeMember': 'Remove {name}',
|
||||
|
||||
'nav.closeMenu': 'Close menu'
|
||||
};
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<button
|
||||
class="scrim"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
aria-label="Luk menu"
|
||||
aria-label={t('nav.closeMenu')}
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -140,6 +140,24 @@
|
||||
|
||||
let editing = $state(false);
|
||||
let choosingColor = $state(false);
|
||||
let showMenu = $state(false);
|
||||
|
||||
// Closes on a click anywhere else and on Escape. A menu that only closes by
|
||||
// pressing its own button again is one you end up clicking twice to leave.
|
||||
$effect(() => {
|
||||
if (!showMenu) return;
|
||||
const away = (event) => {
|
||||
if (!event.target.closest?.('.more')) showMenu = false;
|
||||
};
|
||||
const escape = (event) => event.key === 'Escape' && (showMenu = false);
|
||||
// Capture, so a click that its own handler stops still closes the menu.
|
||||
window.addEventListener('click', away, true);
|
||||
window.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
window.removeEventListener('click', away, true);
|
||||
window.removeEventListener('keydown', escape);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The colour is written to the store as well as here: the sidebar reads its own
|
||||
@@ -388,24 +406,6 @@
|
||||
{/if}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button
|
||||
class="color"
|
||||
style="background: {colorVar(project.color)}"
|
||||
onclick={() => (choosingColor = !choosingColor)}
|
||||
aria-expanded={choosingColor}
|
||||
aria-label={t('project.color')}
|
||||
title={t('project.color')}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="ghost"
|
||||
onclick={() => completedView.toggle()}
|
||||
aria-pressed={completedView.shown}
|
||||
>
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}
|
||||
<button
|
||||
@@ -416,25 +416,94 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showShare = !showShare)}>
|
||||
{project.shared ? t('project.sharedWith', { n: project.member_count }) : t('project.share')}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Everything else lives behind one button.
|
||||
The row had eight controls in it — title, colour, show-done, three view
|
||||
buttons, share, history, delete — and a project name is not a short
|
||||
word. They fitted only on a wide window, and past that the heading was
|
||||
the thing that gave way, which is the one part of the row that has to
|
||||
be readable.
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
|
||||
{t('project.history')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button class="remove" onclick={remove} aria-label={t('project.delete')}>
|
||||
What stays out is what you use while working: which view you are in.
|
||||
What moves in is what you do to a project rather than in it, and most
|
||||
of it once ever. -->
|
||||
<div class="more">
|
||||
<button
|
||||
class="more-toggle"
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
aria-expanded={showMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('project.more')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||
<circle cx="5" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="19" cy="12" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showMenu}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
completedView.toggle();
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showShare = !showShare;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{project.shared
|
||||
? t('project.sharedWith', { n: project.member_count })
|
||||
: t('project.share')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showLog = !showLog;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{t('project.history')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
choosingColor = !choosingColor;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<span class="swatch-dot" style="background: {colorVar(project.color)}"></span>
|
||||
{t('project.color')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
showMenu = false;
|
||||
remove();
|
||||
}}
|
||||
>
|
||||
{t('project.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showLog}
|
||||
@@ -445,7 +514,7 @@
|
||||
<!-- Empty when the account has been deleted: the record of what was
|
||||
done outlives whoever did it, so the row stays and the name goes. -->
|
||||
<span class="who" class:gone={!entry.user_name}
|
||||
>{entry.user_name || 'En slettet konto'}</span
|
||||
>{entry.user_name || t('history.deletedAccount')}</span
|
||||
>
|
||||
<span class="what">
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
@@ -509,7 +578,7 @@
|
||||
await api.removeMember(project.id, member.user_id);
|
||||
members = (await api.listMembers(project.id)).members;
|
||||
}}
|
||||
aria-label="Fjern {member.name}">×</button
|
||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -682,22 +751,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A quiet control: it changes what you are looking at, not what is there. It
|
||||
sits beside the view switcher because it is the same kind of decision. */
|
||||
.ghost {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ghost:hover,
|
||||
.ghost[aria-pressed='true'] {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Recessed, because it is a record rather than a plan. The rows keep their own
|
||||
completed styling; this is about the section around them. */
|
||||
.done {
|
||||
@@ -711,10 +764,6 @@
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||
.view:has(.board),
|
||||
.view:has(.calendar) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Wraps, and the title keeps a floor.
|
||||
*
|
||||
@@ -734,11 +783,15 @@
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
||||
point where the wrapping below is the better answer. */
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
overflow-wrap: anywhere;
|
||||
/* Takes the row, and lets the controls keep their own width. With the rest
|
||||
of them behind one button there is room for a real project name. */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
/* `break-word`, not `anywhere`. `anywhere` breaks inside a word at the first
|
||||
opportunity, so "GarageRisteriet" came out as "GarageRist / eriet" the
|
||||
moment the row was tight — a name split mid-syllable reads as a rendering
|
||||
fault. This only breaks a word that cannot fit on a line of its own. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||
@@ -779,6 +832,83 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- the overflow menu ------------------------------------------------------ */
|
||||
|
||||
.more {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.more-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.more-toggle:hover,
|
||||
.more-toggle[aria-expanded='true'] {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.more-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Right-aligned, because the button is at the right end of the row and a menu
|
||||
that opened leftward from there would hang off the page. */
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--s1));
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
min-width: 190px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--s1);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s2) var(--s3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu button:hover {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.menu button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.menu button.danger:hover {
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
@@ -797,34 +927,7 @@
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* A circle in the project's own colour, which is both the control and its own
|
||||
preview: the thing you are about to change is what you press. */
|
||||
.color {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: 0 0 0 2px var(--ground);
|
||||
transition: box-shadow var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.color:hover,
|
||||
.color:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ground),
|
||||
0 0 0 3px var(--line-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
@@ -887,20 +990,7 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share {
|
||||
flex: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
padding: var(--s1) var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-full);
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.share:hover {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
|
||||
Reference in New Issue
Block a user