fix(ptica): drawer modal a11y (focus-trap + restore) + polish #1842
9 changed files with 111 additions and 63 deletions
|
|
@ -329,6 +329,11 @@
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
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 GRID ===================== */
|
||||||
.hero {
|
.hero {
|
||||||
|
|
@ -438,6 +443,11 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
/* compact, centred title above a radial gauge. */
|
||||||
|
.cardTitleGauge {
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.scoreStack {
|
.scoreStack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -625,6 +635,13 @@
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
align-self: flex-start;
|
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 {
|
.emptyState {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
@ -805,6 +822,10 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
|
/* sc-kvrow that sits below a table/segment block — adds top separation. */
|
||||||
|
.scKvRowSpaced {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
.scK {
|
.scK {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
@ -1575,29 +1596,6 @@
|
||||||
.drawerLink:hover {
|
.drawerLink:hover {
|
||||||
text-decoration: underline;
|
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) ===================== */
|
/* ===================== 3D MASSING SANDBOX (Three.js) ===================== */
|
||||||
.massingWrap {
|
.massingWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1784,9 +1782,10 @@
|
||||||
.dtabs button:hover {
|
.dtabs button:hover {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.dtabActive {
|
/* specific enough to beat `.dtabs button` without !important. */
|
||||||
color: var(--text-strong) !important;
|
.dtabs button.dtabActive {
|
||||||
border-bottom-color: var(--accent-cyan) !important;
|
color: var(--text-strong);
|
||||||
|
border-bottom-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
.dtabPanel {
|
.dtabPanel {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,7 @@ export function BuildabilityGauge({ gauge, title }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.gaugePanel}>
|
<div className={styles.gaugePanel}>
|
||||||
<div
|
<div className={`${styles.cardTitle} ${styles.cardTitleGauge}`}>
|
||||||
className={styles.cardTitle}
|
|
||||||
style={{ fontSize: "9.5px", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.gauge}>
|
<div className={styles.gauge}>
|
||||||
|
|
|
||||||
|
|
@ -69,17 +69,13 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="risks"
|
id="risks"
|
||||||
className={`${styles.card} ${styles.scanCard}`}
|
className={`${styles.card} ${styles.scanCard} ${styles.scanCardCentered}`}
|
||||||
style={{ alignItems: "center" }}
|
|
||||||
>
|
>
|
||||||
<h3 className={styles.cardTitle} style={{ alignSelf: "flex-start" }}>
|
<h3 className={styles.cardTitle}>Риски</h3>
|
||||||
Риски
|
|
||||||
</h3>
|
|
||||||
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
|
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.detailBtn}
|
className={`${styles.detailBtn} ${styles.detailBtnScan}`}
|
||||||
style={{ alignSelf: "flex-start", marginTop: "auto" }}
|
|
||||||
onClick={() => onOpenDrawer("risks")}
|
onClick={() => onOpenDrawer("risks")}
|
||||||
title="Открыть детализацию"
|
title="Открыть детализацию"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export function InvestScoreBlock({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.detailBtn}
|
className={`${styles.detailBtn} ${styles.detailBtnScan}`}
|
||||||
onClick={() => onOpenDrawer("potential")}
|
onClick={() => onOpenDrawer("potential")}
|
||||||
title="Открыть расчёт ёмкости"
|
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) ───────────────────────────────────────────
|
// ── Sub-tabs (.dtabs / .dtab-panel) ───────────────────────────────────────────
|
||||||
|
|
||||||
export interface DrawerTab {
|
export interface DrawerTab {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@
|
||||||
* - the close button is focused on open (a11y),
|
* - the close button is focused on open (a11y),
|
||||||
* - body scroll is locked while open.
|
* - 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.
|
* `role="dialog"` + `aria-modal` + `aria-label` make it a proper modal surface.
|
||||||
* The component is fully controlled — `open` drives mount/visibility, the host
|
* The component is fully controlled — `open` drives mount/visibility, the host
|
||||||
* (PticaPageContent) owns the open-key state and URL deep-link.
|
* (PticaPageContent) owns the open-key state and URL deep-link.
|
||||||
|
|
@ -28,15 +35,72 @@ interface Props {
|
||||||
children: React.ReactNode;
|
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) {
|
export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
||||||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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) {
|
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);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
|
@ -49,8 +113,15 @@ export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
document.body.style.overflow = prevOverflow;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -60,6 +131,7 @@ export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
|
ref={dialogRef}
|
||||||
className={`${styles.drawer} ${open ? styles.drawerOpen : ""}`}
|
className={`${styles.drawer} ${open ? styles.drawerOpen : ""}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
|
||||||
|
|
@ -504,8 +504,10 @@ export default function MassingScene({
|
||||||
scene.add(sun.target);
|
scene.add(sun.target);
|
||||||
sunRef.current = sun;
|
sunRef.current = sun;
|
||||||
|
|
||||||
// initial mass + sun
|
// initial sun. The initial mass is built by the dedicated floors/sections
|
||||||
rebuildMass(computeModel(parcelArea, farTarget, 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);
|
placeSun(hour);
|
||||||
|
|
||||||
// resize handling
|
// resize handling
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ export function HorizonForecast({
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{segmentLabel && (
|
{segmentLabel && (
|
||||||
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
<div className={`${styles.scKvRow} ${styles.scKvRowSpaced}`}>
|
||||||
<span className={styles.scK}>Сегмент</span>
|
<span className={styles.scK}>Сегмент</span>
|
||||||
<span className={styles.scV}>{segmentLabel}</span>
|
<span className={styles.scV}>{segmentLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export function RecommendedProduct({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{targetPrice && (
|
{targetPrice && (
|
||||||
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
<div className={`${styles.scKvRow} ${styles.scKvRowSpaced}`}>
|
||||||
<span className={styles.scK}>Целевая цена</span>
|
<span className={styles.scK}>Целевая цена</span>
|
||||||
<span className={styles.scV}>{targetPrice}</span>
|
<span className={styles.scV}>{targetPrice}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue