This design specification defines comprehensive modal behavior for Fluxora-Frontend, focusing on focus management, stacking order, and scroll lock behavior. The specification ensures consistent, accessible, and predictable modal interactions across all user flows and states.
- Clear Focus Management: Users always know where focus is and can navigate predictably
- Intuitive Modal Stacking: Users understand which modal is active in multi-modal scenarios
- Seamless Scroll Behavior: Page content doesn't jump or become inaccessible during modal interactions
- Accessibility Compliance: All users can navigate modals using assistive technology
- Focus Predictability: >95% of users can navigate modals without confusion
- Modal Stack Clarity: >90% of users understand which modal is active in stacked scenarios
- Scroll Behavior Consistency: Zero layout shifts when modals open/close
- Accessibility Compliance: WCAG 2.1 AA compliance for all modal interactions
- ConnectWalletModal: Basic focus trap and scroll lock
- CreateStreamModal: Multi-step modal with useModalAccessibility hook
- StreamCreatedModal: Success modal with accessibility hooks
- useModalAccessibility: Shared hook for focus management and scroll lock
- No consistent modal stacking system
- Inconsistent z-index management across modals
- Limited focus restoration patterns
- No modal priority system for nested scenarios
- Missing modal state management for complex flows
- ConnectWalletModal: Wallet connection flow
- CreateStreamModal: Multi-step stream creation
- StreamCreatedModal: Success confirmation
- ConfirmationDialogs: Delete/cancel confirmations
- ErrorDialogs: Error recovery options
- InfoDialogs: Help and information displays
- ToastNotifications: Temporary status messages
- LoadingOverlays: Loading state overlays
- DebugModals: Development and debugging tools
- ConnectWalletModal: Primary entry point
- InfoDialogs: Help and onboarding
- ErrorDialogs: Connection failure recovery
- CreateStreamModal: Core functionality
- ConfirmationDialogs: Destructive action confirmations
- StreamCreatedModal: Success feedback
- Default: Normal modal operation
- Loading: Processing state with disabled interactions
- Empty: No content or empty state display
- Error: Error state with recovery options
- Success: Success state with confirmation
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
interface FocusableElement {
element: HTMLElement;
priority: number;
group: 'primary' | 'secondary' | 'tertiary';
}- Initial Focus: First focusable element or specified initial focus
- Tab Navigation: Logical forward progression through focusable elements
- Shift+Tab Navigation: Logical backward progression
- Focus Restoration: Return to trigger element on modal close
- Focus Groups: Prioritize primary actions over secondary elements
interface FocusState {
currentElement: HTMLElement | null;
previousElement: HTMLElement | null;
focusableElements: FocusableElement[];
trapActive: boolean;
restorationPending: boolean;
}Modal Open:
1. Save current focus element
2. Apply scroll lock
3. Set focus to initial element
4. Activate focus trap
Modal Close:
1. Remove focus trap
2. Release scroll lock
3. Restore focus to trigger element
Modal Stack:
1. Save focus for each modal in stack
2. Apply focus trap to top modal only
3. Maintain scroll lock for entire stack
4. Restore focus to previous modal on close
Modal Replacement:
1. Save current modal focus state
2. Transition to new modal
3. Update focus trap to new modal
4. Maintain scroll lock throughout
Parent Modal -> Child Modal:
1. Save parent modal focus state
2. Transition to child modal
3. Update focus trap to child
4. Maintain scroll lock
Child Modal Close -> Parent Modal:
1. Restore parent modal focus state
2. Update focus trap to parent
3. Maintain scroll lock
:root {
--z-index-base: 1;
--z-index-dropdown: 100;
--z-index-sticky: 200;
--z-index-fixed: 300;
--z-index-modal-backdrop: 1000;
--z-index-modal: 1100;
--z-index-modal-high: 1200;
--z-index-toast: 2000;
--z-index-tooltip: 3000;
}enum ModalPriority {
LOW = 1100, // Info dialogs, help modals
MEDIUM = 1200, // Confirmation dialogs, error modals
HIGH = 1300, // Primary modals (connect, create stream)
CRITICAL = 1400, // System critical modals, security dialogs
}interface ModalStack {
modals: ModalInstance[];
activeModal: ModalInstance | null;
maxZIndex: number;
}
interface ModalInstance {
id: string;
priority: ModalPriority;
element: HTMLElement;
focusState: FocusState;
timestamp: number;
}class ModalManager {
private stack: ModalStack = {
modals: [],
activeModal: null,
maxZIndex: 1100,
};
registerModal(modal: ModalInstance): void {
// Add to stack with appropriate z-index
// Update focus management
// Apply scroll lock if needed
}
unregisterModal(modalId: string): void {
// Remove from stack
// Restore focus to previous modal
// Release scroll lock if stack is empty
}
bringToFront(modalId: string): void {
// Move modal to top of stack
// Update z-index
// Update focus trap
}
}- LIFO (Last In, First Out): Most recently opened modal gets focus
- Priority Override: Higher priority modals can push lower priority to back
- Auto-Cleanup: Remove destroyed modals from stack
- Focus Transfer: Smooth focus transitions between stacked modals
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(2, 8, 18, 0.8);
backdrop-filter: blur(5px);
z-index: var(--z-index-modal-backdrop);
}
.modal-backdrop.stack-depth-1 {
background: rgba(2, 8, 18, 0.6);
}
.modal-backdrop.stack-depth-2 {
background: rgba(2, 8, 18, 0.4);
}.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: var(--z-index-modal);
max-width: 90vw;
max-height: 90vh;
}
.modal.stack-offset {
transform: translate(-50%, -50%) scale(0.95);
opacity: 0.8;
}interface ScrollLockState {
lockCount: number;
originalOverflow: string;
originalPaddingRight: string;
scrollbarWidth: number;
isLocked: boolean;
}
class ScrollLockManager {
private state: ScrollLockState = {
lockCount: 0,
originalOverflow: '',
originalPaddingRight: '',
scrollbarWidth: 0,
isLocked: false,
};
lock(): void {
if (this.state.lockCount === 0) {
this.captureOriginalState();
this.applyScrollLock();
}
this.state.lockCount++;
}
unlock(): void {
this.state.lockCount = Math.max(0, this.state.lockCount - 1);
if (this.state.lockCount === 0) {
this.restoreOriginalState();
}
}
}const getScrollbarWidth = (): number => {
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
document.body.appendChild(outer);
const inner = document.createElement('div');
outer.appendChild(inner);
const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
outer.parentNode?.removeChild(outer);
return scrollbarWidth;
};Modal Open:
1. Calculate scrollbar width
2. Store original body styles
3. Apply overflow: hidden to body
4. Add padding-right to compensate for scrollbar
5. Maintain scroll position
Modal Close:
1. Restore original body styles
2. Remove scrollbar compensation
3. Maintain scroll position
First Modal Open:
1. Apply scroll lock (count = 1)
2. Store original scroll position
Second Modal Open:
1. Increment lock count (count = 2)
2. Maintain existing scroll lock
Second Modal Close:
1. Decrement lock count (count = 1)
2. Maintain scroll lock
First Modal Close:
1. Decrement lock count (count = 0)
2. Release scroll lock
3. Restore original scroll position
interface ScrollPosition {
x: number;
y: number;
}
const preserveScrollPosition = (): ScrollPosition => {
return {
x: window.scrollX,
y: window.scrollY,
};
};
const restoreScrollPosition = (position: ScrollPosition): void => {
window.scrollTo(position.x, position.y);
};- Content Height Changes: Handle modal content that changes height
- Overflow Handling: Scroll within modal when content exceeds viewport
- Resize Events: Adjust modal positioning on window resize
- Touch Scrolling: Prevent touch scrolling on body
- Viewport Changes: Handle orientation changes
- Virtual Keyboard: Adjust modal position for keyboard
- Screen Readers: Announce modal state changes
- Focus Management: Maintain focus within modal
- Keyboard Navigation: Ensure all functionality via keyboard
interface BaseModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
description?: string;
priority?: ModalPriority;
initialFocusRef?: RefObject<HTMLElement>;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
preventClose?: boolean;
className?: string;
children: ReactNode;
}
interface BaseModalState {
isVisible: boolean;
isAnimating: boolean;
focusState: FocusState;
scrollLockActive: boolean;
}export function BaseModal({
isOpen,
onClose,
title,
description,
priority = ModalPriority.MEDIUM,
initialFocusRef,
closeOnBackdropClick = true,
closeOnEscape = true,
preventClose = false,
className,
children,
}: BaseModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const modalId = useModalId();
useModalManager({
id: modalId,
isOpen,
priority,
element: modalRef,
onClose,
});
useModalAccessibility({
isOpen,
onClose,
modalRef,
initialFocusRef,
});
// Animation and visibility logic
useEffect(() => {
if (isOpen) {
setIsVisible(true);
} else {
// Handle exit animation
const timer = setTimeout(() => setIsVisible(false), 200);
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!isVisible) return null;
return (
<div className={`modal-backdrop ${className}`}>
<div
ref={modalRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby={title ? `${modalId}-title` : undefined}
aria-describedby={description ? `${modalId}-description` : undefined}
style={{ zIndex: priority }}
>
{title && (
<h2 id={`${modalId}-title`} className="modal-title">
{title}
</h2>
)}
{description && (
<p id={`${modalId}-description`} className="modal-description">
{description}
</p>
)}
<div className="modal-content">
{children}
</div>
</div>
</div>
);
}interface UseModalManagerOptions {
id: string;
isOpen: boolean;
priority: ModalPriority;
element: RefObject<HTMLElement>;
onClose: () => void;
}
interface UseModalManagerReturn {
zIndex: number;
stackDepth: number;
isActive: boolean;
bringToFront: () => void;
}export function useModalManager({
id,
isOpen,
priority,
element,
onClose,
}: UseModalManagerOptions): UseModalManagerReturn {
const [zIndex, setZIndex] = useState(priority);
const [stackDepth, setStackDepth] = useState(0);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
if (isOpen) {
modalManager.registerModal({
id,
priority,
element: element.current!,
focusState: getInitialFocusState(),
timestamp: Date.now(),
});
} else {
modalManager.unregisterModal(id);
}
}, [id, isOpen, priority, element]);
useEffect(() => {
const unsubscribe = modalManager.subscribe((stack) => {
const modal = stack.modals.find(m => m.id === id);
if (modal) {
setZIndex(modal.priority);
setStackDepth(stack.modals.length - stack.modals.findIndex(m => m.id === id) - 1);
setIsActive(stack.activeModal?.id === id);
}
});
return unsubscribe;
}, [id]);
const bringToFront = useCallback(() => {
modalManager.bringToFront(id);
}, [id]);
return {
zIndex,
stackDepth,
isActive,
bringToFront,
};
}interface UseModalAccessibilityOptions {
isOpen: boolean;
onClose: () => void;
modalRef: RefObject<HTMLElement>;
initialFocusRef?: RefObject<HTMLElement>;
restoreFocus?: boolean;
trapFocus?: boolean;
}
interface UseModalAccessibilityReturn {
focusableElements: HTMLElement[];
currentFocus: HTMLElement | null;
setFocus: (element: HTMLElement) => void;
}export function useModalAccessibility({
isOpen,
onClose,
modalRef,
initialFocusRef,
restoreFocus = true,
trapFocus = true,
}: UseModalAccessibilityOptions): UseModalAccessibilityReturn {
const [currentFocus, setCurrentFocus] = useState<HTMLElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Save previous focus
previousFocusRef.current = document.activeElement as HTMLElement;
// Apply scroll lock
scrollLockManager.lock();
// Set initial focus
const focusTarget = initialFocusRef?.current ??
getFocusableElements(modalRef.current)[0] ??
modalRef.current;
focusTarget?.focus();
setCurrentFocus(focusTarget ?? null);
// Setup focus trap
if (trapFocus) {
const handleKeyDown = createFocusTrapHandler(modalRef.current!, onClose);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
scrollLockManager.unlock();
if (restoreFocus && previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}
return () => {
scrollLockManager.unlock();
};
}, [isOpen, modalRef, initialFocusRef, onClose, restoreFocus, trapFocus]);
const setFocus = useCallback((element: HTMLElement) => {
element.focus();
setCurrentFocus(element);
}, []);
const focusableElements = useMemo(() => {
return getFocusableElements(modalRef.current);
}, [modalRef]);
return {
focusableElements,
currentFocus,
setFocus,
};
}type ModalState =
| 'closed'
| 'opening'
| 'open'
| 'closing'
| 'error';
type ModalEvent =
| { type: 'OPEN' }
| { type: 'CLOSE' }
| { type: 'OPEN_COMPLETE' }
| { type: 'CLOSE_COMPLETE' }
| { type: 'ERROR'; payload: string };
interface ModalStateMachine {
state: ModalState;
context: {
isOpen: boolean;
isAnimating: boolean;
error: string | null;
};
}const modalTransitions = {
closed: {
OPEN: 'opening',
},
opening: {
OPEN_COMPLETE: 'open',
ERROR: 'error',
CLOSE: 'closing',
},
open: {
CLOSE: 'closing',
ERROR: 'error',
},
closing: {
CLOSE_COMPLETE: 'closed',
ERROR: 'error',
},
error: {
CLOSE: 'closing',
OPEN: 'opening',
},
};interface ModalContextValue {
modals: Map<string, ModalInstance>;
activeModal: string | null;
stackDepth: number;
registerModal: (modal: ModalInstance) => void;
unregisterModal: (id: string) => void;
activateModal: (id: string) => void;
closeModal: (id: string) => void;
closeAllModals: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
export function ModalProvider({ children }: { children: ReactNode }) {
const [modals, setModals] = useState<Map<string, ModalInstance>>(new Map());
const [activeModal, setActiveModal] = useState<string | null>(null);
const registerModal = useCallback((modal: ModalInstance) => {
setModals(prev => new Map(prev).set(modal.id, modal));
}, []);
const unregisterModal = useCallback((id: string) => {
setModals(prev => {
const newMap = new Map(prev);
newMap.delete(id);
return newMap;
});
}, []);
// ... other methods
const value: ModalContextValue = {
modals,
activeModal,
registerModal,
unregisterModal,
activateModal: setActiveModal,
closeModal: unregisterModal,
closeAllModals: () => setModals(new Map()),
};
return (
<ModalContext.Provider value={value}>
{children}
</ModalContext.Provider>
);
}- Focus Trap: Focus must be trapped within active modal
- Focus Restoration: Focus must return to trigger element on close
- Visible Focus: Focus indicators must be clearly visible
- Logical Order: Focus order must follow logical reading order
- Tab Navigation: Forward navigation through focusable elements
- Shift+Tab Navigation: Backward navigation through focusable elements
- Escape Key: Close modal with Escape key
- Enter/Space: Activate focused buttons and controls
- Role Attributes: Proper role="dialog" and aria-modal="true"
- Labeling: aria-labelledby and aria-describedby for modal content
- Live Regions: Announce modal state changes to screen readers
- Focus Announcements: Announce focus changes within modal
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Modal Title</h2>
<p id="modal-description">Modal description</p>
<!-- Modal content -->
<div role="status" aria-live="polite" aria-atomic="true">
Status message
</div>
<div role="alert" aria-live="assertive" aria-atomic="true">
Error message
</div>
</div><!-- Focusable elements with proper labels -->
<button aria-label="Close modal">×</button>
<button aria-describedby="action-description">Action</button>
<input aria-label="Enter amount" aria-required="true" />describe('Modal Accessibility', () => {
it('should trap focus within modal', () => {
render(<TestModal isOpen={true} />);
const firstFocusable = screen.getAllByRole('button')[0];
expect(firstFocusable).toHaveFocus();
// Tab through all elements
fireEvent.keyDown(document, { key: 'Tab' });
// Focus should remain within modal
});
it('should restore focus on close', () => {
const { getByRole } = render(<TestModal isOpen={true} />);
const triggerButton = getByRole('button', { name: 'Open Modal' });
triggerButton.click();
fireEvent.keyDown(document, { key: 'Escape' });
expect(triggerButton).toHaveFocus();
});
});- Focus is trapped within modal
- Focus returns to trigger element on close
- Escape key closes modal
- Tab navigation works correctly
- Screen reader announces modal content
- ARIA attributes are correct
- Lazy Loading: Load modal content only when opened
- Portal Rendering: Use React Portal for modal rendering
- Animation Optimization: Use CSS transforms for animations
- Memory Management: Clean up event listeners and timers
- Debounced Focus: Debounce rapid focus changes
- Efficient Queries: Cache focusable element queries
- Minimal DOM Queries: Minimize DOM queries in focus trap
- Optimized Event Handlers: Use passive event listeners where possible
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Focus trap logic
};
document.addEventListener('keydown', handleKeyDown, { passive: true });
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);useEffect(() => {
if (!isOpen) return;
const animationTimer = setTimeout(() => {
setIsAnimating(false);
}, 200);
return () => clearTimeout(animationTimer);
}, [isOpen]);const LazyModal = lazy(() => import('./Modal'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyModal isOpen={isOpen} />
</Suspense>
);
}- Export only necessary functions
- Use proper import/export syntax
- Avoid unused dependencies
- Optimize CSS imports
src/components/modal/
BaseModal.tsx
BaseModal.module.css
ModalProvider.tsx
useModalManager.ts
useModalAccessibility.ts
ModalManager.ts
ScrollLockManager.ts
__tests__/
BaseModal.test.tsx
useModalManager.test.tsx
useModalAccessibility.test.tsx
- ConnectWalletModal: Migrate to BaseModal
- CreateStreamModal: Update to use new hooks
- StreamCreatedModal: Use enhanced accessibility
- ConfirmationDialog: Standardized confirmation modals
- ErrorDialog: Error handling modals
- InfoDialog: Help and information modals
- Implement BaseModal component
- Create modal management hooks
- Update existing modals gradually
- Add advanced features
- Implement modal stacking
- Add comprehensive testing
- Performance optimization
- Bundle size reduction
- Documentation completion
describe('BaseModal', () => {
it('should render when isOpen is true', () => {
render(<BaseModal isOpen={true} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('should not render when isOpen is false', () => {
render(<BaseModal isOpen={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should trap focus within modal', () => {
render(<BaseModal isOpen={true} />);
// Test focus trap behavior
});
});describe('useModalManager', () => {
it('should register modal when open', () => {
const { result } = renderHook(() => useModalManager({
id: 'test-modal',
isOpen: true,
priority: ModalPriority.MEDIUM,
element: { current: mockElement },
onClose: jest.fn(),
}));
expect(modalManager.getModal('test-modal')).toBeDefined();
});
});describe('Modal Stacking', () => {
it('should handle multiple modals correctly', () => {
render(
<>
<BaseModal id="modal1" isOpen={true} priority={ModalPriority.LOW} />
<BaseModal id="modal2" isOpen={true} priority={ModalPriority.HIGH} />
</>
);
expect(modalManager.getActiveModal()).toBe('modal2');
});
});describe('Focus Management Integration', () => {
it('should manage focus across modal transitions', () => {
// Test focus behavior across modal open/close/stack scenarios
});
});describe('Screen Reader Support', () => {
it('should announce modal content', () => {
render(<BaseModal isOpen={true} title="Test Modal" />);
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
expect(screen.getByRole('dialog')).toHaveAttribute('aria-labelledby');
});
});describe('Keyboard Navigation', () => {
it('should navigate with Tab key', () => {
render(<BaseModal isOpen={true} />);
fireEvent.keyDown(document, { key: 'Tab' });
// Test focus movement
});
});- Loading Failures: Handle modal content loading errors
- Render Errors: Graceful fallback for render failures
- Network Errors: Handle network-dependent modal content
- No Focusable Elements: Handle modals with no focusable content
- Hidden Elements: Handle dynamically hidden focusable elements
- Disabled Elements: Handle disabled interactive elements
- Body Style Conflicts: Handle conflicting body styles
- Scrollbar Calculation: Handle scrollbar width calculation errors
- Mobile Viewport: Handle mobile viewport changes
- Focus Recovery: Auto-focus to first available element
- Scroll Recovery: Auto-restore scroll position
- State Recovery: Auto-recover from transient errors
- Manual Focus: Allow manual focus setting
- Manual Close: Force close problematic modals
- Reset Options: Reset modal state to defaults
- Modal Inspector: Development tool for inspecting modal state
- Focus Visualizer: Visual focus indicator for debugging
- Stack Viewer: Visual modal stack representation
- Error Tracking: Track modal-related errors
- Performance Metrics: Monitor modal performance
- Usage Analytics: Track modal usage patterns
/**
* BaseModal Component
*
* @param isOpen - Whether the modal is open
* @param onClose - Callback when modal is closed
* @param title - Modal title (optional)
* @param description - Modal description (optional)
* @param priority - Modal priority for stacking
* @param initialFocusRef - Initial focus element ref
* @param closeOnBackdropClick - Close on backdrop click
* @param closeOnEscape - Close on escape key
* @param preventClose - Prevent modal from closing
* @param className - Additional CSS classes
* @param children - Modal content
*//**
* useModalManager Hook
*
* Manages modal registration, stacking, and focus.
*
* @param options - Modal manager options
* @returns Modal manager state and controls
*/function MyComponent() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<BaseModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="My Modal"
priority={ModalPriority.MEDIUM}
>
<p>Modal content goes here</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</BaseModal>
</>
);
}function AdvancedModal() {
const [isOpen, setIsOpen] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement>(null);
return (
<BaseModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="Advanced Modal"
priority={ModalPriority.HIGH}
initialFocusRef={initialFocusRef}
closeOnBackdropClick={false}
preventClose={true}
>
<button ref={initialFocusRef}>Initial Focus</button>
<button onClick={() => setIsOpen(false)}>Can Close</button>
</BaseModal>
);
}- Regular Updates: Keep dependencies up to date
- Performance Monitoring: Regular performance audits
- Accessibility Testing: Regular accessibility reviews
- Documentation Updates: Keep documentation current
- Version Management: Semantic versioning for breaking changes
- Migration Guides: Provide migration guides for breaking changes
- Deprecation Warnings: Warn about deprecated features
- Backward Compatibility: Maintain backward compatibility when possible
Document Version: 1.0
Last Updated: 2025-04-29
Next Review: 2025-05-06
Owner: Design Team
Reviewers: PM, Engineering Lead, Accessibility Specialist