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 { createPortal } from 'react-dom'
import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition'
// Parse React <option> children into a flat [{value, label, disabled}] array
function parseOptions(children) {
@@ -46,7 +47,7 @@ export default function Select({
...props
}) {
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 menuRef = useRef(null)
@@ -105,8 +106,7 @@ export default function Select({
function openMenu() {
if (disabled) return
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect()
setMenuPos({ top: r.bottom + 4, left: r.left, width: r.width })
setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect()))
}
setOpen(true)
}
@@ -130,7 +130,7 @@ export default function Select({
<div
ref={menuRef}
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"
aria-label="Options"
>
+31 -13
View File
@@ -12,8 +12,9 @@
// Each toast auto-dismisses after `duration` ms (default 4000).
// 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 Spinner from '@/components/ui/Spinner'
// ---------------------------------------------------------------------------
// Context
@@ -49,7 +50,7 @@ function ToastIcon({ variant }) {
// ---------------------------------------------------------------------------
// 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 dismiss = useCallback(() => {
@@ -57,15 +58,21 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
setTimeout(() => onDismiss(id), 300)
}, [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 startTimer = () => {
if (pending) return
timerRef.current = setTimeout(dismiss, duration)
}
const clearTimer = () => clearTimeout(timerRef.current)
// Start timer on mount
useState(() => { startTimer() })
// (Re)start the timer whenever `pending` resolves to false, or on mount if already resolved
useEffect(() => {
clearTimer()
startTimer()
return clearTimer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, duration])
return (
<div
@@ -76,7 +83,7 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
onMouseLeave={startTimer}
>
<div className="toast-icon">
<ToastIcon variant={variant} />
{pending ? <Spinner size="sm" /> : <ToastIcon variant={variant} />}
</div>
<div className="toast-content">
@@ -94,11 +101,13 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
</svg>
</button>
{/* Progress bar — shrinks over duration */}
<div
className="toast-progress"
style={{ animationDuration: `${duration}ms` }}
/>
{/* Progress bar — shrinks over duration. Hidden while pending (no timeout running). */}
{!pending && (
<div
className="toast-progress"
style={{ animationDuration: `${duration}ms` }}
/>
)}
</div>
)
}
@@ -111,9 +120,9 @@ let _nextId = 1
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const addToast = useCallback((variant, title, message, duration) => {
const addToast = useCallback((variant, title, message, duration, pending = false) => {
const id = _nextId++
setToasts((prev) => [...prev, { id, variant, title, message, duration }])
setToasts((prev) => [...prev, { id, variant, title, message, duration, pending }])
return id
}, [])
@@ -121,11 +130,20 @@ export function ToastProvider({ children }) {
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 = {
success: (title, message, duration) => addToast('success', title, message, duration),
danger: (title, message, duration) => addToast('danger', title, message, duration),
warning: (title, message, duration) => addToast('warning', 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,
}
+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 }),
}
}