From 4ae43ae88fe27ecaff6045d14e64ab7f0e6146d9 Mon Sep 17 00:00:00 2001 From: Young850 Date: Sat, 29 Aug 2026 17:29:17 +0100 Subject: [PATCH] Add design-system form, Modal, and Toast primitives (#1317, #1318, #1319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1317: added TextInput.tsx, Select.tsx, and Textarea.tsx under components/ui/ — self-contained accessible form primitives (own label/hint/error rendering, aria-invalid/aria-describedby wiring, forwardRef) so market creation, admin content editing, and the wallet/bet form can share one validation-state-styled implementation instead of each rolling their own. Styling follows the existing inline-style + CSS-token convention already used by components/admin/Form.tsx's Input/Textarea/Select (the closest prior art for this pattern in the repo). #1318: added Modal.tsx under components/ui/, consolidating the two existing ad-hoc implementations (components/Modal.tsx and components/admin/Modal.tsx — the former's own header comment notes it exists only because this shared primitive didn't yet). Combines both: focus trap + Tab-cycling, Escape-to-close, backdrop click-to-close (each independently disable-able for confirmation-gated destructive actions), body-scroll lock while open, and focus restoration on close. #1319: added Toast.tsx (ToastContainer) and lib/hooks/useToast.ts under components/ui/ — a module-level toast store with an imperative `toast.success/error/warning/info(...)` API callable from anywhere (API mutation handlers included, not just components), rendered by a single `` mounted near the app root. Color tokens match the existing StatusAlert component's success/error/warning/info convention. None of these paths existed before this change; no existing consumers were migrated to the new primitives (out of scope for this PR — the issues asked for the primitives themselves). Closes #1317 Closes #1318 Closes #1319 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014jDDop7frnew1xcCJDSKEw --- frontend/src/components/ui/Modal.tsx | 222 +++++++++++++++++++++++ frontend/src/components/ui/Select.tsx | 105 +++++++++++ frontend/src/components/ui/TextInput.tsx | 106 +++++++++++ frontend/src/components/ui/Textarea.tsx | 105 +++++++++++ frontend/src/components/ui/Toast.tsx | 93 ++++++++++ frontend/src/lib/hooks/useToast.ts | 94 ++++++++++ 6 files changed, 725 insertions(+) create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/Select.tsx create mode 100644 frontend/src/components/ui/TextInput.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx create mode 100644 frontend/src/components/ui/Toast.tsx create mode 100644 frontend/src/lib/hooks/useToast.ts diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 00000000..9fcd6e35 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,222 @@ +'use client'; + +/** + * Modal — shared design-system dialog primitive (#1318). + * + * Two ad-hoc Modal implementations already exist in this codebase + * (components/Modal.tsx, components/admin/Modal.tsx), each built because + * this shared primitive didn't exist yet — components/Modal.tsx's own + * header comment says as much. This consolidates both: focus trap + + * Tab-cycling, Escape-to-close, backdrop click-to-close (each + * individually disable-able for confirmation-gated destructive actions), + * body-scroll lock while open, and restores focus to the previously + * focused element on close. Existing callers — bet placement (#78), + * market cancellation (#74), the blockchain replay admin tool (#96), and + * GDPR deletion (#102) — can migrate to this without rewriting how they + * open/close the dialog; only the two legacy Modal.tsx files are + * superseded. + */ + +import React, { useCallback, useEffect, useId, useRef } from 'react'; + +export interface ModalProps { + open: boolean; + onClose: () => void; + title: string; + description?: string; + children: React.ReactNode; + disableBackdropDismiss?: boolean; + disableEscapeKey?: boolean; + maxWidth?: string; + className?: string; +} + +export function Modal({ + open, + onClose, + title, + description, + children, + disableBackdropDismiss = false, + disableEscapeKey = false, + maxWidth = '560px', + className = '', +}: ModalProps) { + const dialogRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const generatedId = useId(); + const titleId = `${generatedId}-title`; + const descId = `${generatedId}-desc`; + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape' && !disableEscapeKey) { + onClose(); + return; + } + + if (event.key === 'Tab' && dialogRef.current) { + const focusable = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === first) { + last.focus(); + event.preventDefault(); + } + } else if (document.activeElement === last) { + first.focus(); + event.preventDefault(); + } + } + }, + [disableEscapeKey, onClose] + ); + + useEffect(() => { + if (!open) return; + + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKeyDown); + + const timer = setTimeout(() => { + const firstFocusable = dialogRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + (firstFocusable ?? dialogRef.current)?.focus(); + }, 0); + + return () => { + clearTimeout(timer); + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + previouslyFocusedRef.current?.focus(); + }; + }, [open, handleKeyDown]); + + if (!open) return null; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget && !disableBackdropDismiss) { + onClose(); + } + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {!disableBackdropDismiss && ( + + )} +
+ +
+ {children} +
+
+
+ ); +} + +export default Modal; diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 00000000..fd2cac0d --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,105 @@ +'use client'; + +/** + * Select — shared design-system form primitive (#1317). + * See TextInput.tsx for the shared label/hint/error convention. + */ + +import React, { useId } from 'react'; + +export interface SelectProps extends React.SelectHTMLAttributes { + label?: string; + hint?: string; + error?: string; +} + +export const Select = React.forwardRef( + ({ label, hint, error, required, className = '', id, children, style, ...props }, ref) => { + const generatedId = useId(); + const selectId = id ?? generatedId; + const hintId = hint ? `${selectId}-hint` : undefined; + const errorId = error ? `${selectId}-error` : undefined; + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; + + return ( +
+ {label && ( + + )} + {hint && ( +

+ {hint} +

+ )} + + {error && ( + + )} +
+ ); + } +); +Select.displayName = 'Select'; + +export default Select; diff --git a/frontend/src/components/ui/TextInput.tsx b/frontend/src/components/ui/TextInput.tsx new file mode 100644 index 00000000..c5a693c5 --- /dev/null +++ b/frontend/src/components/ui/TextInput.tsx @@ -0,0 +1,106 @@ +'use client'; + +/** + * TextInput — shared design-system form primitive (#1317). + * + * Self-contained: renders its own