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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
{/* 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user