refactor(admin): restructure the new-appointment modal around its real steps
The booking modal presented one flat scroll of fields whose order did not
match the order of the decisions behind them, and gave no reason when the
submit button stayed grey.
- Group the form into numbered steps (service+time, patient) so the order of
decisions is visible. The optional visit-price collapse stays unnumbered —
numbering an optional step reads as required.
- Show the first blocking condition above the footer instead of leaving a
disabled button unexplained.
- Label the header chip's facts ("device:", "supervising doctor:") and add the
appointment's Jalali date, which the modal never displayed at all.
- Replace the hand-rolled primary/ghost button pair with the design system's
`.seg` + `.on`, and announce state via aria-pressed.
- Move autoFocus off the patient search in picker mode; the first decision is
the section select above it.
- Give every input an id and its label an htmlFor.
- Surface a distinct error state for the slot query. A failed request used to
fall through to "not enough free time", which sent users to another day for
no reason.
- Raise the service remove button (18px), the duration pill and the time chips
to at least the 32px hit target; mark service rows role=checkbox.
- Modal close button gets an accessible name; `.field` controls stretch to the
full 40px box so the whole frame is clickable.
Runtime probe on the open modal goes from 4 unnamed icon controls, 1 unlabelled
field and 2 sub-32px controls to clean, across light/dark/compact/mobile.
The redesign-page probe now names the offending elements instead of only
counting them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -188,6 +188,32 @@ async function shot(url, opts) {
|
||||
await S('Page.navigate', { url });
|
||||
await new Promise((r) => setTimeout(r, opts.wait));
|
||||
|
||||
// مودالها فقط با تعامل باز میشوند و بدون این، نقدشان ممکن نیست: --click یک
|
||||
// متنِ دیدنی یا سلکتور میگیرد، اولین تطابق را میزند و منتظر رندر میماند.
|
||||
if (opts.click) {
|
||||
const clicked = await S('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `(() => {
|
||||
const q = ${JSON.stringify(opts.click)};
|
||||
let el = null;
|
||||
try { el = document.querySelector(q); } catch {}
|
||||
if (!el) {
|
||||
// دکمه بر لینک مقدم است: نامِ یکسان معمولاً هم در سایدبار (a) هست هم
|
||||
// روی خودِ صفحه (button)، و منظورِ نقد همیشه دومی است.
|
||||
const hits = [...document.querySelectorAll('button,[role=button],a,td,.slot,.tl-slot')]
|
||||
.filter((n) => (n.innerText || '').trim().includes(q) && n.offsetParent !== null);
|
||||
el = hits.find((n) => n.closest('nav,.sidebar') === null) ?? hits[0];
|
||||
}
|
||||
if (!el) return 'not found: ' + q;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
el.click();
|
||||
return 'clicked: ' + (el.innerText || el.className || el.tagName).slice(0, 60);
|
||||
})()`,
|
||||
});
|
||||
console.log(' CLICK', clicked?.result?.value ?? '—');
|
||||
await new Promise((r) => setTimeout(r, opts.clickWait ?? 1800));
|
||||
}
|
||||
|
||||
// تم بعد از hydrate ممکن است از استور دوباره خوانده شود؛ آخرین کلام با ما.
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `
|
||||
@@ -244,19 +270,33 @@ async function probeRuntime(S) {
|
||||
out.push('horizontal scroll: page is ' + document.documentElement.scrollWidth
|
||||
+ 'px wide in a ' + window.innerWidth + 'px viewport');
|
||||
}
|
||||
// شمارش تنها میگوید «۲ تا»، نه «کدام دو تا» — و حدس زدنش وقت تلف کردن است.
|
||||
const where = (el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
|
||||
const txt = (el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
||||
const near = el.closest('[class]');
|
||||
return tag + (el.id ? '#' + el.id : '') + (cls.length ? '.' + cls.join('.') : '')
|
||||
+ (txt ? ' «' + txt + '»' : '')
|
||||
+ (near && near !== el && typeof near.className === 'string'
|
||||
? ' ← in .' + near.className.trim().split(/\s+/)[0] : '');
|
||||
};
|
||||
const list = (arr) => arr.map(where).join(' · ');
|
||||
|
||||
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
|
||||
.filter(b => !(b.innerText || '').trim()
|
||||
&& !b.getAttribute('aria-label') && !b.getAttribute('title')).length;
|
||||
if (nameless) out.push(nameless + ' icon-only control(s) with no accessible name');
|
||||
&& !b.getAttribute('aria-label') && !b.getAttribute('title'));
|
||||
if (nameless.length) out.push(nameless.length + ' icon-only control(s) with no accessible name\n ' + list(nameless));
|
||||
const unlabelled = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
|
||||
.filter(i => !i.getAttribute('aria-label') && !i.getAttribute('aria-labelledby')
|
||||
&& !(i.id && document.querySelector('label[for="' + i.id + '"]'))
|
||||
&& !i.closest('label')).length;
|
||||
if (unlabelled) out.push(unlabelled + ' form field(s) with no label');
|
||||
&& !i.closest('label'));
|
||||
if (unlabelled.length) out.push(unlabelled.length + ' form field(s) with no label\n ' + list(unlabelled));
|
||||
const tiny = [...document.querySelectorAll('button, a')]
|
||||
.filter(b => { const r = b.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && r.height < 32; }).length;
|
||||
if (tiny) out.push(tiny + ' control(s) under 32px tall (44px is the touch target)');
|
||||
return r.width > 0 && r.height > 0 && r.height < 32; });
|
||||
if (tiny.length) out.push(tiny.length + ' control(s) under 32px tall (44px is the touch target)\n '
|
||||
+ tiny.map(b => where(b) + ' [' + Math.round(b.getBoundingClientRect().height) + 'px]').join(' · '));
|
||||
return out;
|
||||
})()`,
|
||||
});
|
||||
@@ -266,7 +306,7 @@ async function probeRuntime(S) {
|
||||
}
|
||||
|
||||
/** چهار نمای اجباریِ هر بازطراحی: روشن، تیره، فشرده، موبایل. */
|
||||
async function variants(url, dir) {
|
||||
async function variants(url, dir, extra = {}) {
|
||||
const slug = new URL(url).pathname.replace(/^\/admin\/?/, '').replace(/\W+/g, '-') || 'page';
|
||||
const runs = [
|
||||
{ name: 'light', w: 1440, h: 900, theme: 'light', density: 'comfortable' },
|
||||
@@ -280,7 +320,7 @@ async function variants(url, dir) {
|
||||
await shot(url, {
|
||||
out: `${dir}/${slug}-${r.name}.png`,
|
||||
w: r.w, h: r.h, wait: 5000, full: true, probe: true,
|
||||
theme: r.theme, density: r.density, context: null,
|
||||
theme: r.theme, density: r.density, context: null, ...extra,
|
||||
});
|
||||
}
|
||||
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
|
||||
@@ -408,9 +448,14 @@ if (cmd === 'shot' && arg) {
|
||||
theme: flag('theme', 'light'),
|
||||
density: flag('density', 'comfortable'),
|
||||
context: flag('context', null),
|
||||
click: flag('click', null),
|
||||
clickWait: Number(flag('click-wait', 1800)),
|
||||
});
|
||||
} else if (cmd === 'variants' && arg) {
|
||||
await variants(arg, flag('dir', '/tmp/clinicpro-review'));
|
||||
await variants(arg, flag('dir', '/tmp/clinicpro-review'), {
|
||||
click: flag('click', null),
|
||||
clickWait: Number(flag('click-wait', 1800)),
|
||||
});
|
||||
} else if (cmd === 'inspect' && arg) {
|
||||
inspect(arg);
|
||||
} else if (cmd === 'audit' && arg) {
|
||||
|
||||
Reference in New Issue
Block a user