Toasts: slide in and out, three kinds, pause on hover, fold repeats, cap at three, optional action (#318)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
@@ -321,7 +321,7 @@ const SCENES = [
|
||||
name: 'toasts',
|
||||
setup: async (p) => {
|
||||
await openChannel(p, '#general')
|
||||
await p.evaluate(`window.__mohoShots.store.toast('info', 'Copied to the clipboard'), window.__mohoShots.store.toast('error', "Couldn't send: the connection dropped")`)
|
||||
await p.evaluate(`(() => { const t = window.__mohoShots.store; t.toast('info', 'Copied to the clipboard'); t.toast('success', 'Link copied'); t.toast('error', "Couldn't send: the connection dropped"); t.toast('error', "Couldn't send: the connection dropped"); t.toast('error', "Couldn't send: the connection dropped"); return true })()`)
|
||||
await sleep(300)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2756,16 +2756,77 @@ img.composer-emoji {
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-highest);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-left-width: 3px;
|
||||
box-shadow: var(--shadow-2);
|
||||
font-size: var(--font-size-small);
|
||||
animation: toast-in var(--motion) var(--ease);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
/* The kind is said by the edge and the icon, not by recolouring the words. */
|
||||
.toast.info {
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
color: var(--error);
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.toast span {
|
||||
.toast.error > .icon {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.toast.success > .icon {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.toast.info > .icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.toast.leaving {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-count {
|
||||
margin-left: var(--spacing-xs);
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toast-action {
|
||||
flex: none;
|
||||
padding: 2px var(--spacing-s);
|
||||
border-radius: var(--corner-radius-small);
|
||||
color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.toast-action:hover {
|
||||
background: var(--primary-hover-light);
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- rich message content --------------------------------------------- */
|
||||
|
||||
@@ -1,20 +1,81 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { toastLifetime, type ToastItem } from '../lib/toasts'
|
||||
|
||||
const ICONS = { info: 'info', success: 'check_circle', error: 'error' } as const
|
||||
|
||||
/** How long a toast takes to leave, which is what the exit animation lasts. */
|
||||
const LEAVING_MS = 180
|
||||
|
||||
/**
|
||||
* Things that happened, top right.
|
||||
*
|
||||
* They slide in and out, wait while the pointer is on them (a message half
|
||||
* read is not one to take away), say how many times the same thing happened
|
||||
* rather than stacking copies, and carry a button where there is something to
|
||||
* do about it. `role=status` so a screen reader says them as they arrive.
|
||||
*/
|
||||
export function Toasts(): JSX.Element {
|
||||
const store = useStore()
|
||||
const toasts = useChat((s) => s.toasts)
|
||||
return (
|
||||
<div className="toasts">
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast ${toast.kind}`}>
|
||||
<Icon name={toast.kind === 'error' ? 'error' : 'info'} size={16} />
|
||||
<span>{toast.text}</span>
|
||||
<button type="button" onClick={() => store.dismissToast(toast.id)} title="Dismiss">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ToastRow key={toast.id} toast={toast} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastRow({ toast }: { toast: ToastItem }): JSX.Element {
|
||||
const store = useStore()
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
const gone = useRef(false)
|
||||
|
||||
const leave = (): void => {
|
||||
if (gone.current) return
|
||||
gone.current = true
|
||||
setLeaving(true)
|
||||
setTimeout(() => store.dismissToast(toast.id), LEAVING_MS)
|
||||
}
|
||||
|
||||
// Its time, started over each time it is said again and held while the
|
||||
// pointer is on it.
|
||||
useEffect(() => {
|
||||
if (hovered || leaving) return
|
||||
const t = setTimeout(leave, toastLifetime(toast))
|
||||
return () => clearTimeout(t)
|
||||
// `leave` only closes over refs and stable store methods.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hovered, leaving, toast.stamp])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`toast ${toast.kind}${leaving ? ' leaving' : ''}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<Icon name={ICONS[toast.kind]} size={16} />
|
||||
<span className="toast-text">
|
||||
{toast.text}
|
||||
{toast.count > 1 && <span className="toast-count">×{toast.count}</span>}
|
||||
</span>
|
||||
{toast.action && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-action"
|
||||
onClick={() => {
|
||||
toast.action?.onClick()
|
||||
leave()
|
||||
}}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="toast-close" onClick={leave} title="Dismiss" aria-label="Dismiss">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MAX_TOASTS, addToast, toastLifetime, type ToastItem } from './toasts'
|
||||
|
||||
const add = (list: ToastItem[], id: number, text: string, kind: ToastItem['kind'] = 'info', now = 1000): ToastItem[] =>
|
||||
addToast(list, { id, kind, text }, now)
|
||||
|
||||
describe('the toast stack', () => {
|
||||
it('folds the same words into one toast that says how many', () => {
|
||||
let list = add([], 1, 'Copied')
|
||||
list = add(list, 2, 'Copied', 'info', 2000)
|
||||
list = add(list, 3, 'Copied', 'info', 3000)
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].count).toBe(3)
|
||||
// Repeating it starts its time over.
|
||||
expect(list[0].stamp).toBe(3000)
|
||||
})
|
||||
|
||||
it('keeps the same words of a different kind apart', () => {
|
||||
const list = add(add([], 1, 'Done'), 2, 'Done', 'error')
|
||||
expect(list).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('never holds more than a few, the oldest giving way', () => {
|
||||
let list: ToastItem[] = []
|
||||
for (let i = 1; i <= MAX_TOASTS + 2; i++) list = add(list, i, `message ${i}`)
|
||||
expect(list).toHaveLength(MAX_TOASTS)
|
||||
expect(list.map((t) => t.text)).toEqual(['message 3', 'message 4', 'message 5'])
|
||||
})
|
||||
|
||||
it('gives errors and ones with something to press longer to be read', () => {
|
||||
const quiet = toastLifetime({ kind: 'info', text: 'Copied', action: undefined })
|
||||
const error = toastLifetime({ kind: 'error', text: 'Copied', action: undefined })
|
||||
const actionable = toastLifetime({ kind: 'info', text: 'Copied', action: { label: 'Undo', onClick: () => {} } })
|
||||
expect(error).toBeGreaterThan(quiet)
|
||||
expect(actionable).toBeGreaterThan(quiet)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* What the toast stack does with a new message.
|
||||
*
|
||||
* Kept apart from the store so the two rules can be tested without one: the
|
||||
* same words saying the same thing again are one toast that says how many
|
||||
* times, not a column of copies; and the column never holds more than a few,
|
||||
* the oldest giving way - a burst of errors must not paper over the window.
|
||||
*/
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
kind: 'info' | 'success' | 'error'
|
||||
text: string
|
||||
/** How many times this has been said while it was showing. */
|
||||
count: number
|
||||
/** When it was last said, so repeating it starts its time over. */
|
||||
stamp: number
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
export const MAX_TOASTS = 3
|
||||
|
||||
export function addToast(
|
||||
list: ToastItem[],
|
||||
next: { id: number; kind: ToastItem['kind']; text: string; action?: ToastAction },
|
||||
now = Date.now()
|
||||
): ToastItem[] {
|
||||
const same = list.find((t) => t.kind === next.kind && t.text === next.text)
|
||||
if (same) {
|
||||
return list.map((t) => (t === same ? { ...t, count: t.count + 1, stamp: now, action: next.action ?? t.action } : t))
|
||||
}
|
||||
return [...list, { ...next, count: 1, stamp: now }].slice(-MAX_TOASTS)
|
||||
}
|
||||
|
||||
/** How long a toast stays: errors and anything with an action are given longer to be read. */
|
||||
export function toastLifetime(t: Pick<ToastItem, 'kind' | 'action' | 'text'>): number {
|
||||
const reading = Math.min(8000, 3000 + t.text.length * 40)
|
||||
return t.kind === 'error' || t.action ? Math.max(8000, reading) : Math.max(5000, reading)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
VoicePrefs,
|
||||
VoiceSession
|
||||
} from '../../../shared/wire'
|
||||
import { addToast, type ToastAction, type ToastItem } from '../lib/toasts'
|
||||
import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format'
|
||||
import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { runExport } from '../lib/exporter'
|
||||
@@ -674,11 +675,7 @@ export interface ChatState {
|
||||
matrixLoginStatus: string
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: 'info' | 'error'
|
||||
text: string
|
||||
}
|
||||
export type Toast = ToastItem
|
||||
|
||||
const INITIAL: ChatState = {
|
||||
newRelease: null,
|
||||
@@ -3960,7 +3957,7 @@ export class ChatStore {
|
||||
try {
|
||||
const { url } = await window.moho.rpc<{ url: string }>('getDiscordMessageLink', { bufferId, messageId })
|
||||
void window.moho.copyText(url)
|
||||
this.toast('info', 'Link copied')
|
||||
this.toast('success', 'Link copied')
|
||||
} catch (e) {
|
||||
this.toast('error', (e as Error).message)
|
||||
}
|
||||
@@ -4757,12 +4754,13 @@ export class ChatStore {
|
||||
})
|
||||
}
|
||||
|
||||
toast(kind: 'info' | 'error', text: string): void {
|
||||
const id = ++this.toastSeq
|
||||
this.set({ toasts: [...this.state.toasts, { id, kind, text }] })
|
||||
setTimeout(() => {
|
||||
this.set({ toasts: this.state.toasts.filter((t) => t.id !== id) })
|
||||
}, 6000)
|
||||
/**
|
||||
* Says something happened. How long it stays, and going away when the
|
||||
* pointer is not on it, is the stack's own business (components/Toasts.tsx):
|
||||
* a toast that vanished while being read was the old behaviour.
|
||||
*/
|
||||
toast(kind: 'info' | 'success' | 'error', text: string, action?: ToastAction): void {
|
||||
this.set({ toasts: addToast(this.state.toasts, { id: ++this.toastSeq, kind, text, action }) })
|
||||
}
|
||||
|
||||
dismissToast(id: number): void {
|
||||
|
||||
Reference in New Issue
Block a user