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
Binary file added Microsoft/Windows/PowerShell/ModuleAnalysisCache
Binary file not shown.
7 changes: 7 additions & 0 deletions docs/MOBILE_NAVIGATION_RESPONSIVE_DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Mobile Navigation Responsive Design

`MobileNavigation` renders as a bottom tab bar on compact and portrait screens. It switches to a left-side rail only when the viewport is at least `640px` wide and in landscape orientation.

This keeps the navigation reachable on phones while avoiding an unexpected side rail on narrow portrait layouts. The component also preserves safe-area padding with `env(safe-area-inset-*)`, remains hidden at the `lg` breakpoint, and uses WAI-ARIA tab semantics with roving keyboard focus.

Responsive behavior is covered by `src/components/mobile/__tests__/MobileNavigation.test.tsx`.
129 changes: 115 additions & 14 deletions src/components/mobile/MobileNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
import React, { useState } from 'react';
'use client';

import React, { useState, useEffect, useRef } from 'react';
import { Home, Search, BookOpen, User } from 'lucide-react';

interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
onClick?: () => void;
}

const railNavClasses = [
'[@media_(min-width:640px)_and_(orientation:landscape)]:top-0',
'[@media_(min-width:640px)_and_(orientation:landscape)]:bottom-0',
'[@media_(min-width:640px)_and_(orientation:landscape)]:left-0',
'[@media_(min-width:640px)_and_(orientation:landscape)]:right-auto',
'[@media_(min-width:640px)_and_(orientation:landscape)]:h-dvh',
'[@media_(min-width:640px)_and_(orientation:landscape)]:w-20',
'[@media_(min-width:640px)_and_(orientation:landscape)]:border-t-0',
'[@media_(min-width:640px)_and_(orientation:landscape)]:border-r',
].join(' ');
const railListClasses = [
'[@media_(min-width:640px)_and_(orientation:landscape)]:h-full',
'[@media_(min-width:640px)_and_(orientation:landscape)]:flex-col',
'[@media_(min-width:640px)_and_(orientation:landscape)]:justify-start',
'[@media_(min-width:640px)_and_(orientation:landscape)]:space-y-6',
'[@media_(min-width:640px)_and_(orientation:landscape)]:px-0',
'[@media_(min-width:640px)_and_(orientation:landscape)]:pt-8',
].join(' ');
const railListItemClasses = [
'[@media_(min-width:640px)_and_(orientation:landscape)]:flex-none',
'[@media_(min-width:640px)_and_(orientation:landscape)]:px-2',
].join(' ');
const railButtonClasses = [
'[@media_(min-width:640px)_and_(orientation:landscape)]:mx-auto',
'[@media_(min-width:640px)_and_(orientation:landscape)]:h-14',
'[@media_(min-width:640px)_and_(orientation:landscape)]:w-14',
'[@media_(min-width:640px)_and_(orientation:landscape)]:max-w-none',
'[@media_(min-width:640px)_and_(orientation:landscape)]:py-0',
].join(' ');
const railHiddenClass = '[@media_(min-width:640px)_and_(orientation:landscape)]:hidden';
const railBlockClass = '[@media_(min-width:640px)_and_(orientation:landscape)]:block';

export const MobileNavigation: React.FC<{
initialActive?: string;
onNavChange?: (id: string) => void;
}> = ({ initialActive = 'home', onNavChange }) => {
const [activeTab, setActiveTab] = useState(initialActive);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);

const navItems: NavItem[] = [
{ id: 'home', label: 'Home', icon: <Home size={24} /> },
Expand All @@ -21,40 +55,107 @@ export const MobileNavigation: React.FC<{
{ id: 'profile', label: 'Profile', icon: <User size={24} /> },
];

// Sync state with prop
useEffect(() => {
setActiveTab(initialActive);
}, [initialActive]);

const handleTabClick = (id: string) => {
setActiveTab(id);
if (onNavChange) onNavChange(id);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
let nextIndex = -1;
const length = navItems.length;

if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
nextIndex = (index + 1) % length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
nextIndex = (index - 1 + length) % length;
} else if (e.key === 'Home') {
nextIndex = 0;
} else if (e.key === 'End') {
nextIndex = length - 1;
}

if (nextIndex !== -1) {
e.preventDefault();
buttonRefs.current[nextIndex]?.focus();
}
};

return (
<nav
className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 z-50 md:hidden"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
className={`fixed z-50 bg-white/95 dark:bg-gray-950/95 backdrop-blur-md border-gray-200 dark:border-gray-800 transition-all duration-300 lg:hidden
bottom-0 left-0 right-0 border-t min-h-16 w-full
${railNavClasses}`}
style={{
paddingBottom: 'env(safe-area-inset-bottom)',
paddingLeft: 'env(safe-area-inset-left)',
paddingRight: 'env(safe-area-inset-right)',
}}
role="navigation"
aria-label="Mobile Navigation"
>
<ul className="flex justify-around items-center h-16 px-2">
{navItems.map((item) => {
<ul
className={`flex min-h-16 w-full items-center justify-around px-2
${railListClasses}`}
role="tablist"
aria-label="Navigation Tabs"
>
{navItems.map((item, index) => {
const isActive = activeTab === item.id;
return (
<li key={item.id} className="flex-1">
<li
key={item.id}
className={`flex w-full flex-1 justify-center ${railListItemClasses}`}
role="presentation"
>
<button
ref={(el) => {
buttonRefs.current[index] = el;
}}
id={`tab-${item.id}`}
role="tab"
aria-selected={isActive}
aria-controls={`panel-${item.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => handleTabClick(item.id)}
className={`w-full flex flex-col items-center justify-center py-2 space-y-1 transition-colors duration-200
onKeyDown={(e) => handleKeyDown(e, index)}
className={`relative flex w-full max-w-[72px] flex-col items-center justify-center rounded-xl py-1.5 transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 dark:focus-visible:ring-blue-400 dark:focus-visible:ring-offset-gray-950 ${railButtonClasses}
${
isActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
? 'text-blue-600 dark:text-blue-400 bg-blue-50/60 dark:bg-blue-900/20 font-semibold'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50/80 dark:hover:bg-gray-900/50'
}
`}
aria-label={item.label}
>
<div
className={`${
isActive ? 'scale-110' : 'scale-100'
} transition-transform duration-200`}
className={`transition-transform duration-300 ${
isActive ? 'scale-110' : 'scale-100 hover:scale-105'
}`}
>
{item.icon}
</div>
<span className="text-[10px] font-medium leading-none">{item.label}</span>
<span className={`mt-0.5 text-[10px] font-medium ${railHiddenClass}`}>
{item.label}
</span>

{/* Active Indicator Dot (Portrait) */}
<div
className={`absolute bottom-1 h-1 w-1 rounded-full bg-blue-600 transition-all duration-300 dark:bg-blue-400 ${railHiddenClass} ${
isActive ? 'scale-100 opacity-100' : 'scale-0 opacity-0'
}`}
/>

{/* Active Indicator Bar (Landscape/Tablet) */}
<div
className={`absolute left-0 top-1/2 hidden h-8 w-1 -translate-y-1/2 rounded-r-full bg-blue-600 transition-all duration-300 dark:bg-blue-400 ${railBlockClass} ${
isActive ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0'
}`}
/>
</button>
</li>
);
Expand Down
86 changes: 86 additions & 0 deletions src/components/mobile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Mobile Components & Responsive Navigation

This folder contains mobile-optimized UI elements, touch gesture handlers, adaptive layouts, and responsive navigation controls designed for the TeachLink platform.

---

## 📱 MobileNavigation

A fully responsive, highly accessible, and visually premium navigation component that adapts dynamically to different viewport aspect ratios, orientations, and touch constraints.

### 🌟 Key Features

1. **Orientation & Viewport Adaptability**:
- **Portrait Mobile (`< 640px`)**: Translucent bottom navigation bar with a glassmorphic background blur, optimized for single-handed thumb interaction.
- **Landscape Mobile & Compact Tablets (`640px <= width < 1024px`)**: Vertical left navigation rail, preserving precious vertical height in landscape orientation and displaying compact icon buttons.
- **Desktop View (`>= 1024px`)**: Automatically hidden (`lg:hidden`) to avoid visual redundancies with the main desktop Header.

2. **Safe-Area Insets**:
- Implements robust safe-area bounds on notch, dynamic island, and rounded-corner devices held in either portrait (`env(safe-area-inset-bottom)`) or landscape (`env(safe-area-inset-left)` / `env(safe-area-inset-right)`) orientation.

3. **WAI-ARIA & WCAG 2.1 AA Accessibility**:
- **Keyboard Navigation**: Implements robust keyboard interaction using arrow keys (`ArrowRight`/`ArrowDown` for forward, `ArrowLeft`/`ArrowUp` for backward, `Home` for first, `End` for last item).
- **Interactive Sequences**: Only the active tab is in the focus stream (`tabIndex={0}`), while inactive ones are excluded (`tabIndex={-1}`), preventing keyboard clutter.
- **Semantic Roles**: Proper container (`role="tablist"`), tab controls (`role="tab"`), active states (`aria-selected`), and descriptive labels (`aria-label`).
- **Accessible Focus**: High-contrast outline states via Tailwind focus-ring overrides.

4. **Rich Visual Aesthetics**:
- Animated visual indicators: an active indicator dot on bottom portrait nav, and a sleek vertical bar on the left sidebar rail.
- Smooth transform scale-up animations on active icons.

---

### 🛠️ API & Component Interface

```tsx
import { MobileNavigation } from '@/components/mobile/MobileNavigation';

function Layout() {
const handleNavChange = (activeTabId: string) => {
console.log(`Switched to tab: ${activeTabId}`);
};

return (
<MobileNavigation
initialActive="home"
onNavChange={handleNavChange}
/>
);
}
```

---

### 🧪 Testing

Automated unit and interaction tests are located at `__tests__/MobileNavigation.test.tsx` and can be run via:

```bash
pnpm test src/components/mobile/__tests__/MobileNavigation.test.tsx
```

The test suite covers:
- Successful rendering of all semantic navigation items.
- Correct active tab and initial state styling.
- Callback triggers (`onNavChange`) when clicking tabs.
- Arrow key keyboard navigation sequence matches.
- Correct CSS class responsive transitions (portrait vs landscape/tablet widths).
- Safe area boundaries styles check.

---

## 🧱 Other Mobile Components

### 📐 AdaptiveLayouts
Provides the `AdaptiveLayout` component which dynamically toggles rendering between a `mobileView` and `desktopView` based on window width and mobile device detection. It includes a debounced performance handler for resize events.

### 👆 GestureHandler
A lightweight, performant drag and swipe detector translating native touch inputs into callbacks (`onSwipeLeft`, `onSwipeRight`, `onSwipeUp`, `onSwipeDown`).

### 📦 MobileOptimizedComponents
- **TouchButton**: Enhanced tap button with large hit area (min 48px height) and touch cancel event detection.
- **SwipeableCard**: Gesture-ready container utilizing the touch handler.
- **BottomSheet**: A premium sliding modal drawer entering from the bottom of the viewport, supporting drag-to-dismiss swipes.

### 🎨 TouchOptimizedUI
Provides pre-styled, touch-friendly `TouchButton` and `TouchCard` elements ensuring clean active feedback scaling states and meeting WCAG target sizes (min 44x44px).
Loading
Loading