Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions client/src/components/ui/Toast.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* own dismiss timer passes `collapseAfter` to fold on its schedule instead.
*/

import { Loader2 } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { uuidv4 } from '../../lib/uuid.js';

Expand Down Expand Up @@ -118,8 +119,8 @@ export const toast = Object.assign(

export default toast;

const TYPE_ICON = { success: '✓', error: '✕', loading: '⟳', warning: '⚠' };
const TYPE_CLASS = { success: 'text-port-success', error: 'text-port-error', loading: 'text-gray-400 animate-spin', warning: 'text-port-warning' };
const TYPE_ICON = { success: '✓', error: '✕', warning: '⚠' };
const TYPE_CLASS = { success: 'text-port-success', error: 'text-port-error', loading: 'text-gray-400', warning: 'text-port-warning' };

export function Toaster({ position = 'bottom-right', toastOptions = {} }) {
const [items, setItems] = useState([]);
Expand Down Expand Up @@ -162,7 +163,23 @@ export function Toaster({ position = 'bottom-right', toastOptions = {} }) {
function ToastItem({ t, toastOptions }) {
const style = { padding: '12px 16px', borderRadius: '8px', ...toastOptions.style, ...t.style };
const iconStr = t.icon ?? (t.type !== 'default' ? TYPE_ICON[t.type] : null);
// The loading icon is `Loader2` — the same spinner the rest of the UI spins
// (ConfirmButtonPair, TabPills) — and no longer the `⟳` glyph it used to be.
// `animate-spin` rotates about the center of the element's box, and a glyph's
// ink is not centered in its line box: the font's ascent/descent padding above
// and below it is asymmetric, so the character is drawn off the box's midpoint
// and spinning it traces a visible wobble instead of a clean circle ("PortOS is
// restarting..." made it obvious, since that toast spins for the whole
// restart). An icon whose arc is centered in a square viewBox puts the visual
// center on the rotation origin, so it turns in place.
// A caller-supplied `icon` still wins over the spinner — it's an explicit
// override, and the type only picks the default.
const showSpinner = t.type === 'loading' && !t.icon;
const iconNode = showSpinner ? <Loader2 size={14} className="animate-spin" /> : iconStr;
const iconClass = t.type !== 'default' ? TYPE_CLASS[t.type] : '';
// The icon is 14px; centering it on the `text-sm` line box keeps it level
// with the first line of the message rather than riding above it.
const iconBoxClass = showSpinner ? 'inline-flex h-5 items-center' : '';
// Only a toast that never dismisses itself can outstay its welcome and start
// eating clicks — see COLLAPSE_AFTER_MS.
const collapsible = t.duration === Infinity;
Expand Down Expand Up @@ -256,7 +273,7 @@ function ToastItem({ t, toastOptions }) {
aria-label={collapsedLabel(t)}
className="pointer-events-auto flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-port-card border border-port-border shadow-lg text-sm"
>
<span className={iconClass} aria-hidden="true">{iconStr ?? '•'}</span>
<span className={iconClass} aria-hidden="true">{iconNode ?? '•'}</span>
</button>
)}
<div
Expand All @@ -275,7 +292,7 @@ function ToastItem({ t, toastOptions }) {
onFocus={() => setFocusWithin(true)}
onBlur={() => setFocusWithin(false)}
className="pointer-events-auto flex items-start gap-2 shadow-lg text-sm max-w-[calc(100vw-2rem)] sm:max-w-[520px] bg-port-card border border-port-border">
{iconStr && <span className={`shrink-0 ${iconClass}`} aria-hidden="true">{iconStr}</span>}
{iconNode && <span className={`shrink-0 ${iconClass} ${iconBoxClass}`} aria-hidden="true">{iconNode}</span>}
<div className="flex-1 min-w-0">
{typeof t.content === 'function' ? t.content({ id: t.id }) : <span>{t.content}</span>}
</div>
Expand Down
27 changes: 27 additions & 0 deletions client/src/components/ui/Toast.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,33 @@ describe('Toaster accessibility', () => {
});
});

describe('loading spinner', () => {
// Regression: the loading icon was the `⟳` glyph with `animate-spin` on it.
// The rotation origin is the center of the span's line box, but the glyph's
// ink sits off that point, so it wobbled instead of turning in place — very
// visible on "PortOS is restarting...", which spins for the whole restart.
// The spinning element must be an SVG whose arc is centered in its viewBox.
it('spins an SVG, never a text glyph', () => {
render(<Toaster />);
act(() => { toast.loading('PortOS is restarting...'); });

const status = screen.getByRole('status');
const spinner = status.querySelector('.animate-spin');
expect(spinner?.tagName.toLowerCase()).toBe('svg');
// A glyph carried along by the rotation would reintroduce the wobble.
expect(spinner).toHaveTextContent('');
});

it('lets a caller-supplied icon override the spinner', () => {
render(<Toaster />);
act(() => { toast.loading('Uploading', { icon: '⬆' }); });

const status = screen.getByRole('status');
expect(status.querySelector('[aria-hidden="true"]')).toHaveTextContent('⬆');
expect(status.querySelector('.animate-spin')).toBeNull();
});
});

/** Why a never-dismissing toast has to fold away: see COLLAPSE_AFTER_MS. */
describe('long-lived toasts stop blocking the page', () => {
const advance = (ms) => act(() => { vi.advanceTimersByTime(ms); });
Expand Down
Loading