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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1da7ea64bf
commit
eaff890860
+330
-9
@@ -156,6 +156,7 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
// Only an administrator sees these two, and the account these tests run as
|
||||
// is the first account, which is one.
|
||||
'Brugere',
|
||||
'Historik',
|
||||
'Fejl',
|
||||
'Data og skabeloner'
|
||||
]) {
|
||||
@@ -263,8 +264,8 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
// `exact`, because the heading's colour dot is a button too and its label is
|
||||
// "Farve på Arbejde" — and Playwright matches an accessible name by substring
|
||||
// `exact`, because a group's name is a user-chosen word that can turn up inside
|
||||
// another button's name — Playwright matches an accessible name by substring
|
||||
// unless told otherwise.
|
||||
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
|
||||
await expect(heading).toBeVisible();
|
||||
@@ -311,16 +312,34 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
);
|
||||
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click();
|
||||
|
||||
// Colour, and indentation. The dot is the control as well as the preview, so
|
||||
// the thing you press is the thing you are about to change.
|
||||
await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click();
|
||||
// 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();
|
||||
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
|
||||
// button on click.
|
||||
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) =>
|
||||
getComputedStyle(el).backgroundColor
|
||||
);
|
||||
const painted = await sidebar
|
||||
.locator('.folder-head .group-dot')
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// The heading itself is *not* indented — only what is filed under it. A heading
|
||||
// that starts further in than the rows above it says the heading is inside
|
||||
// something, which it is not.
|
||||
const headLeft = await sidebar
|
||||
.locator('.folder-head')
|
||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||
const looseLeft = await sidebar
|
||||
.locator('.views a')
|
||||
.first()
|
||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
||||
|
||||
// A project inside a group starts further in than a row outside one. Measured
|
||||
// rather than asserted on a class, because indentation is a fact about where
|
||||
// the row's contents land — and measured at the *content* edge, not the box:
|
||||
@@ -465,7 +484,7 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
||||
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
||||
// hours of every day they disagree about what today is.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Kalender' }).click();
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
||||
await expect(chip).toBeVisible();
|
||||
@@ -804,6 +823,139 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task dragged across a month boundary.
|
||||
*
|
||||
* The month grid could not do this. It is anchored to a month, so the two days on
|
||||
* either side of its edge sit in different grids, and getting from one to the other
|
||||
* means paging — which cannot be done mid-drag, because a drag in flight swallows
|
||||
* the click that would page it. The week view exists for exactly this: a week that
|
||||
* straddles the 31st and the 1st has both days in the same row.
|
||||
*
|
||||
* The dates are worked out in the browser, not here. Playwright pins the page to
|
||||
* Europe/Copenhagen and this process is not pinned to anything, so for two hours of
|
||||
* every day the two disagree about what today is.
|
||||
*/
|
||||
test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// The first day, starting tomorrow, whose next day is in another month *and* in
|
||||
// the same Monday-to-Sunday week. Such a pair exists at every month boundary
|
||||
// except one that falls between a Sunday and a Monday, so searching forward
|
||||
// finds one within about two months whatever today is.
|
||||
const { from, to } = await page.evaluate(() => {
|
||||
const iso = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
const monday = (d) => {
|
||||
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
|
||||
return iso(m);
|
||||
};
|
||||
const day = new Date();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
day.setDate(day.getDate() + 1);
|
||||
const next = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
||||
if (day.getMonth() !== next.getMonth() && monday(day) === monday(next)) {
|
||||
return { from: iso(day), to: iso(next) };
|
||||
}
|
||||
}
|
||||
throw new Error('no month boundary inside a week in the next 90 days');
|
||||
});
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(`skifte dæk ${from}`);
|
||||
await box.press('Enter');
|
||||
// Not asserted here: this is the Today view and the task is dated weeks out, so
|
||||
// it is correctly absent. The week grid below is where it has to turn up.
|
||||
await expect(box).toHaveValue('');
|
||||
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
// Forward a week at a time until the row is the one holding that date. Bounded,
|
||||
// because a loop that pages forever on a broken button is a timeout with no
|
||||
// explanation in it.
|
||||
const cell = page.locator(`[data-date="${from}"]`);
|
||||
const next = page.getByRole('button', { name: 'Næste uge' });
|
||||
for (let i = 0; i < 15 && (await cell.count()) === 0; i++) {
|
||||
await next.click();
|
||||
}
|
||||
await expect(cell, `uge-visningen nåede aldrig frem til ${from}`).toBeVisible();
|
||||
|
||||
// The claim the view is for: both sides of the month boundary, in one row.
|
||||
await expect(page.locator(`[data-date="${to}"]`)).toBeVisible();
|
||||
expect(from.slice(0, 7), 'de to dage skulle ligge i hver sin måned').not.toEqual(to.slice(0, 7));
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
||||
|
||||
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
||||
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The instance-wide history.
|
||||
*
|
||||
* The per-project panel has shown the same rows for a while; this is the question
|
||||
* it cannot answer, which is what happened in a project the administrator is not a
|
||||
* member of. The test uses a second person's own project for exactly that reason —
|
||||
* asserting against a project the admin can already see would pass with the old
|
||||
* per-project endpoint behind it.
|
||||
*/
|
||||
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
|
||||
browser,
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// A second account, with a project of its own. storageState is spelled out:
|
||||
// a "fresh" context inherits the signed-in one otherwise, which is the single
|
||||
// case this test is not about.
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const theirs = await context.newPage();
|
||||
const email = `historik-${Date.now()}@example.dk`;
|
||||
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill(email);
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
expect(link).toContain('/invite?token=');
|
||||
|
||||
await theirs.goto(link);
|
||||
await theirs.getByLabel('Navn').fill('Sigrid');
|
||||
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
const sidebar = theirs.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Sigrids eget');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Sigrids eget' })).toBeVisible();
|
||||
|
||||
// The administrator cannot open that project, and can still see that it happened.
|
||||
await page.goto('/indstillinger/historik');
|
||||
const rows = page.locator('.rows li').filter({ hasText: 'Sigrids eget' });
|
||||
await expect(rows.first()).toBeVisible();
|
||||
await expect(rows.first().getByText('oprettede projektet')).toBeVisible();
|
||||
// `exact`, or this matches the project name "Sigrids eget" in the same row.
|
||||
await expect(rows.first().getByText('Sigrid', { exact: true })).toBeVisible();
|
||||
|
||||
// And the filters narrow it rather than emptying it.
|
||||
await page.getByLabel('Hændelse').selectOption('project.created');
|
||||
await expect(page.locator('.rows li').filter({ hasText: 'Sigrids eget' }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Ryd' }).click();
|
||||
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project page on a phone.
|
||||
*
|
||||
@@ -854,6 +1006,175 @@ test('projektsiden er brugbar på en telefon', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
* It grew a horizontal scrollbar on an ordinary desktop, under the whole menu.
|
||||
* The cause was one flex item: the account name had `overflow: hidden` and no
|
||||
* `min-width: 0`, so it refused to shrink below its content and pushed the row
|
||||
* past the column — and `overflow-y: auto` on the sidebar resolves to `auto` on
|
||||
* the other axis too, which turned "a row that is too wide" into a scrollbar.
|
||||
*
|
||||
* Asserted with a long name, because the name is the part that varies and a short
|
||||
* one hides the whole class of bug. The mobile test measures the *document*; this
|
||||
* measures the sidebar itself, which is a different box and was never checked.
|
||||
*/
|
||||
test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
// A name long enough to overflow the column at its default width.
|
||||
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// And the whole page, for the same reason at a different scale.
|
||||
const page_sideways = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||
);
|
||||
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// Put the name back, so the tests after this one see the account they expect.
|
||||
await page.getByLabel('Navn').fill(USER.name);
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A project can have more than one section.
|
||||
*
|
||||
* Reported from use: sections "have no function, because you cannot create more
|
||||
* than one". The API makes as many as you ask for — there is a Go test that makes
|
||||
* three — so whatever this is, it is in the page. Every other test here makes
|
||||
* exactly one section, which is the shape of test that cannot see it.
|
||||
*/
|
||||
test('et projekt kan have flere sektioner @forms', 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('Ombygning');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Ombygning' })).toBeVisible();
|
||||
|
||||
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await expect(field, `feltet kom ikke frem for "${name}"`).toBeVisible();
|
||||
await field.fill(name);
|
||||
await field.press('Enter');
|
||||
await expect(
|
||||
page.getByRole('heading', { name, exact: true }),
|
||||
`sektionen "${name}" kom ikke på siden`
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// All three at once, still there — an each-block that threw would have stopped
|
||||
// rendering at the one that broke it.
|
||||
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
||||
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// And they survive a reload, which is the difference between "the page kept up"
|
||||
// and "the server took it".
|
||||
await page.reload();
|
||||
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
||||
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// The board is where somebody actually looks for a section, because a column on
|
||||
// a board *is* one — and it had no way to make one at all. Reported as
|
||||
// "sections have no function: there is no way to create them", which from
|
||||
// inside a board was exactly true.
|
||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
||||
await field.fill('Afsluttet');
|
||||
await field.press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Form fields on a touch device are at least 16px.
|
||||
*
|
||||
* Not a matter of taste. Below 16px, iOS Safari zooms the page in when a field
|
||||
* takes focus and does not zoom back out when it is blurred — so the person is
|
||||
* left with the whole interface too large and has to pinch it back by hand, once
|
||||
* per field they tap. The type scale tops out at 15px, so every input in the app
|
||||
* did it; the task drawer worst, being four fields deep.
|
||||
*
|
||||
* Chromium cannot reproduce the zoom, but it can be asked the question that causes
|
||||
* it, which is what this checks. A touch context rather than a narrow viewport:
|
||||
* the rule is keyed on `(hover: none) and (pointer: coarse)`, because an iPad does
|
||||
* the same thing at a width no phone breakpoint would catch.
|
||||
*/
|
||||
test('felter er mindst 16px, så iOS ikke zoomer ind og bliver der', async ({ browser }) => {
|
||||
const phone = { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true };
|
||||
|
||||
const tooSmall = async (page, where) => {
|
||||
const bad = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('input, textarea, select')]
|
||||
.filter((el) => el.getClientRects().length)
|
||||
.map((el) => ({
|
||||
what: el.tagName.toLowerCase() + (el.type ? `[type=${el.type}]` : ''),
|
||||
size: parseFloat(getComputedStyle(el).fontSize)
|
||||
}))
|
||||
.filter((f) => f.size < 16)
|
||||
);
|
||||
expect(bad, `for små felter på ${where}`).toEqual([]);
|
||||
};
|
||||
|
||||
// The sign-in form, in a context with the session spelled out as empty —
|
||||
// newContext inherits the project's storageState, so a "fresh" browser is
|
||||
// otherwise already signed in and the form never renders.
|
||||
const signedOut = await browser.newContext({
|
||||
...phone,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const anon = await signedOut.newPage();
|
||||
await anon.goto('/');
|
||||
await expect(anon.getByRole('button', { name: 'Log ind' })).toBeVisible();
|
||||
await tooSmall(anon, 'login-siden');
|
||||
await signedOut.close();
|
||||
|
||||
const context = await browser.newContext(phone);
|
||||
const page = await context.newPage();
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByLabel('Ny opgave')).toBeVisible();
|
||||
await tooSmall(page, 'I dag');
|
||||
|
||||
// The task drawer, which is where it was worst: four fields deep. Dated today,
|
||||
// or it goes to the Inbox and is correctly absent from the view being looked at.
|
||||
await page.getByLabel('Ny opgave').fill('slibe gulvet i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('slibe gulvet', { exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||
await tooSmall(page, 'opgavedetaljer');
|
||||
|
||||
// And the settings pages, which are almost entirely fields.
|
||||
await page.goto('/indstillinger');
|
||||
await expect(page.locator('section.panel').first()).toBeVisible();
|
||||
await tooSmall(page, 'indstillinger');
|
||||
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
|
||||
@@ -65,6 +65,32 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
/**
|
||||
* A short WebKit pass over the forms, and nothing else.
|
||||
*
|
||||
* The rest of this suite is Chromium only on purpose: a smoke test exists to
|
||||
* catch a build that does not work, and running it in three engines finds the
|
||||
* same bug three times. Focus is the exception, and it cost a real report to
|
||||
* learn it. Safari ignores the `autofocus` attribute on anything inserted
|
||||
* after the page has loaded — so every inline form in this app opened with no
|
||||
* cursor in it, and what you typed went nowhere. 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.
|
||||
*
|
||||
* One test, not four. The engines share this server and this database, so a
|
||||
* second pass over tests that create projects by name collides with what the
|
||||
* first pass already made — which fails as a name that is suddenly ambiguous
|
||||
* rather than as anything about WebKit. The sections test is the one that
|
||||
* earns the second engine, because it is the one the focus bug broke.
|
||||
*
|
||||
* WebKit here is the same engine Safari is, which is the whole point.
|
||||
*/
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
use: { ...devices['Desktop Safari'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -407,6 +407,32 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
/* Form fields are 16px on a touch device, whatever the component asked for.
|
||||
*
|
||||
* Below 16px, iOS Safari zooms the page in when a field takes focus — and it does
|
||||
* not zoom back out when the field is blurred. The person is left with the whole
|
||||
* interface too large and has to pinch it back themselves, once per field they
|
||||
* tap. The type scale here tops out at 15px, so every input in the app did it:
|
||||
* quick add, the sign-in form, and the task drawer worst of all, which is four
|
||||
* fields deep.
|
||||
*
|
||||
* `!important` on purpose. This is a browser workaround that has to beat every
|
||||
* component's own typography, and each of those is a scoped class that outranks
|
||||
* any selector this file could write. Narrowing it further would mean editing the
|
||||
* sixty-nine places that set a field's font size and adding a rule none of them
|
||||
* can be allowed to break.
|
||||
*
|
||||
* Touch rather than a width: an iPad in Safari does the same thing at a width no
|
||||
* phone breakpoint would catch.
|
||||
*/
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?.());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user