Merge pull request 'fix(ptica): drawer modal a11y (focus-trap + restore) + polish' (#1842) from feat/ptica-a11y-polish into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m12s

Reviewed-on: #1842
This commit is contained in:
lekss361 2026-06-20 18:02:38 +00:00
commit f80bdd751b
9 changed files with 111 additions and 63 deletions

View file

@ -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;

View file

@ -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}>

View file

@ -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="Открыть детализацию"
>

View file

@ -72,7 +72,7 @@ export function InvestScoreBlock({
<button
type="button"
className={styles.detailBtn}
className={`${styles.detailBtn} ${styles.detailBtnScan}`}
onClick={() => onOpenDrawer("potential")}
title="Открыть расчёт ёмкости"
>

View file

@ -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 {

View file

@ -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"

View file

@ -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

View file

@ -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>

View file

@ -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>