diff --git a/client/src/assets/accessibility.css b/client/src/assets/accessibility.css new file mode 100644 index 0000000..2a7bce6 --- /dev/null +++ b/client/src/assets/accessibility.css @@ -0,0 +1,453 @@ +/** + * accessibility.css + * Global accessibility styles for NaviDocs + * WCAG 2.1 AA compliant focus indicators, skip links, and reduced motion support + */ + +/* =========================== + FOCUS INDICATORS + =========================== */ + +/* High-visibility focus indicators for keyboard navigation */ +*:focus-visible { + outline: 3px solid rgba(236, 72, 153, 0.6); + outline-offset: 2px; + border-radius: 4px; +} + +/* Remove default outline for mouse users */ +*:focus:not(:focus-visible) { + outline: none; +} + +/* Enhanced focus for buttons and links */ +button:focus-visible, +a:focus-visible, +.btn:focus-visible { + outline: 3px solid rgba(236, 72, 153, 0.8); + outline-offset: 3px; + box-shadow: 0 0 0 4px rgba(236, 72, 153, 0.2); +} + +/* Input field focus states */ +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 3px solid rgba(236, 72, 153, 0.8); + outline-offset: 2px; + border-color: rgb(236, 72, 153) !important; + box-shadow: 0 0 0 4px rgba(236, 72, 153, 0.15); +} + +/* Search result card focus */ +.nv-card:focus-visible, +.result-item:focus-visible { + outline: 3px solid rgba(236, 72, 153, 0.8); + outline-offset: 2px; + transform: translateX(4px); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(236, 72, 153, 0.5); +} + +/* Navigation button focus */ +.nav-btn:focus-visible, +.go-btn:focus-visible { + outline: 3px solid rgba(236, 72, 153, 0.8); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(236, 72, 153, 0.2); +} + +/* =========================== + SKIP LINKS + =========================== */ + +/* Skip navigation links (hidden until focused) */ +.skip-links { + position: relative; + z-index: 10000; +} + +.skip-link { + position: absolute; + top: -100px; + left: 0; + background: rgb(236, 72, 153); + color: white; + padding: 12px 24px; + text-decoration: none; + font-weight: 600; + border-radius: 0 0 8px 0; + transition: top 0.2s ease; + z-index: 10001; +} + +.skip-link:focus { + top: 0; + outline: 3px solid white; + outline-offset: 2px; +} + +.skip-link:hover { + background: rgb(219, 39, 119); +} + +/* =========================== + SCREEN READER ONLY + =========================== */ + +/* Hide content visually but keep accessible to screen readers */ +.sr-only, +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* Allow element to be focusable when navigated to via keyboard */ +.sr-only-focusable:focus, +.visually-hidden-focusable:focus { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; +} + +/* =========================== + COLOR CONTRAST + =========================== */ + +/* Enhanced text colors for better contrast (WCAG AA compliant) */ +:root { + --text-primary: rgba(255, 255, 255, 0.95); + --text-secondary: rgba(255, 255, 255, 0.80); + --text-tertiary: rgba(255, 255, 255, 0.65); + --text-disabled: rgba(255, 255, 255, 0.40); + + /* Highlight colors */ + --highlight-bg: #FFE666; + --highlight-text: #1d1d1f; + + /* Focus colors */ + --focus-ring: rgba(236, 72, 153, 0.8); + --focus-shadow: rgba(236, 72, 153, 0.2); +} + +/* Apply contrast-safe colors */ +.text-primary { + color: var(--text-primary); +} + +.text-secondary { + color: var(--text-secondary); +} + +.text-tertiary { + color: var(--text-tertiary); +} + +.text-disabled { + color: var(--text-disabled); +} + +/* Ensure sufficient contrast for search highlights */ +mark, +.nv-hi, +.search-highlight { + background-color: var(--highlight-bg); + color: var(--highlight-text); + padding: 1px 3px; + border-radius: 3px; +} + +/* =========================== + HIGH CONTRAST MODE + =========================== */ + +/* Support for Windows High Contrast Mode */ +@media (prefers-contrast: high) { + *:focus-visible { + outline: 4px solid currentColor; + outline-offset: 3px; + } + + button:focus-visible, + a:focus-visible { + outline: 4px solid; + outline-offset: 4px; + } + + /* Ensure borders are visible */ + .nv-card, + .result-item, + input, + button { + border: 2px solid currentColor; + } + + /* High contrast highlights */ + mark, + .nv-hi, + .search-highlight { + background-color: Highlight; + color: HighlightText; + forced-color-adjust: none; + } +} + +/* =========================== + REDUCED MOTION + =========================== */ + +/* Respect user's motion preferences */ +@media (prefers-reduced-motion: reduce) { + /* Disable animations */ + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + /* Keep essential focus transitions for usability */ + *:focus-visible { + transition: outline 0.1s ease; + } + + /* Disable parallax effects */ + .parallax { + background-attachment: scroll !important; + } + + /* Disable hover transforms */ + .nv-card:hover, + .result-item:hover, + button:hover { + transform: none !important; + } + + /* Instant state changes */ + .dropdown-enter-active, + .dropdown-leave-active, + .fade-enter-active, + .fade-leave-active { + transition: opacity 0.05s linear, visibility 0.05s linear !important; + } + + /* Disable rotating spinners - show static icon instead */ + .spinner, + .animate-spin { + animation: none !important; + } +} + +/* =========================== + TOUCH TARGETS (MOBILE) + =========================== */ + +/* Ensure minimum 44×44px touch targets on mobile (WCAG AAA) */ +@media (max-width: 768px) { + button, + .btn, + .nav-btn, + .go-btn, + .close-btn, + a[href], + input[type="checkbox"], + input[type="radio"] { + min-width: 44px; + min-height: 44px; + padding: 12px; + } + + /* Search result cards */ + .nv-card, + .result-item { + min-height: 60px; + padding: 16px; + } + + /* Chip buttons */ + .nv-chip, + .nv-chip-text { + min-height: 44px; + padding: 8px 12px; + } + + /* Page input */ + .page-input { + min-width: 60px; + min-height: 44px; + font-size: 16px; /* Prevents iOS zoom on focus */ + } + + /* Compact nav spacing */ + .compact-nav { + gap: 12px; + padding: 12px; + } +} + +/* =========================== + KEYBOARD NAVIGATION + =========================== */ + +/* Show keyboard hints on focus */ +kbd { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.75rem; + font-weight: 500; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 2px 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +/* =========================== + FOCUS TRAPS + =========================== */ + +/* Ensure modals/dialogs trap focus properly */ +.modal:focus, +.dialog:focus { + outline: none; +} + +/* First/last focusable element in trap */ +.focus-trap-start:focus, +.focus-trap-end:focus { + outline: none; +} + +/* =========================== + ARIA LIVE REGIONS + =========================== */ + +/* Ensure live regions are properly announced */ +[aria-live="polite"], +[aria-live="assertive"] { + position: relative; +} + +/* Status messages */ +[role="status"], +[role="alert"] { + position: relative; +} + +/* =========================== + SEMANTIC EMPHASIS + =========================== */ + +/* Ensure semantic HTML is styled appropriately */ +strong, +b { + font-weight: 700; +} + +em, +i { + font-style: italic; +} + +/* =========================== + LANDMARKS + =========================== */ + +/* Clear landmark boundaries for screen readers */ +main[role="main"], +nav[role="navigation"], +aside[role="complementary"], +header[role="banner"], +footer[role="contentinfo"] { + position: relative; +} + +/* =========================== + RESPONSIVE TYPOGRAPHY + =========================== */ + +/* Ensure text can be resized up to 200% without breaking layout */ +@media (min-width: 1024px) { + html { + font-size: 16px; + } +} + +@media (min-width: 1280px) { + html { + font-size: 18px; + } +} + +/* Allow text zoom up to 200% */ +body { + font-size: 1rem; + line-height: 1.5; +} + +/* =========================== + FOCUS MANAGEMENT + =========================== */ + +/* Prevent focus outline on click, show on keyboard */ +.js-focus-visible :focus:not(.focus-visible) { + outline: none; +} + +/* =========================== + PRINT STYLES (BONUS) + =========================== */ + +@media print { + /* Remove skip links in print */ + .skip-links { + display: none; + } + + /* Ensure focus indicators don't print */ + *:focus-visible { + outline: none; + } + + /* Preserve link URLs */ + a[href]:after { + content: " (" attr(href) ")"; + } + + /* Preserve document structure */ + h1, h2, h3, h4, h5, h6 { + page-break-after: avoid; + } +} + +/* =========================== + DARK MODE SUPPORT + =========================== */ + +@media (prefers-color-scheme: dark) { + /* Already dark by default, but ensure sufficient contrast */ + :root { + --text-primary: rgba(255, 255, 255, 0.95); + --text-secondary: rgba(255, 255, 255, 0.80); + } +} + +@media (prefers-color-scheme: light) { + /* If implementing light mode in future */ + :root { + --text-primary: rgba(0, 0, 0, 0.95); + --text-secondary: rgba(0, 0, 0, 0.80); + --focus-ring: rgba(236, 72, 153, 1); + } +} diff --git a/client/src/components/SkipLinks.vue b/client/src/components/SkipLinks.vue new file mode 100644 index 0000000..4cb21a4 --- /dev/null +++ b/client/src/components/SkipLinks.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/client/src/composables/useKeyboardShortcuts.js b/client/src/composables/useKeyboardShortcuts.js new file mode 100644 index 0000000..3e1163d --- /dev/null +++ b/client/src/composables/useKeyboardShortcuts.js @@ -0,0 +1,133 @@ +/** + * useKeyboardShortcuts.js + * Global keyboard shortcut manager for NaviDocs + * Provides accessible keyboard navigation across the application + */ + +import { onMounted, onUnmounted } from 'vue' + +/** + * @param {Object} handlers - Object containing handler functions + * @param {Function} handlers.focusSearch - Focus the search input (Ctrl/Cmd+F) + * @param {Function} handlers.nextResult - Navigate to next search result (Enter) + * @param {Function} handlers.prevResult - Navigate to previous result (Shift+Enter) + * @param {Function} handlers.closeSearch - Close/clear search (Escape) + * @param {Function} handlers.nextPage - Navigate to next page (ArrowRight/PageDown) + * @param {Function} handlers.prevPage - Navigate to previous page (ArrowLeft/PageUp) + * @param {Function} handlers.firstPage - Jump to first page (Home) + * @param {Function} handlers.lastPage - Jump to last page (End) + */ +export function useKeyboardShortcuts(handlers = {}) { + const handleKeyDown = (event) => { + const { key, ctrlKey, metaKey, shiftKey, altKey } = event + const modifier = ctrlKey || metaKey + const activeElement = document.activeElement + const tagName = activeElement?.tagName + const isTyping = tagName === 'INPUT' || tagName === 'TEXTAREA' + const isSearchBox = activeElement?.getAttribute('role') === 'searchbox' + + // Search focus: Ctrl/Cmd + F + if (modifier && key === 'f' && handlers.focusSearch) { + event.preventDefault() + handlers.focusSearch() + return + } + + // Next result: Enter (when search active, not in input) + if (key === 'Enter' && !shiftKey && handlers.nextResult) { + if (!isSearchBox && !isTyping) { + event.preventDefault() + handlers.nextResult() + } + return + } + + // Previous result: Shift + Enter + if (key === 'Enter' && shiftKey && handlers.prevResult) { + event.preventDefault() + handlers.prevResult() + return + } + + // Close search: Escape + if (key === 'Escape' && handlers.closeSearch) { + event.preventDefault() + handlers.closeSearch() + return + } + + // Don't interfere with typing in inputs + if (isTyping && !isSearchBox) { + return + } + + // Next page: ArrowRight or PageDown + if ((key === 'ArrowRight' || key === 'PageDown') && handlers.nextPage) { + if (!isTyping) { + event.preventDefault() + handlers.nextPage() + } + return + } + + // Previous page: ArrowLeft or PageUp + if ((key === 'ArrowLeft' || key === 'PageUp') && handlers.prevPage) { + if (!isTyping) { + event.preventDefault() + handlers.prevPage() + } + return + } + + // Home: Go to first page + if (key === 'Home' && handlers.firstPage) { + if (!isTyping) { + event.preventDefault() + handlers.firstPage() + } + return + } + + // End: Go to last page + if (key === 'End' && handlers.lastPage) { + if (!isTyping) { + event.preventDefault() + handlers.lastPage() + } + return + } + } + + onMounted(() => { + document.addEventListener('keydown', handleKeyDown) + }) + + onUnmounted(() => { + document.removeEventListener('keydown', handleKeyDown) + }) + + return { handleKeyDown } +} + +/** + * Get human-readable description of keyboard shortcut + * @param {string} key - Shortcut key + * @returns {string} Formatted shortcut description + */ +export function getShortcutDescription(key) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + const modKey = isMac ? '⌘' : 'Ctrl' + + const shortcuts = { + focusSearch: `${modKey}+F`, + nextResult: 'Enter', + prevResult: 'Shift+Enter', + closeSearch: 'Esc', + nextPage: 'ArrowRight / PageDown', + prevPage: 'ArrowLeft / PageUp', + firstPage: 'Home', + lastPage: 'End' + } + + return shortcuts[key] || '' +}