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 { 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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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). */}
|
||||||
|
{!pending && (
|
||||||
<div
|
<div
|
||||||
className="toast-progress"
|
className="toast-progress"
|
||||||
style={{ animationDuration: `${duration}ms` }}
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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