Compare commits
No commits in common. "f80bdd751b478b9454ca26462927ab7cc19c50dc" and "b57bd2e3be7a52ef83ff712c76e3b5e18b74bef7" have entirely different histories.
f80bdd751b
...
b57bd2e3be
9 changed files with 63 additions and 111 deletions
|
|
@ -329,11 +329,6 @@
|
||||||
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 {
|
||||||
|
|
@ -443,11 +438,6 @@
|
||||||
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;
|
||||||
|
|
@ -635,13 +625,6 @@
|
||||||
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;
|
||||||
|
|
@ -822,10 +805,6 @@
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -1596,6 +1575,29 @@
|
||||||
.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;
|
||||||
|
|
@ -1782,10 +1784,9 @@
|
||||||
.dtabs button:hover {
|
.dtabs button:hover {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
/* specific enough to beat `.dtabs button` without !important. */
|
.dtabActive {
|
||||||
.dtabs button.dtabActive {
|
color: var(--text-strong) !important;
|
||||||
color: var(--text-strong);
|
border-bottom-color: var(--accent-cyan) !important;
|
||||||
border-bottom-color: var(--accent-cyan);
|
|
||||||
}
|
}
|
||||||
.dtabPanel {
|
.dtabPanel {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@ export function BuildabilityGauge({ gauge, title }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.gaugePanel}>
|
<div className={styles.gaugePanel}>
|
||||||
<div className={`${styles.cardTitle} ${styles.cardTitleGauge}`}>
|
<div
|
||||||
|
className={styles.cardTitle}
|
||||||
|
style={{ fontSize: "9.5px", textAlign: "center" }}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.gauge}>
|
<div className={styles.gauge}>
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,17 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="risks"
|
id="risks"
|
||||||
className={`${styles.card} ${styles.scanCard} ${styles.scanCardCentered}`}
|
className={`${styles.card} ${styles.scanCard}`}
|
||||||
|
style={{ alignItems: "center" }}
|
||||||
>
|
>
|
||||||
<h3 className={styles.cardTitle}>Риски</h3>
|
<h3 className={styles.cardTitle} style={{ alignSelf: "flex-start" }}>
|
||||||
|
Риски
|
||||||
|
</h3>
|
||||||
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
|
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.detailBtn} ${styles.detailBtnScan}`}
|
className={styles.detailBtn}
|
||||||
|
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} ${styles.detailBtnScan}`}
|
className={styles.detailBtn}
|
||||||
onClick={() => onOpenDrawer("potential")}
|
onClick={() => onOpenDrawer("potential")}
|
||||||
title="Открыть расчёт ёмкости"
|
title="Открыть расчёт ёмкости"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,24 @@ 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,13 +10,6 @@
|
||||||
* - 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.
|
||||||
|
|
@ -35,72 +28,15 @@ 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") {
|
if (e.key === "Escape") onClose();
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -113,15 +49,8 @@ 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]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -131,7 +60,6 @@ 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,10 +504,8 @@ export default function MassingScene({
|
||||||
scene.add(sun.target);
|
scene.add(sun.target);
|
||||||
sunRef.current = sun;
|
sunRef.current = sun;
|
||||||
|
|
||||||
// initial sun. The initial mass is built by the dedicated floors/sections
|
// initial mass + sun
|
||||||
// effect below, which runs right after this mount effect in the same commit
|
rebuildMass(computeModel(parcelArea, farTarget, floors, sections));
|
||||||
// (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} ${styles.scKvRowSpaced}`}>
|
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
||||||
<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} ${styles.scKvRowSpaced}`}>
|
<div className={styles.scKvRow} style={{ marginTop: 8 }}>
|
||||||
<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