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