fix: align form field widths across the app
From 900px up, selects and text fields fill their column up to 480px, numbers and dates are 140px, and long text stays full width, so every form shares one right edge. Below 900px every field runs full width. Overrides core's id-pinned widths (% Done, mobile dates, settings textareas). check-form-alignment.mjs now checks widths at 1440, 1024 and 390px across all settings tabs and admin forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
20b7269df2
commit
db58c6e86f
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user