swarm ui: a stored claude login closes the dialog and shows a toast
The login dialog stayed open with a success line after the code was accepted, though nothing is left to do in it. It now closes and a toast says the login is stored. The toast is a new swarm-ui primitive: a short note raised with showToast, rendered by a ToastHost at the app root, gone after a few seconds or on click.
This commit is contained in:
parent
892675628d
commit
73f875eab8
3 changed files with 211 additions and 15 deletions
37
frontend/packages/swarm-ui/src/ui/toast/Toast.css
Normal file
37
frontend/packages/swarm-ui/src/ui/toast/Toast.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* <ToastHost>: a fixed stack top-right above the page. The host takes no
|
||||
clicks itself, so it never blocks the page below it; each toast does. A
|
||||
toast raised while a modal dialog is open sits behind the dialog's top
|
||||
layer, so raise one after closing the dialog. */
|
||||
.ui-toast-host {
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
max-width: min(28em, 92vw);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ui-toast {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 3px;
|
||||
border-radius: 0.4em;
|
||||
box-shadow: 0 4px 20px -6px var(--crust);
|
||||
padding: 0.6em 0.9em;
|
||||
font-size: 0.9em;
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ui-toast-info {
|
||||
border-left-color: var(--purple-dim);
|
||||
}
|
||||
.ui-toast-ok {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
.ui-toast-error {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
43
frontend/packages/swarm-ui/src/ui/toast/Toast.tsx
Normal file
43
frontend/packages/swarm-ui/src/ui/toast/Toast.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// <ToastHost> — renders the toasts `showToast` raised, stacked top-right
|
||||
// above the page. Mounted once, at the app root. A toast removes itself after
|
||||
// its duration; a click dismisses it sooner. An error is announced as an
|
||||
// alert, the others as a status.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import {
|
||||
currentToasts,
|
||||
dismissToast,
|
||||
subscribeToasts,
|
||||
type Toast,
|
||||
} from "./toasts.js";
|
||||
import "./Toast.css";
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => dismissToast(toast.id), toast.duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, toast.duration]);
|
||||
return (
|
||||
<div
|
||||
class={`ui-toast ui-toast-${toast.tone}`}
|
||||
role={toast.tone === "error" ? "alert" : "status"}
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastHost() {
|
||||
const [list, setList] = useState<readonly Toast[]>(currentToasts());
|
||||
useEffect(() => {
|
||||
setList(currentToasts());
|
||||
return subscribeToasts(() => setList(currentToasts()));
|
||||
}, []);
|
||||
return (
|
||||
<div class="ui-toast-host">
|
||||
{list.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/packages/swarm-ui/src/ui/toast/toasts.ts
Normal file
59
frontend/packages/swarm-ui/src/ui/toast/toasts.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// The toast list, kept out of the component so `npm test` runs it. A toast is
|
||||
// a short note that needs no answer: it shows for a few seconds and goes away
|
||||
// by itself, and a click dismisses it sooner. Anything that shows one calls
|
||||
// `showToast`; `<ToastHost>` renders the list.
|
||||
|
||||
export type ToastTone = "info" | "ok" | "error";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: ToastTone;
|
||||
/** How long it stays, in ms. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** An error lingers longer, since it is the one worth reading twice. */
|
||||
export function toastDuration(tone: ToastTone): number {
|
||||
return tone === "error" ? 8000 : 4000;
|
||||
}
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/** Shows a toast and returns its id. */
|
||||
export function showToast(
|
||||
message: string,
|
||||
tone: ToastTone = "info",
|
||||
duration: number = toastDuration(tone),
|
||||
): number {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, message, tone, duration }];
|
||||
emit();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Removes a toast; an id that is gone already does nothing. */
|
||||
export function dismissToast(id: number): void {
|
||||
if (!toasts.some((t) => t.id === id)) return;
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
emit();
|
||||
}
|
||||
|
||||
/** The toasts on screen, oldest first. */
|
||||
export function currentToasts(): readonly Toast[] {
|
||||
return toasts;
|
||||
}
|
||||
|
||||
/** Calls `fn` on every change; returns the function that stops it. */
|
||||
export function subscribeToasts(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue