feat(ui): add pending/update toast state and shared floating-menu positioning

Toast gains a pending/update lifecycle so async actions can show a single
toast that transitions from in-progress to success/error, instead of
firing a new one. Select's floating-menu placement logic is extracted
into a shared helper so other dropdown-style components (MultiSelect)
can reuse it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:20:26 +03:00
co-authored by Claude Sonnet 5
parent 1ce0137ba9
commit 883c0556dd
3 changed files with 67 additions and 17 deletions
+4 -4
View File
@@ -16,6 +16,7 @@
import { useState, useRef, useEffect, Children, isValidElement } from 'react' import { useState, useRef, useEffect, Children, isValidElement } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition'
// Parse React <option> children into a flat [{value, label, disabled}] array // Parse React <option> children into a flat [{value, label, disabled}] array
function parseOptions(children) { function parseOptions(children) {
@@ -46,7 +47,7 @@ export default function Select({
...props ...props
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }) const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 280 })
const triggerRef = useRef(null) const triggerRef = useRef(null)
const menuRef = useRef(null) const menuRef = useRef(null)
@@ -105,8 +106,7 @@ export default function Select({
function openMenu() { function openMenu() {
if (disabled) return if (disabled) return
if (triggerRef.current) { if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect() setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect()))
setMenuPos({ top: r.bottom + 4, left: r.left, width: r.width })
} }
setOpen(true) setOpen(true)
} }
@@ -130,7 +130,7 @@ export default function Select({
<div <div
ref={menuRef} ref={menuRef}
className="select-menu" className="select-menu"
style={{ top: menuPos.top, left: menuPos.left, width: menuPos.width }} style={{ top: menuPos.top, bottom: menuPos.bottom, left: menuPos.left, width: menuPos.width, maxHeight: menuPos.maxHeight }}
role="listbox" role="listbox"
aria-label="Options" aria-label="Options"
> >
+31 -13
View File
@@ -12,8 +12,9 @@
// Each toast auto-dismisses after `duration` ms (default 4000). // Each toast auto-dismisses after `duration` ms (default 4000).
// ToastProvider renders the stack itself via a portal — no extra component needed. // ToastProvider renders the stack itself via a portal — no extra component needed.
import { createContext, useContext, useState, useCallback, useRef } from 'react' import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import Spinner from '@/components/ui/Spinner'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Context // Context
@@ -49,7 +50,7 @@ function ToastIcon({ variant }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Single Toast item // Single Toast item
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDismiss }) { function ToastItem({ id, variant = 'info', title, message, duration = 4000, pending = false, onDismiss }) {
const [exiting, setExiting] = useState(false) const [exiting, setExiting] = useState(false)
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
@@ -57,15 +58,21 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
setTimeout(() => onDismiss(id), 300) setTimeout(() => onDismiss(id), 300)
}, [id, onDismiss]) }, [id, onDismiss])
// Auto-dismiss timer // Auto-dismiss timer — does not run while `pending` (e.g. waiting on a command reply)
const timerRef = useRef(null) const timerRef = useRef(null)
const startTimer = () => { const startTimer = () => {
if (pending) return
timerRef.current = setTimeout(dismiss, duration) timerRef.current = setTimeout(dismiss, duration)
} }
const clearTimer = () => clearTimeout(timerRef.current) const clearTimer = () => clearTimeout(timerRef.current)
// Start timer on mount // (Re)start the timer whenever `pending` resolves to false, or on mount if already resolved
useState(() => { startTimer() }) useEffect(() => {
clearTimer()
startTimer()
return clearTimer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, duration])
return ( return (
<div <div
@@ -76,7 +83,7 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
onMouseLeave={startTimer} onMouseLeave={startTimer}
> >
<div className="toast-icon"> <div className="toast-icon">
<ToastIcon variant={variant} /> {pending ? <Spinner size="sm" /> : <ToastIcon variant={variant} />}
</div> </div>
<div className="toast-content"> <div className="toast-content">
@@ -94,11 +101,13 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
</svg> </svg>
</button> </button>
{/* Progress bar — shrinks over duration */} {/* Progress bar — shrinks over duration. Hidden while pending (no timeout running). */}
<div {!pending && (
className="toast-progress" <div
style={{ animationDuration: `${duration}ms` }} className="toast-progress"
/> style={{ animationDuration: `${duration}ms` }}
/>
)}
</div> </div>
) )
} }
@@ -111,9 +120,9 @@ let _nextId = 1
export function ToastProvider({ children }) { export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]) const [toasts, setToasts] = useState([])
const addToast = useCallback((variant, title, message, duration) => { const addToast = useCallback((variant, title, message, duration, pending = false) => {
const id = _nextId++ const id = _nextId++
setToasts((prev) => [...prev, { id, variant, title, message, duration }]) setToasts((prev) => [...prev, { id, variant, title, message, duration, pending }])
return id return id
}, []) }, [])
@@ -121,11 +130,20 @@ export function ToastProvider({ children }) {
setToasts((prev) => prev.filter((t) => t.id !== id)) setToasts((prev) => prev.filter((t) => t.id !== id))
}, []) }, [])
// Update an existing toast in place — e.g. flip a `pending` (blue) toast to
// success/danger/warning once a command reply (or timeout) arrives.
const update = useCallback((id, patch) => {
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
}, [])
const toast = { const toast = {
success: (title, message, duration) => addToast('success', title, message, duration), success: (title, message, duration) => addToast('success', title, message, duration),
danger: (title, message, duration) => addToast('danger', title, message, duration), danger: (title, message, duration) => addToast('danger', title, message, duration),
warning: (title, message, duration) => addToast('warning', title, message, duration), warning: (title, message, duration) => addToast('warning', title, message, duration),
info: (title, message, duration) => addToast('info', title, message, duration), info: (title, message, duration) => addToast('info', title, message, duration),
// pending: creates a toast with no auto-dismiss timer until update() clears `pending`
pending: (title, message) => addToast('info', title, message, 4000, true),
update,
dismiss, dismiss,
} }
+32
View File
@@ -0,0 +1,32 @@
// frontend/src/lib/floatingMenuPosition.js
// Positions a fixed-position dropdown menu relative to its trigger, flipping
// above the trigger and clamping its height when there isn't enough room
// below in the viewport (e.g. a Select near the bottom of the screen).
//
// Used by Select.jsx and MultiSelect.jsx — both render their menu via
// createPortal(..., document.body) with position: fixed, so they need to
// account for viewport edges themselves (page scroll doesn't move a
// position:fixed element into view).
const MENU_GAP = 4
const MENU_MARGIN = 8 // minimum breathing room from the viewport edge
const PREFERRED_MAX_HEIGHT = 280 // matches .select-menu's CSS max-height
export function computeFloatingMenuPosition(triggerRect) {
const spaceBelow = window.innerHeight - triggerRect.bottom - MENU_GAP - MENU_MARGIN
const spaceAbove = triggerRect.top - MENU_GAP - MENU_MARGIN
// Prefer opening below; flip above only when below doesn't fit but above has more room.
const openAbove = spaceBelow < PREFERRED_MAX_HEIGHT && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(PREFERRED_MAX_HEIGHT, openAbove ? spaceAbove : spaceBelow))
return {
left: triggerRect.left,
width: triggerRect.width,
maxHeight,
...(openAbove
? { bottom: window.innerHeight - triggerRect.top + MENU_GAP }
: { top: triggerRect.bottom + MENU_GAP }),
}
}