Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions frontend/src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(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<HTMLElement>(
'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<HTMLElement>(
'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<HTMLDivElement>) => {
if (event.target === event.currentTarget && !disableBackdropDismiss) {
onClose();
}
};

return (
<div
role="presentation"
onClick={handleBackdropClick}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(5, 10, 20, 0.85)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
padding: '1rem',
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descId : undefined}
tabIndex={-1}
className={`ui-modal ${className}`}
style={{
width: '100%',
maxWidth,
backgroundColor: 'var(--surface)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius)',
boxShadow: 'var(--shadow), 0 0 30px rgba(0, 0, 0, 0.6)',
color: 'var(--fg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
outline: 'none',
}}
onClick={(event) => event.stopPropagation()}
>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<div>
<h2
id={titleId}
style={{
margin: 0,
fontSize: '1.25rem',
fontFamily: 'var(--font-display)',
fontWeight: 600,
color: 'var(--fg)',
}}
>
{title}
</h2>
{description && (
<p
id={descId}
style={{
margin: '0.35rem 0 0',
fontSize: 'var(--text-sm)',
color: 'var(--fg-muted)',
lineHeight: 1.4,
}}
>
{description}
</p>
)}
</div>
{!disableBackdropDismiss && (
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
style={{
background: 'transparent',
border: 'none',
color: 'var(--fg-muted)',
cursor: 'pointer',
padding: '0.25rem',
fontSize: '1.25rem',
lineHeight: 1,
borderRadius: 'var(--radius-sm)',
}}
>
×
</button>
)}
</div>

<div
style={{
padding: '1.5rem',
overflowY: 'auto',
maxHeight: 'calc(80vh - 120px)',
}}
>
{children}
</div>
</div>
</div>
);
}

export default Modal;
105 changes: 105 additions & 0 deletions frontend/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSelectElement> {
label?: string;
hint?: string;
error?: string;
}

export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ 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 (
<div className={`ui-field ${className}`} style={{ marginBottom: '1.25rem' }}>
{label && (
<label
htmlFor={selectId}
style={{
display: 'block',
fontSize: 'var(--text-sm)',
fontWeight: 500,
color: 'var(--fg)',
marginBottom: '0.4rem',
}}
>
{label}
{required && (
<span aria-hidden="true" style={{ color: 'var(--destructive)', marginLeft: '0.25rem' }}>
*
</span>
)}
</label>
)}
{hint && (
<p
id={hintId}
style={{
fontSize: 'var(--text-xs)',
color: 'var(--fg-muted)',
marginTop: '-0.2rem',
marginBottom: '0.4rem',
lineHeight: 1.4,
}}
>
{hint}
</p>
)}
<select
ref={ref}
id={selectId}
required={required}
aria-invalid={!!error}
aria-describedby={describedBy}
className={`ui-select ${error ? 'ui-select--error' : ''}`}
style={{
width: '100%',
padding: '0.65rem 0.85rem',
fontSize: 'var(--text-sm)',
fontFamily: 'inherit',
backgroundColor: 'var(--surface-2)',
color: 'var(--fg)',
border: `1px solid ${error ? 'var(--destructive)' : 'var(--border)'}`,
borderRadius: 'var(--radius-sm)',
outline: 'none',
boxSizing: 'border-box',
cursor: 'pointer',
transition: 'border-color var(--dur-fast), box-shadow var(--dur-fast)',
...style,
}}
{...props}
>
{children}
</select>
{error && (
<p
id={errorId}
role="alert"
style={{
color: 'var(--destructive)',
fontSize: 'var(--text-xs)',
marginTop: '0.4rem',
fontWeight: 500,
}}
>
{error}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

export default Select;
Loading