From 8fa2a084840327f0169525328cd13e23546be49a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 04:07:36 +0000 Subject: [PATCH 1/4] feat: add toast component using react-aria Uses @react-aria/toast and @react-stately/toast for accessible toast notifications. Supports three intents (default, success, error), stacking of multiple toasts, and auto-dismiss with configurable timeout. Global showToast() function allows any component to display toasts without prop drilling. Co-authored-by: bitnimble --- src/app/layout.tsx | 4 +- src/ui/base/toast/index.ts | 3 + src/ui/base/toast/toast.module.css | 82 ++++++++++++++++++++++++++++ src/ui/base/toast/toast.tsx | 77 ++++++++++++++++++++++++++ src/ui/base/toast/toast_provider.tsx | 7 +++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/ui/base/toast/index.ts create mode 100644 src/ui/base/toast/toast.module.css create mode 100644 src/ui/base/toast/toast.tsx create mode 100644 src/ui/base/toast/toast_provider.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e11e426..8cb7ce1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,14 +1,15 @@ import { ApiProvider } from 'app/api/api_provider'; import { MaintenanceBanner } from 'app/maintenance_banner'; +import { SkeletonProvider } from 'app/skeleton_provider'; import type { Metadata } from 'next'; import { getFlags } from 'services/server_context'; import { SessionProvider } from 'session/session_provider'; import { colors } from 'ui/base/design_system/design_tokens'; import { ThemeProvider } from 'ui/base/theme'; +import { ToastProvider } from 'ui/base/toast'; import { NavBar } from 'ui/nav_bar/nav_bar'; import './globals.css'; import styles from './layout.module.css'; -import { SkeletonProvider } from 'app/skeleton_provider'; export const metadata: Metadata = { title: 'ParaDB', @@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) ) : null}
{children}
+ diff --git a/src/ui/base/toast/index.ts b/src/ui/base/toast/index.ts new file mode 100644 index 0000000..2d4b68c --- /dev/null +++ b/src/ui/base/toast/index.ts @@ -0,0 +1,3 @@ +export { showToast, ToastContainer } from './toast'; +export { ToastProvider } from './toast_provider'; +export type { ToastIntent } from './toast'; diff --git a/src/ui/base/toast/toast.module.css b/src/ui/base/toast/toast.module.css new file mode 100644 index 0000000..6951171 --- /dev/null +++ b/src/ui/base/toast/toast.module.css @@ -0,0 +1,82 @@ +.toastRegion { + position: fixed; + top: calc(var(--gridBaseline) * 2); + right: calc(var(--gridBaseline) * 2); + z-index: 1000; + + display: flex; + flex-direction: column; + gap: var(--gridBaseline); +} + +.toast { + display: flex; + align-items: center; + gap: var(--gridBaseline); + + padding: calc(var(--gridBaseline) * 1.5) calc(var(--gridBaseline) * 2); + background: var(--colorBackground); + border: 1px solid var(--colorAccent); + + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.content { + flex: 1; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + + width: calc(var(--gridBaseline) * 3); + height: calc(var(--gridBaseline) * 3); + + background: transparent; + border: none; + color: var(--colorForeground); + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.closeButton:hover { + opacity: 1; +} + +/* Intent variants */ +.default { + border-color: var(--colorAccent); +} + +.success { + border-color: var(--colorGreen); + background: var(--colorGreen); + color: var(--colorWhite); +} + +.success .closeButton { + color: var(--colorWhite); +} + +.error { + border-color: var(--colorRed); + background: var(--colorRed); + color: var(--colorWhite); +} + +.error .closeButton { + color: var(--colorWhite); +} diff --git a/src/ui/base/toast/toast.tsx b/src/ui/base/toast/toast.tsx new file mode 100644 index 0000000..1072b45 --- /dev/null +++ b/src/ui/base/toast/toast.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useToast, useToastRegion } from '@react-aria/toast'; +import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'; +import classNames from 'classnames'; +import React, { useRef } from 'react'; +import { Button } from 'react-aria-components'; +import { T } from 'ui/base/text/text'; +import styles from './toast.module.css'; + +export type ToastIntent = 'default' | 'success' | 'error'; + +export type ToastContent = { + message: string; + intent?: ToastIntent; +}; + +const toastQueue = new ToastQueue({ maxVisibleToasts: 5 }); + +/** + * Shows a toast notification. + * @param message The message to display + * @param intent The visual intent: 'default' (purple border), 'success' (green), or 'error' (red) + * @param timeout Duration in ms before auto-dismiss (default: 5000) + */ +export function showToast( + message: string, + intent: ToastIntent = 'default', + timeout: number = 5000 +): void { + toastQueue.add({ message, intent }, { timeout }); +} + +type ToastProps = { + toast: ToastState['visibleToasts'][number]; + state: ToastState; +}; + +function Toast({ toast, state }: ToastProps) { + const ref = useRef(null); + const { toastProps, contentProps, closeButtonProps } = useToast({ toast }, state, ref); + + const intent = toast.content.intent ?? 'default'; + + return ( +
+
+ {toast.content.message} +
+ +
+ ); +} + +export function ToastContainer() { + const state = useToastQueue(toastQueue); + const ref = useRef(null); + const { regionProps } = useToastRegion({}, state, ref); + + if (state.visibleToasts.length === 0) { + return null; + } + + return ( +
+ {state.visibleToasts.map((toast) => ( + + ))} +
+ ); +} diff --git a/src/ui/base/toast/toast_provider.tsx b/src/ui/base/toast/toast_provider.tsx new file mode 100644 index 0000000..a52c34c --- /dev/null +++ b/src/ui/base/toast/toast_provider.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { ToastContainer } from './toast'; + +export function ToastProvider() { + return ; +} From aa11e7b76dd5aeecaaef96d6d75f3f736eeb0c25 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:32:13 +0000 Subject: [PATCH 2/4] refactor: use Toast from react-aria-components Co-authored-by: bitnimble --- src/ui/base/toast/index.ts | 2 +- src/ui/base/toast/toast.tsx | 75 ++++++++++++++----------------------- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/ui/base/toast/index.ts b/src/ui/base/toast/index.ts index 2d4b68c..0d47141 100644 --- a/src/ui/base/toast/index.ts +++ b/src/ui/base/toast/index.ts @@ -1,3 +1,3 @@ export { showToast, ToastContainer } from './toast'; export { ToastProvider } from './toast_provider'; -export type { ToastIntent } from './toast'; +export type { ToastData, ToastIntent } from './toast'; diff --git a/src/ui/base/toast/toast.tsx b/src/ui/base/toast/toast.tsx index 1072b45..7702a36 100644 --- a/src/ui/base/toast/toast.tsx +++ b/src/ui/base/toast/toast.tsx @@ -1,21 +1,25 @@ 'use client'; -import { useToast, useToastRegion } from '@react-aria/toast'; -import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'; +import { ToastQueue } from '@react-stately/toast'; import classNames from 'classnames'; -import React, { useRef } from 'react'; -import { Button } from 'react-aria-components'; +import React from 'react'; +import { + Button, + UNSTABLE_Toast as Toast, + UNSTABLE_ToastContent as ToastContent, + UNSTABLE_ToastRegion as ToastRegion, +} from 'react-aria-components'; import { T } from 'ui/base/text/text'; import styles from './toast.module.css'; export type ToastIntent = 'default' | 'success' | 'error'; -export type ToastContent = { +export type ToastData = { message: string; intent?: ToastIntent; }; -const toastQueue = new ToastQueue({ maxVisibleToasts: 5 }); +const toastQueue = new ToastQueue({ maxVisibleToasts: 5 }); /** * Shows a toast notification. @@ -31,47 +35,26 @@ export function showToast( toastQueue.add({ message, intent }, { timeout }); } -type ToastProps = { - toast: ToastState['visibleToasts'][number]; - state: ToastState; -}; - -function Toast({ toast, state }: ToastProps) { - const ref = useRef(null); - const { toastProps, contentProps, closeButtonProps } = useToast({ toast }, state, ref); - - const intent = toast.content.intent ?? 'default'; - - return ( -
-
- {toast.content.message} -
- -
- ); -} - export function ToastContainer() { - const state = useToastQueue(toastQueue); - const ref = useRef(null); - const { regionProps } = useToastRegion({}, state, ref); - - if (state.visibleToasts.length === 0) { - return null; - } - return ( -
- {state.visibleToasts.map((toast) => ( - - ))} -
+ + {({ toast }) => { + const intent = toast.content.intent ?? 'default'; + return ( + + + {toast.content.message} + + + + ); + }} + ); } From f1871da9e066dc4a38df360ae1086e5c783f294c Mon Sep 17 00:00:00 2001 From: D Date: Sun, 18 Jan 2026 21:52:05 +1100 Subject: [PATCH 3/4] . --- AGENTS.md | 1 + src/app/layout.tsx | 2 +- src/app/map_list_presenter.ts | 2 ++ src/app/page.tsx | 5 ++-- src/ui/base/design_system/design_tokens.ts | 1 + src/ui/base/toast/index.ts | 3 --- src/ui/base/toast/toast.module.css | 28 ++++------------------ src/ui/base/toast/toast.tsx | 19 +++++++++++---- src/ui/base/toast/toast_provider.tsx | 7 ------ src/ui/base/tooltip/tooltip.module.css | 2 +- src/ui/base/tooltip/tooltip.tsx | 2 +- 11 files changed, 28 insertions(+), 44 deletions(-) delete mode 100644 src/ui/base/toast/index.ts delete mode 100644 src/ui/base/toast/toast_provider.tsx diff --git a/AGENTS.md b/AGENTS.md index ffdb8d3..a09fb51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ The codebase uses Docker to run third-party services locally (Meilisearch for se - Use `== null` (double equals null) instead of either `=== null` or `=== undefined` (triple equals null / undefined), and the same for `!=`. This is to make `null` and `undefined` mean the same thing everywhere in our codebase to avoid any potential serialisation/deserialisation confusion or issues. - Avoid use of `any` unless absolutely necessary. - Prefer early-exit if statements rather than nested if statements. +- Avoid the use of barrel files # CSS diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8cb7ce1..502f9b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,10 +6,10 @@ import { getFlags } from 'services/server_context'; import { SessionProvider } from 'session/session_provider'; import { colors } from 'ui/base/design_system/design_tokens'; import { ThemeProvider } from 'ui/base/theme'; -import { ToastProvider } from 'ui/base/toast'; import { NavBar } from 'ui/nav_bar/nav_bar'; import './globals.css'; import styles from './layout.module.css'; +import { ToastProvider } from 'ui/base/toast/toast'; export const metadata: Metadata = { title: 'ParaDB', diff --git a/src/app/map_list_presenter.ts b/src/app/map_list_presenter.ts index 34a8753..e09a197 100644 --- a/src/app/map_list_presenter.ts +++ b/src/app/map_list_presenter.ts @@ -4,6 +4,7 @@ import { makeAutoObservable, runInAction } from 'mobx'; import { computedFn } from 'mobx-utils'; import { MapSortableAttributes, PDMap, mapSortableAttributes } from 'schema/maps'; import { TableSortStore } from 'ui/base/table/table_presenter'; +import { ToastIntent, showToast } from 'ui/base/toast/toast'; import { getMapFileLink } from 'utils/maps'; const SEARCH_LIMIT = 20; @@ -65,6 +66,7 @@ export class MapListPresenter { onClickBulkSelect() { this.store.enableBulkSelect = true; + showToast('Test toast', ToastIntent.DEFAULT); } async onClickBulkDownload() { diff --git a/src/app/page.tsx b/src/app/page.tsx index 7101cf1..7ca4f48 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,9 @@ import { useApi } from 'app/api/api_provider'; import { MapListPresenter, MapListStore } from 'app/map_list_presenter'; +import { useSkeletonRef } from 'app/skeleton_provider'; import classNames from 'classnames'; +import useInfiniteScroll from 'hooks/useInfiniteScroll'; import { action, computed, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import { useSearchParams } from 'next/navigation'; @@ -18,9 +20,6 @@ import { KnownDifficulty, difficultyColors, parseDifficulty } from 'utils/diffic import { RoutePath, routeFor } from 'utils/routes'; import styles from './page.module.css'; import { Search } from './search'; -import useInfiniteScroll from 'hooks/useInfiniteScroll'; -import { useSkeletonRef } from 'app/skeleton_provider'; -import { Tooltip } from 'ui/base/tooltip/tooltip'; export default function Page() { return ( diff --git a/src/ui/base/design_system/design_tokens.ts b/src/ui/base/design_system/design_tokens.ts index e13d1dd..ef6dd29 100644 --- a/src/ui/base/design_system/design_tokens.ts +++ b/src/ui/base/design_system/design_tokens.ts @@ -12,6 +12,7 @@ export const colors = { green: '#4c4', red: '#f00', purple: '#9b15f1', + accent: '#9b15f1', } as const; export const metrics = { diff --git a/src/ui/base/toast/index.ts b/src/ui/base/toast/index.ts deleted file mode 100644 index 0d47141..0000000 --- a/src/ui/base/toast/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { showToast, ToastContainer } from './toast'; -export { ToastProvider } from './toast_provider'; -export type { ToastData, ToastIntent } from './toast'; diff --git a/src/ui/base/toast/toast.module.css b/src/ui/base/toast/toast.module.css index 6951171..b93cb0a 100644 --- a/src/ui/base/toast/toast.module.css +++ b/src/ui/base/toast/toast.module.css @@ -1,7 +1,7 @@ .toastRegion { position: fixed; - top: calc(var(--gridBaseline) * 2); - right: calc(var(--gridBaseline) * 2); + right: calc(var(--gridBaseline) * 5); + bottom: calc(var(--gridBaseline) * 5); z-index: 1000; display: flex; @@ -24,11 +24,11 @@ @keyframes slideIn { from { opacity: 0; - transform: translateX(100%); + transform: translateY(100%); } to { opacity: 1; - transform: translateX(0); + transform: translateY(0); } } @@ -40,9 +40,7 @@ display: flex; align-items: center; justify-content: center; - - width: calc(var(--gridBaseline) * 3); - height: calc(var(--gridBaseline) * 3); + margin-left: var(--gridBaseline); background: transparent; border: none; @@ -57,26 +55,10 @@ } /* Intent variants */ -.default { - border-color: var(--colorAccent); -} - .success { border-color: var(--colorGreen); - background: var(--colorGreen); - color: var(--colorWhite); -} - -.success .closeButton { - color: var(--colorWhite); } .error { border-color: var(--colorRed); - background: var(--colorRed); - color: var(--colorWhite); -} - -.error .closeButton { - color: var(--colorWhite); } diff --git a/src/ui/base/toast/toast.tsx b/src/ui/base/toast/toast.tsx index 7702a36..9e151b2 100644 --- a/src/ui/base/toast/toast.tsx +++ b/src/ui/base/toast/toast.tsx @@ -2,7 +2,6 @@ import { ToastQueue } from '@react-stately/toast'; import classNames from 'classnames'; -import React from 'react'; import { Button, UNSTABLE_Toast as Toast, @@ -12,7 +11,17 @@ import { import { T } from 'ui/base/text/text'; import styles from './toast.module.css'; -export type ToastIntent = 'default' | 'success' | 'error'; +const intentStyles: Record = { + [ToastIntent.DEFAULT]: styles.default, + [ToastIntent.ERROR]: styles.error, + [ToastIntent.SUCCESS]: styles.success, +}; + +export const enum ToastIntent { + DEFAULT = 'default', + SUCCESS = 'success', + ERROR = 'error', +} export type ToastData = { message: string; @@ -29,19 +38,19 @@ const toastQueue = new ToastQueue({ maxVisibleToasts: 5 }); */ export function showToast( message: string, - intent: ToastIntent = 'default', + intent: ToastIntent = ToastIntent.DEFAULT, timeout: number = 5000 ): void { toastQueue.add({ message, intent }, { timeout }); } -export function ToastContainer() { +export function ToastProvider() { return ( {({ toast }) => { const intent = toast.content.intent ?? 'default'; return ( - + {toast.content.message} diff --git a/src/ui/base/toast/toast_provider.tsx b/src/ui/base/toast/toast_provider.tsx deleted file mode 100644 index a52c34c..0000000 --- a/src/ui/base/toast/toast_provider.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import { ToastContainer } from './toast'; - -export function ToastProvider() { - return ; -} diff --git a/src/ui/base/tooltip/tooltip.module.css b/src/ui/base/tooltip/tooltip.module.css index 40fa53d..4dc3193 100644 --- a/src/ui/base/tooltip/tooltip.module.css +++ b/src/ui/base/tooltip/tooltip.module.css @@ -3,7 +3,7 @@ color: var(--colorForeground); padding: var(--gridBaseline) calc(var(--gridBaseline) * 1.5); border-radius: calc(var(--gridBaseline) * 0.5); - border: 1px solid var(--colorPurple); + border: 1px solid var(--colorAccent); box-sizing: border-box; font-size: 14px; max-width: calc(var(--gridBaseline) * 32); diff --git a/src/ui/base/tooltip/tooltip.tsx b/src/ui/base/tooltip/tooltip.tsx index 44f2c3a..7a7e393 100644 --- a/src/ui/base/tooltip/tooltip.tsx +++ b/src/ui/base/tooltip/tooltip.tsx @@ -30,7 +30,7 @@ export const Tooltip = (props: TooltipProps) => { - + {content} From 01eefbd1f033e7cf140943ee8ce045e69eb312c2 Mon Sep 17 00:00:00 2001 From: D Date: Sun, 18 Jan 2026 21:52:17 +1100 Subject: [PATCH 4/4] . --- src/app/map_list_presenter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/map_list_presenter.ts b/src/app/map_list_presenter.ts index e09a197..9926f64 100644 --- a/src/app/map_list_presenter.ts +++ b/src/app/map_list_presenter.ts @@ -66,7 +66,6 @@ export class MapListPresenter { onClickBulkSelect() { this.store.enableBulkSelect = true; - showToast('Test toast', ToastIntent.DEFAULT); } async onClickBulkDownload() {