fix(ptica): drawer modal a11y (focus-trap + restore) + polish
PticaDrawer: proper modal focus management — - focus-trap: Tab/Shift+Tab wrap inside the dialog (focusables re-queried per Tab so it survives lazy panels/sub-tabs), focus-already-outside pulled back in; - focus-restore: the trigger element is captured on open and re-focused on close (guards null + detached node); - onClose held in a ref so the effect depends on [open] only — fixes focus thrashing when the host's URL/searchParams change mid-open. Esc/scrim close, body-scroll-lock, role=dialog/aria-modal preserved. Cleanups (code-review follow-ups): - MassingScene: drop duplicate initial mass build (floors/sections effect owns it); corrected the effect-ordering comment. - Static inline styles → CSS-module classes (scan/gauge/score/spaced rows). - DrawerPrimitives: remove !important on .dtabActive via specificity (.dtabs button.dtabActive). - Remove unused SoonCard component + its CSS. tsc/lint/prettier clean; next build green (route bundled). ptica-only, no any.
This commit is contained in:
parent
06fa5b4910
commit
88865bf6a7
9 changed files with 111 additions and 63 deletions
|
|
@ -329,6 +329,11 @@
|
|||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* «Подробнее» pinned to the bottom-left of a flex-column card (scan / score). */
|
||||
.detailBtnScan {
|
||||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ===================== HERO GRID ===================== */
|
||||
.hero {
|
||||
|
|
@ -438,6 +443,11 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* compact, centred title above a radial gauge. */
|
||||
.cardTitleGauge {
|
||||
font-size: 9.5px;
|
||||
text-align: center;
|
||||
}
|
||||
.scoreStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -625,6 +635,13 @@
|
|||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
}
|
||||
/* Risks card centres its gauge; title stays left-aligned like the other cards. */
|
||||
.scanCardCentered {
|
||||
align-items: center;
|
||||
}
|
||||
.scanCardCentered .cardTitle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.emptyState {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
|
@ -805,6 +822,10 @@
|
|||
font-size: 11px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
/* sc-kvrow that sits below a table/segment block — adds top separation. */
|
||||
.scKvRowSpaced {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.scK {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
|
@ -1575,29 +1596,6 @@
|
|||
.drawerLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.soonCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-align: center;
|
||||
padding: 22px 16px;
|
||||
border-style: dashed;
|
||||
}
|
||||
.soonCard p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-soft);
|
||||
max-width: 320px;
|
||||
}
|
||||
.roadmap {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* ===================== 3D MASSING SANDBOX (Three.js) ===================== */
|
||||
.massingWrap {
|
||||
display: flex;
|
||||
|
|
@ -1784,9 +1782,10 @@
|
|||
.dtabs button:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dtabActive {
|
||||
color: var(--text-strong) !important;
|
||||
border-bottom-color: var(--accent-cyan) !important;
|
||||
/* specific enough to beat `.dtabs button` without !important. */
|
||||
.dtabs button.dtabActive {
|
||||
color: var(--text-strong);
|
||||
border-bottom-color: var(--accent-cyan);
|
||||
}
|
||||
.dtabPanel {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -41,10 +41,7 @@ export function BuildabilityGauge({ gauge, title }: Props) {
|
|||
|
||||
return (
|
||||
<div className={styles.gaugePanel}>
|
||||
<div
|
||||
className={styles.cardTitle}
|
||||
style={{ fontSize: "9.5px", textAlign: "center" }}
|
||||
>
|
||||
<div className={`${styles.cardTitle} ${styles.cardTitleGauge}`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.gauge}>
|
||||
|
|
|
|||
|
|
@ -69,17 +69,13 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
|||
|
||||
<div
|
||||
id="risks"
|
||||
className={`${styles.card} ${styles.scanCard}`}
|
||||
style={{ alignItems: "center" }}
|
||||
className={`${styles.card} ${styles.scanCard} ${styles.scanCardCentered}`}
|
||||
>
|
||||
<h3 className={styles.cardTitle} style={{ alignSelf: "flex-start" }}>
|
||||
Риски
|
||||
</h3>
|
||||
<h3 className={styles.cardTitle}>Риски</h3>
|
||||
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
style={{ alignSelf: "flex-start", marginTop: "auto" }}
|
||||
className={`${styles.detailBtn} ${styles.detailBtnScan}`}
|
||||
onClick={() => onOpenDrawer("risks")}
|
||||
title="Открыть детализацию"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function InvestScoreBlock({
|
|||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
className={`${styles.detailBtn} ${styles.detailBtnScan}`}
|
||||
onClick={() => onOpenDrawer("potential")}
|
||||
title="Открыть расчёт ёмкости"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -243,24 +243,6 @@ export function DrawerActions({ labels }: { labels: string[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Placeholder card (СКОРО) ──────────────────────────────────────────────────
|
||||
|
||||
export function SoonCard({
|
||||
text,
|
||||
roadmap,
|
||||
}: {
|
||||
text: string;
|
||||
roadmap?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${styles.card} ${styles.soonCard}`}>
|
||||
<div className={styles.soon}>СКОРО</div>
|
||||
<p>{text}</p>
|
||||
{roadmap && <div className={styles.roadmap}>{roadmap}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-tabs (.dtabs / .dtab-panel) ───────────────────────────────────────────
|
||||
|
||||
export interface DrawerTab {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@
|
|||
* - the close button is focused on open (a11y),
|
||||
* - body scroll is locked while open.
|
||||
*
|
||||
* Accessible-dialog hardening (a11y follow-up):
|
||||
* - focus-restore: the element focused before the drawer opened (the trigger)
|
||||
* is captured on open and re-focused on close/unmount,
|
||||
* - focus-trap: Tab / Shift+Tab wrap inside the dialog so keyboard focus can
|
||||
* never escape behind the scrim. Focusables are re-queried on each Tab so the
|
||||
* trap survives drawer-content changes (lazy panels, sub-tabs).
|
||||
*
|
||||
* `role="dialog"` + `aria-modal` + `aria-label` make it a proper modal surface.
|
||||
* The component is fully controlled — `open` drives mount/visibility, the host
|
||||
* (PticaPageContent) owns the open-key state and URL deep-link.
|
||||
|
|
@ -28,15 +35,72 @@ interface Props {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||
const dialogRef = useRef<HTMLElement | null>(null);
|
||||
/** Element focused right before the drawer opened — restored on close. */
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
// Hold the latest onClose in a ref so the effect can depend on [open] only.
|
||||
// (onClose's identity changes when the host's URL/searchParams change; keeping
|
||||
// it out of the deps stops the effect re-running mid-open and thrashing focus.)
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Esc-to-close + body-scroll-lock + focus the close button on open.
|
||||
// Esc-to-close + body-scroll-lock + focus the close button on open +
|
||||
// focus-trap (Tab/Shift+Tab wrap) + focus-restore to the trigger on close.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Capture the trigger (the element that had focus when the drawer opened)
|
||||
// so it can be re-focused on close — standard accessible-dialog behaviour.
|
||||
triggerRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
// Re-query focusables on every Tab so the trap survives content changes
|
||||
// (lazy-mounted panels, sub-tabs) rather than caching a stale list.
|
||||
const focusables = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter((el) => !el.hasAttribute("disabled") && el.tabIndex !== -1);
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const activeEl =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab on the first (or focus already outside) → wrap to last.
|
||||
if (activeEl === first || !dialog.contains(activeEl)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab on the last (or focus already outside) → wrap to first.
|
||||
if (activeEl === last || !dialog.contains(activeEl)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
|
|
@ -49,8 +113,15 @@ export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
|||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
|
||||
// Restore focus to the trigger if it is still in the DOM and focusable.
|
||||
const trigger = triggerRef.current;
|
||||
triggerRef.current = null;
|
||||
if (trigger && document.contains(trigger)) {
|
||||
trigger.focus();
|
||||
}
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -60,6 +131,7 @@ export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
ref={dialogRef}
|
||||
className={`${styles.drawer} ${open ? styles.drawerOpen : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
|
|||
|
|
@ -504,8 +504,10 @@ export default function MassingScene({
|
|||
scene.add(sun.target);
|
||||
sunRef.current = sun;
|
||||
|
||||
// initial mass + sun
|
||||
rebuildMass(computeModel(parcelArea, farTarget, floors, sections));
|
||||
// initial sun. The initial mass is built by the dedicated floors/sections
|
||||
// effect below, which runs right after this mount effect in the same commit
|
||||
// (after paint, like all passive effects) now that sceneRef is set — the RAF
|
||||
// render loop paints it on the next frame, so no duplicate build here.
|
||||
placeSun(hour);
|
||||
|
||||
// resize handling
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export function HorizonForecast({
|
|||
</tbody>
|
||||
</table>
|
||||
{segmentLabel && (
|
||||
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
||||
<div className={`${styles.scKvRow} ${styles.scKvRowSpaced}`}>
|
||||
<span className={styles.scK}>Сегмент</span>
|
||||
<span className={styles.scV}>{segmentLabel}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export function RecommendedProduct({
|
|||
)}
|
||||
|
||||
{targetPrice && (
|
||||
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
||||
<div className={`${styles.scKvRow} ${styles.scKvRowSpaced}`}>
|
||||
<span className={styles.scK}>Целевая цена</span>
|
||||
<span className={styles.scV}>{targetPrice}</span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue