Responsive Design - RumenDamyanov/js-chess GitHub Wiki
Responsive Design
Comprehensive guide to creating responsive chess interfaces that work seamlessly across all devices and screen sizes.
Overview
This guide covers responsive design principles for the chess showcase, including:
- Mobile-first design approach
- Flexible board sizing
- Touch-friendly interactions
- Progressive disclosure
- Adaptive layouts
- Performance considerations
Design Principles
Mobile-First Approach
Start with mobile constraints and progressively enhance for larger screens:
/* Mobile base styles (320px+) */
.chess-container {
padding: 1rem;
max-width: 100vw;
}
/* Tablet styles (768px+) */
@media (min-width: 768px) {
.chess-container {
padding: 2rem;
max-width: 768px;
}
}
/* Desktop styles (1024px+) */
@media (min-width: 1024px) {
.chess-container {
padding: 3rem;
max-width: 1200px;
margin: 0 auto;
}
}
Flexible Grid System
Use CSS Grid and Flexbox for adaptive layouts:
.chess-layout {
display: grid;
gap: 1rem;
grid-template-areas:
"board"
"controls"
"history"
"chat";
}
@media (min-width: 768px) {
.chess-layout {
grid-template-columns: 1fr 300px;
grid-template-areas:
"board sidebar"
"controls sidebar";
}
.sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
gap: 1rem;
}
}
@media (min-width: 1024px) {
.chess-layout {
grid-template-columns: 300px 1fr 300px;
grid-template-areas:
"left-panel board right-panel";
}
}
Responsive Chess Board
Dynamic Board Sizing
/* shared/styles/responsive-board.css */
.chess-board-container {
width: 100%;
max-width: min(100vw - 2rem, 100vh - 2rem, 600px);
aspect-ratio: 1;
margin: 0 auto;
position: relative;
}
.chess-board {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
border: 2px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
/* Responsive board sizing breakpoints */
@media (max-width: 480px) {
.chess-board-container {
max-width: calc(100vw - 1rem);
}
.chess-board {
border-width: 1px;
border-radius: 4px;
}
}
@media (min-width: 1200px) {
.chess-board-container {
max-width: 700px;
}
}
Touch-Friendly Squares
.chess-square {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* Minimum touch target size (44px recommended) */
@media (max-width: 600px) {
.chess-square {
min-height: 44px;
min-width: 44px;
}
}
/* Enhanced touch feedback */
.chess-square:active {
transform: scale(0.95);
transition: transform 100ms ease;
}
.chess-square--selected {
background-color: var(--color-primary) !important;
box-shadow: inset 0 0 0 2px var(--color-primary-variant);
}
@media (hover: hover) {
.chess-square:hover {
filter: brightness(1.1);
}
}
Responsive Components
Adaptive Move History
.move-history {
height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.move-history-header {
padding: 0.75rem;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.move-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.move-history {
height: 200px;
}
.move-history-header {
padding: 0.5rem;
}
.move-controls {
gap: 0.25rem;
}
.control-btn {
width: 32px;
height: 32px;
font-size: 0.8rem;
}
.move-pair {
padding: 0.25rem;
gap: 0.25rem;
}
.move-button {
min-width: 2.5rem;
height: 24px;
font-size: 0.75rem;
}
}
/* Tablet landscape */
@media (min-width: 768px) and (max-width: 1023px) {
.move-history {
height: 400px;
}
}
/* Desktop */
@media (min-width: 1024px) {
.move-history {
height: 500px;
}
}
Responsive Game Controls
.game-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
padding: 1rem;
}
.control-button {
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
cursor: pointer;
transition: all 200ms ease;
font-size: 0.9rem;
white-space: nowrap;
}
/* Mobile stacked layout */
@media (max-width: 480px) {
.game-controls {
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem;
}
.control-button {
padding: 1rem;
font-size: 1rem;
}
}
/* Two-column on small tablets */
@media (min-width: 481px) and (max-width: 767px) {
.game-controls {
grid-template-columns: 1fr 1fr;
}
}
Layout Patterns
Progressive Disclosure
/* Hide secondary information on mobile */
.secondary-info {
display: none;
}
@media (min-width: 768px) {
.secondary-info {
display: block;
}
}
/* Collapsible sections */
.collapsible-section {
border: 1px solid var(--color-border);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.collapsible-header {
padding: 0.75rem;
background: var(--color-surface-variant);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.collapsible-content {
padding: 0.75rem;
display: none;
}
.collapsible-section.expanded .collapsible-content {
display: block;
}
/* Auto-expand on larger screens */
@media (min-width: 1024px) {
.collapsible-content {
display: block !important;
}
.collapsible-header {
pointer-events: none;
}
}
Sidebar Management
.sidebar {
position: fixed;
top: 0;
right: -100%;
width: 300px;
height: 100vh;
background: var(--color-surface);
border-left: 1px solid var(--color-border);
transition: right 300ms ease;
z-index: 1000;
overflow-y: auto;
}
.sidebar.open {
right: 0;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 300ms ease;
}
.sidebar-overlay.visible {
opacity: 1;
visibility: visible;
}
/* Desktop: show sidebar inline */
@media (min-width: 1024px) {
.sidebar {
position: static;
width: auto;
height: auto;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.sidebar-overlay {
display: none;
}
}
Framework Implementations
Angular Responsive Directive
// angular-chess/src/app/directives/responsive.directive.ts
import { Directive, ElementRef, Input, OnInit, OnDestroy } from '@angular/core';
@Directive({
selector: '[appResponsive]'
})
export class ResponsiveDirective implements OnInit, OnDestroy {
@Input() breakpoints: { [key: string]: number } = {
mobile: 768,
tablet: 1024,
desktop: 1200
};
private mediaQueries: MediaQueryList[] = [];
private currentBreakpoint = 'mobile';
constructor(private el: ElementRef) {}
ngOnInit() {
this.setupMediaQueries();
this.updateClasses();
}
ngOnDestroy() {
this.mediaQueries.forEach(mq => {
mq.removeEventListener('change', this.handleMediaChange);
});
}
private setupMediaQueries() {
Object.entries(this.breakpoints).forEach(([name, width]) => {
const mq = window.matchMedia(`(min-width: ${width}px)`);
mq.addEventListener('change', this.handleMediaChange.bind(this));
this.mediaQueries.push(mq);
});
}
private handleMediaChange = () => {
this.updateClasses();
};
private updateClasses() {
// Remove existing breakpoint classes
Object.keys(this.breakpoints).forEach(bp => {
this.el.nativeElement.classList.remove(`breakpoint-${bp}`);
});
// Add current breakpoint class
const current = this.getCurrentBreakpoint();
this.el.nativeElement.classList.add(`breakpoint-${current}`);
this.currentBreakpoint = current;
}
private getCurrentBreakpoint(): string {
const sorted = Object.entries(this.breakpoints)
.sort(([, a], [, b]) => b - a);
for (const [name, width] of sorted) {
if (window.innerWidth >= width) {
return name;
}
}
return 'mobile';
}
}
React Responsive Hook
// react-chess/src/hooks/useResponsive.js
import { useState, useEffect } from 'react';
const defaultBreakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1200
};
export function useResponsive(breakpoints = defaultBreakpoints) {
const [currentBreakpoint, setCurrentBreakpoint] = useState('mobile');
const [windowSize, setWindowSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0
});
useEffect(() => {
function handleResize() {
const width = window.innerWidth;
const height = window.innerHeight;
setWindowSize({ width, height });
// Determine current breakpoint
const sorted = Object.entries(breakpoints)
.sort(([, a], [, b]) => b - a);
for (const [name, minWidth] of sorted) {
if (width >= minWidth) {
setCurrentBreakpoint(name);
break;
}
}
}
handleResize(); // Set initial values
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoints]);
const isMobile = currentBreakpoint === 'mobile';
const isTablet = currentBreakpoint === 'tablet';
const isDesktop = currentBreakpoint === 'desktop';
const isTouch = 'ontouchstart' in window;
return {
currentBreakpoint,
windowSize,
isMobile,
isTablet,
isDesktop,
isTouch,
isSmallScreen: windowSize.width < breakpoints.tablet
};
}
Vue Responsive Composable
// vue-chess/src/composables/useResponsive.js
import { ref, reactive, onMounted, onUnmounted } from 'vue';
export function useResponsive(breakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1200
}) {
const currentBreakpoint = ref('mobile');
const windowSize = reactive({
width: 0,
height: 0
});
function handleResize() {
windowSize.width = window.innerWidth;
windowSize.height = window.innerHeight;
// Determine current breakpoint
const sorted = Object.entries(breakpoints)
.sort(([, a], [, b]) => b - a);
for (const [name, minWidth] of sorted) {
if (windowSize.width >= minWidth) {
currentBreakpoint.value = name;
break;
}
}
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
return {
currentBreakpoint,
windowSize,
isMobile: computed(() => currentBreakpoint.value === 'mobile'),
isTablet: computed(() => currentBreakpoint.value === 'tablet'),
isDesktop: computed(() => currentBreakpoint.value === 'desktop'),
isTouch: 'ontouchstart' in window,
isSmallScreen: computed(() => windowSize.width < breakpoints.tablet)
};
}
Touch Interactions
Touch-Friendly Piece Movement
// shared/utils/TouchHandler.js
export class TouchHandler {
constructor(boardElement, onMove) {
this.board = boardElement;
this.onMove = onMove;
this.isDragging = false;
this.draggedPiece = null;
this.startSquare = null;
this.currentSquare = null;
this.setupEventListeners();
}
setupEventListeners() {
// Prevent default touch behaviors
this.board.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
this.board.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
this.board.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false });
// Also handle mouse events for desktop
this.board.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.board.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.board.addEventListener('mouseup', this.handleMouseUp.bind(this));
}
handleTouchStart(event) {
event.preventDefault();
const touch = event.touches[0];
this.startDrag(touch.clientX, touch.clientY);
}
handleTouchMove(event) {
event.preventDefault();
if (this.isDragging) {
const touch = event.touches[0];
this.updateDrag(touch.clientX, touch.clientY);
}
}
handleTouchEnd(event) {
event.preventDefault();
this.endDrag();
}
handleMouseDown(event) {
this.startDrag(event.clientX, event.clientY);
}
handleMouseMove(event) {
if (this.isDragging) {
this.updateDrag(event.clientX, event.clientY);
}
}
handleMouseUp(event) {
this.endDrag();
}
startDrag(x, y) {
const square = this.getSquareFromCoordinates(x, y);
if (!square) return;
const piece = square.querySelector('.chess-piece');
if (!piece) return;
this.isDragging = true;
this.draggedPiece = piece;
this.startSquare = square;
this.currentSquare = square;
// Visual feedback
piece.classList.add('dragging');
square.classList.add('drag-source');
// Create ghost piece for touch devices
if ('ontouchstart' in window) {
this.createGhostPiece(piece, x, y);
}
}
updateDrag(x, y) {
if (!this.isDragging) return;
const square = this.getSquareFromCoordinates(x, y);
// Update ghost piece position
if (this.ghostPiece) {
this.ghostPiece.style.left = `${x - 25}px`;
this.ghostPiece.style.top = `${y - 25}px`;
}
// Highlight potential drop target
if (square !== this.currentSquare) {
if (this.currentSquare) {
this.currentSquare.classList.remove('drag-over');
}
if (square) {
square.classList.add('drag-over');
}
this.currentSquare = square;
}
}
endDrag() {
if (!this.isDragging) return;
const targetSquare = this.currentSquare;
// Clean up visual state
this.draggedPiece.classList.remove('dragging');
this.startSquare.classList.remove('drag-source');
if (targetSquare) {
targetSquare.classList.remove('drag-over');
}
// Remove ghost piece
if (this.ghostPiece) {
this.ghostPiece.remove();
this.ghostPiece = null;
}
// Execute move if valid
if (targetSquare && targetSquare !== this.startSquare) {
const from = this.startSquare.dataset.square;
const to = targetSquare.dataset.square;
this.onMove(from, to);
}
// Reset state
this.isDragging = false;
this.draggedPiece = null;
this.startSquare = null;
this.currentSquare = null;
}
createGhostPiece(originalPiece, x, y) {
this.ghostPiece = originalPiece.cloneNode(true);
this.ghostPiece.classList.add('ghost-piece');
this.ghostPiece.style.position = 'fixed';
this.ghostPiece.style.left = `${x - 25}px`;
this.ghostPiece.style.top = `${y - 25}px`;
this.ghostPiece.style.pointerEvents = 'none';
this.ghostPiece.style.zIndex = '1000';
document.body.appendChild(this.ghostPiece);
// Hide original piece
originalPiece.style.opacity = '0.3';
}
getSquareFromCoordinates(x, y) {
const element = document.elementFromPoint(x, y);
return element?.closest('.chess-square');
}
}
Gesture Recognition
// shared/utils/GestureRecognizer.js
export class GestureRecognizer {
constructor(element, callbacks = {}) {
this.element = element;
this.callbacks = callbacks;
this.touches = [];
this.setupEventListeners();
}
setupEventListeners() {
this.element.addEventListener('touchstart', this.handleTouchStart.bind(this));
this.element.addEventListener('touchmove', this.handleTouchMove.bind(this));
this.element.addEventListener('touchend', this.handleTouchEnd.bind(this));
}
handleTouchStart(event) {
this.touches = Array.from(event.touches).map(touch => ({
id: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
currentX: touch.clientX,
currentY: touch.clientY,
startTime: Date.now()
}));
}
handleTouchMove(event) {
event.preventDefault();
this.touches.forEach(touch => {
const eventTouch = Array.from(event.touches)
.find(t => t.identifier === touch.id);
if (eventTouch) {
touch.currentX = eventTouch.clientX;
touch.currentY = eventTouch.clientY;
}
});
// Detect pinch for zoom
if (this.touches.length === 2) {
this.handlePinch();
}
}
handleTouchEnd(event) {
const endTime = Date.now();
this.touches.forEach(touch => {
const duration = endTime - touch.startTime;
const deltaX = touch.currentX - touch.startX;
const deltaY = touch.currentY - touch.startY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// Tap detection
if (duration < 300 && distance < 10) {
this.callbacks.tap?.(touch.startX, touch.startY);
}
// Swipe detection
if (duration < 500 && distance > 50) {
const direction = this.getSwipeDirection(deltaX, deltaY);
this.callbacks.swipe?.(direction, distance);
}
});
this.touches = [];
}
handlePinch() {
if (this.touches.length !== 2) return;
const [touch1, touch2] = this.touches;
const currentDistance = this.getDistance(
touch1.currentX, touch1.currentY,
touch2.currentX, touch2.currentY
);
const startDistance = this.getDistance(
touch1.startX, touch1.startY,
touch2.startX, touch2.startY
);
const scale = currentDistance / startDistance;
this.callbacks.pinch?.(scale);
}
getDistance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
getSwipeDirection(deltaX, deltaY) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? 'right' : 'left';
} else {
return deltaY > 0 ? 'down' : 'up';
}
}
}
Performance Optimization
CSS Container Queries
/* Use container queries for component-level responsiveness */
.chess-component {
container-type: inline-size;
}
@container (max-width: 400px) {
.chess-board {
border-width: 1px;
}
.chess-piece {
font-size: 0.8em;
}
}
@container (min-width: 600px) {
.chess-board {
border-width: 3px;
}
.chess-piece {
font-size: 1.2em;
}
}
Lazy Loading and Code Splitting
// shared/utils/LazyComponents.js
export const LazyComponents = {
// Lazy load heavy components
MoveAnalysis: () => import('../components/MoveAnalysis'),
GameDatabase: () => import('../components/GameDatabase'),
EngineAnalysis: () => import('../components/EngineAnalysis'),
// Load based on screen size
loadResponsiveComponent(componentName, breakpoint) {
const isMobile = window.innerWidth < 768;
if (isMobile && componentName === 'MoveAnalysis') {
return import('../components/MoveAnalysisMobile');
}
return this[componentName]();
}
};
Virtual Scrolling for Mobile
// shared/components/VirtualScrollList.js
export class VirtualScrollList {
constructor(container, itemHeight = 40, bufferSize = 5) {
this.container = container;
this.itemHeight = itemHeight;
this.bufferSize = bufferSize;
this.items = [];
this.visibleStart = 0;
this.visibleEnd = 0;
this.setupScrolling();
}
setItems(items) {
this.items = items;
this.updateVisibleRange();
this.render();
}
setupScrolling() {
this.container.addEventListener('scroll', () => {
this.updateVisibleRange();
this.render();
});
}
updateVisibleRange() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
this.visibleStart = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.bufferSize
);
this.visibleEnd = Math.min(this.items.length,
Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.bufferSize
);
}
render() {
// Clear container
this.container.innerHTML = '';
// Create spacer for items before visible range
if (this.visibleStart > 0) {
const spacer = document.createElement('div');
spacer.style.height = `${this.visibleStart * this.itemHeight}px`;
this.container.appendChild(spacer);
}
// Render visible items
for (let i = this.visibleStart; i < this.visibleEnd; i++) {
const item = this.createItem(this.items[i], i);
this.container.appendChild(item);
}
// Create spacer for items after visible range
const remainingItems = this.items.length - this.visibleEnd;
if (remainingItems > 0) {
const spacer = document.createElement('div');
spacer.style.height = `${remainingItems * this.itemHeight}px`;
this.container.appendChild(spacer);
}
}
createItem(data, index) {
const item = document.createElement('div');
item.className = 'virtual-item';
item.style.height = `${this.itemHeight}px`;
item.textContent = data.toString();
return item;
}
}
Testing Responsive Design
Responsive Testing Utilities
// shared/testing/ResponsiveTestUtils.js
export class ResponsiveTestUtils {
static setViewportSize(width, height) {
// For testing frameworks
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: width,
});
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: height,
});
// Trigger resize event
window.dispatchEvent(new Event('resize'));
}
static async testBreakpoints(component, breakpoints) {
const results = {};
for (const [name, width] of Object.entries(breakpoints)) {
this.setViewportSize(width, 800);
await new Promise(resolve => setTimeout(resolve, 100)); // Wait for updates
results[name] = {
width,
classes: Array.from(component.classList),
computedStyles: window.getComputedStyle(component)
};
}
return results;
}
static simulateTouch(element, touches) {
const touchEvent = new TouchEvent('touchstart', {
touches: touches.map(touch => new Touch({
identifier: touch.id || 0,
target: element,
clientX: touch.x,
clientY: touch.y
}))
});
element.dispatchEvent(touchEvent);
}
}
Automated Visual Testing
// tests/visual/responsive.test.js
import { ResponsiveTestUtils } from '../shared/testing/ResponsiveTestUtils';
describe('Responsive Design', () => {
const breakpoints = {
mobile: 320,
tablet: 768,
desktop: 1024
};
test('chess board scales correctly', async () => {
const boardElement = document.querySelector('.chess-board-container');
for (const [name, width] of Object.entries(breakpoints)) {
ResponsiveTestUtils.setViewportSize(width, 800);
const computedStyle = window.getComputedStyle(boardElement);
const boardWidth = parseInt(computedStyle.width);
// Board should never exceed viewport width
expect(boardWidth).toBeLessThanOrEqual(width);
// Board should maintain aspect ratio
const boardHeight = parseInt(computedStyle.height);
expect(Math.abs(boardWidth - boardHeight)).toBeLessThan(5);
}
});
test('navigation adapts to screen size', async () => {
const navigation = document.querySelector('.chess-navigation');
ResponsiveTestUtils.setViewportSize(320, 800);
let style = window.getComputedStyle(navigation);
expect(style.flexDirection).toBe('column');
ResponsiveTestUtils.setViewportSize(1024, 800);
style = window.getComputedStyle(navigation);
expect(style.flexDirection).toBe('row');
});
test('touch targets are adequately sized', () => {
ResponsiveTestUtils.setViewportSize(320, 800);
const squares = document.querySelectorAll('.chess-square');
squares.forEach(square => {
const rect = square.getBoundingClientRect();
expect(rect.width).toBeGreaterThanOrEqual(44);
expect(rect.height).toBeGreaterThanOrEqual(44);
});
});
});
Accessibility in Responsive Design
Screen Reader Adaptations
/* Hide visual elements on small screens, show for screen readers */
.sr-only-mobile {
@media (max-width: 767px) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
/* Show condensed labels on small screens */
.label-full {
@media (max-width: 767px) {
display: none;
}
}
.label-short {
display: none;
@media (max-width: 767px) {
display: inline;
}
}
Focus Management
// shared/utils/FocusManager.js
export class ResponsiveFocusManager {
constructor() {
this.focusStack = [];
this.setupResponsiveFocus();
}
setupResponsiveFocus() {
window.addEventListener('resize', () => {
this.handleResponsiveChanges();
});
}
handleResponsiveChanges() {
const isMobile = window.innerWidth < 768;
if (isMobile) {
// Focus main interactive element on mobile
this.focusMainElement();
} else {
// Restore previous focus on desktop
this.restoreFocus();
}
}
focusMainElement() {
const mainElement = document.querySelector('[data-main-focus]') ||
document.querySelector('.chess-board');
if (mainElement) {
mainElement.focus();
}
}
trapFocus(container) {
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
}
});
}
}
Next Steps
- Accessibility - Screen reader and keyboard support
- Performance - Mobile optimization
- PWA Features - Progressive web app capabilities
- Testing Guide - Responsive testing strategies