// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { colors, radius, space, text } from '../styles/tokens'; export interface Notice { /** Stable id used as React key and for dismiss tracking. */ id: string; /** One-line title shown bold on the notice card. */ title: string; /** * Body content. ReactNode so callers can compose icons / inline buttons / * styled fragments. Rendered with the warning-text color on a soft yellow * background for visual continuity with the previous inline banners. */ body: React.ReactNode; /** * Visual severity. `info` renders blue, `warning` renders yellow. * Both share the same panel layout so the operator can scan all * active notices at once. */ severity?: 'info' | 'warning'; /** Optional dismiss handler — when set, a × appears on the card. */ onDismiss?: () => void; /** Optional in-card action button (e.g. "Open settings"). */ action?: { label: string; onClick: () => void }; } interface NoticesPanelProps { notices: Notice[]; } /** * Sidebar-header pill that opens an inline panel listing every active * background notice (action-dict info, recommender thresholds, * monitoring coverage). Replaces the five concurrent yellow banners * called out in `docs/proposals/ui-design-critique.md` recommendation * #4 — one entry point, one place to dismiss, no warning-fatigue * stack. * * The pill self-hides when there are zero active notices so the * sidebar header stays clean once the operator has dismissed * everything they wanted to dismiss. */ const PANEL_WIDTH = 320; const PANEL_GAP = 4; const VIEWPORT_MARGIN = 8; export default function NoticesPanel({ notices }: NoticesPanelProps) { const [open, setOpen] = useState(false); const containerRef = useRef(null); const buttonRef = useRef(null); const panelRef = useRef(null); const [panelPos, setPanelPos] = useState<{ top: number; left: number } | null>(null); // Anchor the floating panel to the pill button using viewport // coordinates. We render it in a portal so it escapes the sidebar's // `overflow: hidden` clip and any ancestor stacking contexts that // would otherwise let the visualization panel paint over it. const recomputePosition = useCallback(() => { const btn = buttonRef.current; if (!btn) return; const rect = btn.getBoundingClientRect(); const viewportW = window.innerWidth; const top = rect.bottom + PANEL_GAP; // Right-align the panel with the pill so it grows leftward into // the sidebar instead of bleeding into the visualization area. let left = rect.right - PANEL_WIDTH; if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN; if (left + PANEL_WIDTH > viewportW - VIEWPORT_MARGIN) { left = viewportW - VIEWPORT_MARGIN - PANEL_WIDTH; } setPanelPos({ top, left }); }, []); useLayoutEffect(() => { if (!open) return; recomputePosition(); }, [open, recomputePosition, notices.length]); useEffect(() => { if (!open) return; const handle = () => recomputePosition(); window.addEventListener('resize', handle); window.addEventListener('scroll', handle, true); return () => { window.removeEventListener('resize', handle); window.removeEventListener('scroll', handle, true); }; }, [open, recomputePosition]); // Close-on-outside-click — keeps the panel modal-light without // requiring a backdrop layer that would compete with the rest of // the chrome. The panel itself lives in a portal, so we also have // to exempt clicks that land inside it. useEffect(() => { if (!open) return; const handleClick = (e: MouseEvent) => { const target = e.target as Node; const insidePill = containerRef.current?.contains(target); const insidePanel = panelRef.current?.contains(target); if (!insidePill && !insidePanel) { setOpen(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open]); // When the underlying state retires every notice (e.g. operator // hits Dismiss on the last one) we early-return below so the pill // and the open panel both unmount. The `open` state is intentionally // not reset here — when fresh notices arrive on a later render the // panel will be re-mounted in its default collapsed state because // useState defaults run per-mount. if (notices.length === 0) return null; return (
{open && createPortal(
{notices.map(notice => { const isInfo = notice.severity === 'info'; return (
{notice.title}
{notice.body}
{notice.action && ( )}
{notice.onDismiss && ( )}
); })}
, document.body, )}
); }