diff --git a/e2e/check-form-alignment.mjs b/e2e/check-form-alignment.mjs index 9b4f6cf..d090a1f 100644 --- a/e2e/check-form-alignment.mjs +++ b/e2e/check-form-alignment.mjs @@ -1,47 +1,83 @@ -// Checks that each form label (.tabular forms, query filters) lines up with the field next to it. +// Checks .tabular forms and query filters at desktop, tablet landscape and phone width: +// - each form label lines up with the field next to it (not on phones, where labels stack) +// - field widths follow the three tracks (components.css): standard fields fill the column up +// to 480px, compact fields (numbers, dates) are 140px, long-text fields fill the column; +// on phones every field fills its row // Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px) import { chromium } from 'playwright'; const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070'; +const settingsTabs = ['general', 'display', 'authentication', 'api', 'projects', 'users', 'issues', 'timelog', 'attachments', 'mail_handler', 'repositories']; const pages = [ '/projects?display_type=list', '/projects/apollo/issues', '/projects/apollo/time_entries', '/projects/apollo/issues/new', '/issues/13/edit', '/issues/bulk_edit?ids[]=13&ids[]=17', - '/projects/apollo/time_entries/new', '/settings', '/my/account', '/users/5/edit', - '/projects/apollo/settings', '/custom_fields/1/edit', '/projects/apollo/wiki/Wiki/edit', + '/projects/apollo/time_entries/new', '/my/account', '/my/password', '/users/5/edit', + '/projects/apollo/settings', '/projects/apollo/settings/issues', '/custom_fields/1/edit', '/projects/apollo/wiki/Wiki/edit', + '/projects/apollo/wiki/Wiki/rename', '/projects/apollo/versions/new', '/projects/apollo/news/new', '/projects/apollo/documents/new', + '/projects/apollo/queries/new', '/projects/new', '/roles/new', '/trackers/new', '/groups/new', + ...settingsTabs.map((tab) => `/settings?tab=${tab}`), ]; const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); -await page.goto(`${BASE}/login`); -await page.fill('#username', 'admin'); -await page.fill('#password', 'admin'); -await page.click('#login-submit'); -await page.waitForURL((url) => !url.pathname.startsWith('/login')); - let failures = 0; -for (const path of pages) { - await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); - const rows = await page.evaluate(() => [ - // Query filters: label text vs. operator select - ...[...document.querySelectorAll('#filters-table .filter')].flatMap((row) => { - const label = row.querySelector('.field label'), control = row.querySelector('.operator select'); - if (!label || !control || !control.offsetParent) return []; - const l = label.getBoundingClientRect(), c = control.getBoundingClientRect(); - return [{ label: `filter ${label.textContent.trim().slice(0, 22)}`, offset: Math.round(l.top + l.height / 2 - (c.top + c.height / 2)) }]; - }), - ...[...document.querySelectorAll('.tabular p')].flatMap((row) => { - const label = row.querySelector(':scope > label:not(.floating):not(.block):not(.inline):not(.no-css)'); - const control = row.querySelector(':scope > input:not([type=hidden]), :scope > select, :scope > span > input:not([type=hidden]), :scope > span > select'); - if (!label || !control || !label.offsetParent || !control.offsetParent) return []; - const style = getComputedStyle(label); - const labelBox = label.getBoundingClientRect(); - const controlBox = control.getBoundingClientRect(); - const labelMiddle = labelBox.top + parseFloat(style.paddingTop) + parseFloat(style.lineHeight) / 2; - return [{ label: label.textContent.trim().slice(0, 30), offset: Math.round(labelMiddle - (controlBox.top + controlBox.height / 2)) }]; - }), - ]); - const bad = rows.filter((r) => Math.abs(r.offset) > 2); - failures += bad.length; - console.log(`${bad.length ? 'FAIL' : 'ok '} ${path} (${rows.length} rows)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`); +for (const width of [1440, 1024, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + await page.goto(`${BASE}/login`); + await page.fill('#username', 'admin'); + await page.fill('#password', 'admin'); + await page.click('#login-submit'); + await page.waitForURL((url) => !url.pathname.startsWith('/login')); + + for (const path of pages) { + await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); + const rows = await page.evaluate((stacked) => { + const visible = (el) => el && el.offsetParent; + const out = []; + if (!stacked) { + // Query filters: label text vs. operator select + for (const row of document.querySelectorAll('#filters-table .filter')) { + const label = row.querySelector('.field label'), control = row.querySelector('.operator select'); + if (!label || !visible(control)) continue; + const l = label.getBoundingClientRect(), c = control.getBoundingClientRect(); + out.push({ label: `filter ${label.textContent.trim().slice(0, 22)}`, offset: Math.round(l.top + l.height / 2 - (c.top + c.height / 2)) }); + } + // Labels vs. fields (and the default appearance groups on the Display tab) + for (const row of document.querySelectorAll('.tabular p')) { + const label = row.querySelector(':scope > label:not(.floating):not(.block):not(.inline):not(.no-css)'); + const control = row.querySelector(':scope > input:not([type=hidden]), :scope > select, :scope > span > input:not([type=hidden]), :scope > span > select, :scope > .nm-appearance > [role="group"]'); + if (!visible(label) || !visible(control)) continue; + const style = getComputedStyle(label); + const labelBox = label.getBoundingClientRect(); + const controlBox = control.getBoundingClientRect(); + const labelMiddle = labelBox.top + parseFloat(style.paddingTop) + parseFloat(style.lineHeight) / 2; + out.push({ label: label.textContent.trim().slice(0, 30), offset: Math.round(labelMiddle - (controlBox.top + controlBox.height / 2)) }); + } + } + // Field widths + const textLike = 'textarea, select:not([multiple]), input:is([type="text"], [type="password"], [type="email"], [type="url"], [type="search"], [type="tel"], [type="number"], [type="date"], :not([type]))'; + const compact = 'input:is([type="number"], [type="date"], .date, [size="1"], [size="2"], [size="3"], [size="4"], [size="5"], [size="6"])'; + const full = 'textarea, input:is(.string_cf, .link_cf, #issue_subject, #document_title, #content_comments, #time_entry_comments)'; + for (const field of document.querySelectorAll(`.tabular p > :is(${textLike}), .tabular p > span > :is(${textLike})`)) { + if (!visible(field)) continue; + // The project select shares its row with the floated "Private" checkbox + if (field.id === 'issue_project_id') continue; + const row = field.closest('p'); + const style = getComputedStyle(row); + const column = row.clientWidth - parseFloat(style.paddingInlineStart) - parseFloat(style.paddingInlineEnd); + const icon = field.nextElementSibling?.matches('.icon-only') ? 36 : 0; + const expected = stacked || field.matches(full) ? column - icon + : field.matches(compact) ? 140 + : Math.min(480, column) - icon; + const actual = field.getBoundingClientRect().width; + const name = row.querySelector(':scope > label')?.textContent.trim().slice(0, 30) || field.id || field.name; + out.push({ label: `width ${name}`, offset: Math.round(actual - expected) }); + } + return out; + }, width < 900); + const bad = rows.filter((r) => Math.abs(r.offset) > 2); + failures += bad.length; + console.log(`${bad.length ? 'FAIL' : 'ok '} ${width} ${path} (${rows.length} checks)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`); + } + await page.close(); } await browser.close(); diff --git a/tonalmine/stylesheets/components.css b/tonalmine/stylesheets/components.css index eb2d147..6c0acbd 100644 --- a/tonalmine/stylesheets/components.css +++ b/tonalmine/stylesheets/components.css @@ -178,18 +178,36 @@ blockquote { border-inline-start: 4px solid var(--md-sys-color-outline-variant); /* Core gives wide form fields 90%, 95% or 99% and caps .tabular fields at 95%. They all run to the content edge, so the right edges in a form line up */ .tabular :is(input, select) { max-inline-size: 100%; } +/* Core pins these to 90% by id */ #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select { inline-size: 100%; } -.tabular textarea, textarea.wiki-edit, textarea.text_cf, input.string_cf, input.link_cf, -textarea#custom_field_possible_values, textarea#custom_field_default_value, -input#issue_subject, input#document_title, input#content_comments, input#time_entry_comments, -.tabular .jstTabs, .tabular .wiki-preview, .jstBlock .jstTabs, .jstBlock .wiki-preview { inline-size: 100%; box-sizing: border-box; } +/* Desktop and tablet landscape: three field widths, so the right edges in a form line up. + Standard: selects and text fields fill the column up to 480px (wider than core's widest, + size=60). Compact: numbers and dates, 140px fits a date with its picker. Full: long text. + Below 900px core's responsive.css runs every field full width. */ +@media screen and (min-width: 900px) { + .tabular :is(select, input:is([type="text"], [type="password"], [type="email"], [type="url"], [type="search"], [type="tel"], :not([type]))) { + inline-size: 100%; + max-inline-size: 480px; + box-sizing: border-box; + } + .tabular :is(input[type="number"], input[type="date"], input.date, input:is([size="1"], [size="2"], [size="3"], [size="4"], [size="5"], [size="6"])) { inline-size: 140px; } + /* The icon button after a select (new category, new version) stays on its line */ + .tabular select:has(+ .icon-only) { inline-size: calc(100% - 36px); max-inline-size: calc(480px - 36px); } +} +html .tabular textarea, html textarea.wiki-edit, html textarea.text_cf, html input.string_cf, html input.link_cf, +html textarea#custom_field_possible_values, html textarea#custom_field_default_value, +html input#issue_subject, html input#document_title, html input#content_comments, html input#time_entry_comments, +html .tabular .jstTabs, html .tabular .wiki-preview, html .jstBlock .jstTabs, html .jstBlock .wiki-preview, +html .tabular.settings :is(textarea, .jstTabs, .wiki-preview) { inline-size: 100%; max-inline-size: none; box-sizing: border-box; } +/* Core pins % Done to 95px by id; it follows the standard width like the other selects */ +html select#issue_done_ratio { inline-size: 100%; } /* An invalid field keeps the error colour while focused */ .tabular label.error + :is(input, select, textarea):focus { border-color: var(--md-sys-color-error); outline-color: var(--md-sys-color-error); } /* Rows without a field start at the top, like a row with only links (two-factor auth) */ -.tabular p:not(:has(input:not([type=hidden]), select, textarea)) > label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 0; } +.tabular p:not(:has(input:not([type=hidden]), select, textarea, .nm-segmented)) > label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 0; } /* Cancel next to a submit button */ input[type="submit"] + a { margin-inline-start: 8px; } diff --git a/tonalmine/stylesheets/mobile.css b/tonalmine/stylesheets/mobile.css index e055bd4..71cc620 100644 --- a/tonalmine/stylesheets/mobile.css +++ b/tonalmine/stylesheets/mobile.css @@ -229,6 +229,10 @@ /* Form fields run full width; a select keeps room for the icon button after it */ html form .attributes select { inline-size: 100%; } html form .attributes select:has(+ .icon-only) { inline-size: calc(100% - 36px); } + /* Core leaves the tracker at 90%; only the project row shares its line (with "Private") */ + html #all_attributes #issue_tracker_id { inline-size: 100%; } + /* Core leaves dates at their content width; every field runs full width here */ + html .tabular input:is(.date, [type="date"]) { inline-size: 100%; max-inline-size: 100%; } html .tabular input[type="radio"] { inline-size: auto; } /* Query filters: operator and values run full width under the checkbox label instead of