diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..9fcd6e3 --- /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 0000000..fd2cac0 --- /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 0000000..c5a693c --- /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