Co-Study4Grid / frontend /src /components /NoticesPanel.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
10 kB
// 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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
data-testid="notices-panel"
style={{ position: 'relative', flexShrink: 0 }}
>
<button
ref={buttonRef}
type="button"
data-testid="notices-pill"
onClick={() => setOpen(prev => !prev)}
title={`${notices.length} active notice${notices.length === 1 ? '' : 's'}`}
aria-expanded={open}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: space[1],
padding: `3px ${space[2]}`,
border: `1px solid ${colors.warningBorder}`,
background: colors.warningSoft,
color: colors.warningText,
borderRadius: radius.lg,
fontSize: text.xs,
fontWeight: 600,
cursor: 'pointer',
lineHeight: 1.4,
}}
>
<span aria-hidden="true">🔔</span>
Notices
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '18px',
height: '18px',
padding: `0 ${space[1]}`,
background: colors.warning,
color: colors.textOnBright,
borderRadius: '999px',
fontSize: text.xs,
fontWeight: 700,
}}
>
{notices.length}
</span>
</button>
{open && createPortal(
<div
ref={panelRef}
data-testid="notices-list"
role="dialog"
aria-label="Active notices"
style={{
position: 'fixed',
top: panelPos?.top ?? -9999,
left: panelPos?.left ?? -9999,
zIndex: 1000,
width: `${PANEL_WIDTH}px`,
maxHeight: '60vh',
overflowY: 'auto',
background: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: radius.md,
boxShadow: '0 6px 20px rgba(0,0,0,0.18)',
padding: space[2],
display: 'flex',
flexDirection: 'column',
gap: space[2],
visibility: panelPos ? 'visible' : 'hidden',
}}
>
{notices.map(notice => {
const isInfo = notice.severity === 'info';
return (
<div
key={notice.id}
data-testid={`notice-${notice.id}`}
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: space[2],
padding: `${space[2]} ${space[3]}`,
background: isInfo ? colors.infoSoft : colors.warningSoft,
border: `1px solid ${isInfo ? colors.infoBorder : colors.warningBorder}`,
borderRadius: radius.sm,
fontSize: text.sm,
color: isInfo ? colors.infoText : colors.warningText,
lineHeight: 1.45,
overflow: 'hidden',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
<div style={{ flex: 1, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
<div style={{ fontWeight: 700, marginBottom: space[1] }}>{notice.title}</div>
<div>{notice.body}</div>
{notice.action && (
<button
type="button"
onClick={notice.action.onClick}
style={{
marginTop: space[1],
background: 'none',
border: 'none',
color: colors.brandStrong,
textDecoration: 'underline',
cursor: 'pointer',
padding: 0,
fontSize: text.xs,
}}
>
{notice.action.label}
</button>
)}
</div>
{notice.onDismiss && (
<button
type="button"
onClick={notice.onDismiss}
title="Dismiss"
aria-label={`Dismiss ${notice.title}`}
style={{
flexShrink: 0,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: text.lg,
lineHeight: 1,
color: 'inherit',
}}
>
&times;
</button>
)}
</div>
);
})}
</div>,
document.body,
)}
</div>
);
}