feat(a11y): accessibility audit, high contrast, i18n foundation, skip nav (M17.1-M17.8)
- Keyboard navigation: useFocusTrap() for modals, useRovingTabindex() for grids - ARIA audit: composables for focus trap and roving tabindex patterns - High contrast mode: @media (prefers-contrast: more) + .high-contrast class - i18n foundation: en/es/fr locale files, useI18n() composable with auto-detect - RTL layout support: dir attribute toggling based on locale - Dyslexia-friendly font: .font-dyslexia CSS class with OpenDyslexic support - Skip navigation link: .skip-nav CSS with focus-visible positioning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
85936d3005
commit
725f629f69
@@ -0,0 +1,53 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
|
||||||
|
function getFocusableElements(): HTMLElement[] {
|
||||||
|
const container = containerRef.value
|
||||||
|
if (!container) return []
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// Bubble up for close handlers
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
const focusable = getFocusableElements()
|
||||||
|
if (focusable.length === 0) return
|
||||||
|
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
// Auto-focus first element
|
||||||
|
const focusable = getFocusableElements()
|
||||||
|
if (focusable.length > 0) {
|
||||||
|
focusable[0].focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export function useRovingTabindex(containerRef: Ref<HTMLElement | null>, selector = '[role="listitem"], [data-roving]') {
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
|
||||||
|
function getItems(): HTMLElement[] {
|
||||||
|
const container = containerRef.value
|
||||||
|
if (!container) return []
|
||||||
|
return Array.from(container.querySelectorAll<HTMLElement>(selector))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTabindex() {
|
||||||
|
const items = getItems()
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
item.setAttribute('tabindex', i === currentIndex.value ? '0' : '-1')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
const items = getItems()
|
||||||
|
if (items.length === 0) return
|
||||||
|
|
||||||
|
let handled = false
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowRight':
|
||||||
|
currentIndex.value = (currentIndex.value + 1) % items.length
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
currentIndex.value = (currentIndex.value - 1 + items.length) % items.length
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
currentIndex.value = 0
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
currentIndex.value = items.length - 1
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
e.preventDefault()
|
||||||
|
updateTabindex()
|
||||||
|
items[currentIndex.value]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const container = containerRef.value
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('keydown', onKeyDown)
|
||||||
|
updateTabindex()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
const container = containerRef.value
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { currentIndex, updateTabindex }
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "AIUI",
|
||||||
|
"tagline": "AI chat with rich content surfaces"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"newChat": "New Chat",
|
||||||
|
"sendMessage": "Send message",
|
||||||
|
"typeMessage": "Type a message...",
|
||||||
|
"stopGenerating": "Stop generating",
|
||||||
|
"regenerate": "Regenerate",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"copy": "Copy",
|
||||||
|
"reply": "Reply",
|
||||||
|
"branch": "Branch from here",
|
||||||
|
"search": "Search messages...",
|
||||||
|
"noMessages": "No messages yet",
|
||||||
|
"thinking": "Thinking..."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"films": "Films",
|
||||||
|
"music": "Music",
|
||||||
|
"books": "Books",
|
||||||
|
"tv": "TV",
|
||||||
|
"images": "Images",
|
||||||
|
"places": "Places",
|
||||||
|
"podcasts": "Podcasts",
|
||||||
|
"news": "News",
|
||||||
|
"web": "Web",
|
||||||
|
"brief": "Brief",
|
||||||
|
"code": "Code",
|
||||||
|
"design": "Design",
|
||||||
|
"nostr": "Nostr",
|
||||||
|
"favorites": "Favorites",
|
||||||
|
"discover": "Discover"
|
||||||
|
},
|
||||||
|
"discover": {
|
||||||
|
"forYou": "For You",
|
||||||
|
"recent": "Recent",
|
||||||
|
"trending": "Trending",
|
||||||
|
"collections": "Collections",
|
||||||
|
"tags": "Tags",
|
||||||
|
"playlists": "Playlists",
|
||||||
|
"noFavorites": "Add favorites to get personalized suggestions",
|
||||||
|
"noRecent": "No recently viewed items",
|
||||||
|
"noTrending": "No trending items yet",
|
||||||
|
"noCollections": "No collections yet",
|
||||||
|
"noTags": "No tags yet"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"content": "Content",
|
||||||
|
"shortcuts": "Shortcuts",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"storage": "Storage",
|
||||||
|
"chat": "Chat",
|
||||||
|
"accentColor": "Accent Colour",
|
||||||
|
"glassIntensity": "Glass Intensity",
|
||||||
|
"fontSize": "Font Size",
|
||||||
|
"exportData": "Export All Data",
|
||||||
|
"wipeData": "Wipe All Data",
|
||||||
|
"wipeConfirm": "Are you sure?",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"feed": "Feed",
|
||||||
|
"messages": "Messages",
|
||||||
|
"relays": "Relays",
|
||||||
|
"profile": "Profile",
|
||||||
|
"lists": "Lists",
|
||||||
|
"articles": "Articles",
|
||||||
|
"publish": "Publish",
|
||||||
|
"signIn": "Sign in with Nostr"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"search": "Search",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "AIUI",
|
||||||
|
"tagline": "Chat IA con superficies de contenido enriquecidas"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"newChat": "Nuevo Chat",
|
||||||
|
"sendMessage": "Enviar mensaje",
|
||||||
|
"typeMessage": "Escribe un mensaje...",
|
||||||
|
"stopGenerating": "Detener generación",
|
||||||
|
"regenerate": "Regenerar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"reply": "Responder",
|
||||||
|
"branch": "Bifurcar desde aquí",
|
||||||
|
"search": "Buscar mensajes...",
|
||||||
|
"noMessages": "Sin mensajes aún",
|
||||||
|
"thinking": "Pensando..."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"films": "Películas",
|
||||||
|
"music": "Música",
|
||||||
|
"books": "Libros",
|
||||||
|
"tv": "TV",
|
||||||
|
"images": "Imágenes",
|
||||||
|
"places": "Lugares",
|
||||||
|
"podcasts": "Podcasts",
|
||||||
|
"news": "Noticias",
|
||||||
|
"web": "Web",
|
||||||
|
"brief": "Resumen",
|
||||||
|
"code": "Código",
|
||||||
|
"design": "Diseño",
|
||||||
|
"nostr": "Nostr",
|
||||||
|
"favorites": "Favoritos",
|
||||||
|
"discover": "Descubrir"
|
||||||
|
},
|
||||||
|
"discover": {
|
||||||
|
"forYou": "Para Ti",
|
||||||
|
"recent": "Recientes",
|
||||||
|
"trending": "Tendencias",
|
||||||
|
"collections": "Colecciones",
|
||||||
|
"tags": "Etiquetas",
|
||||||
|
"playlists": "Listas",
|
||||||
|
"noFavorites": "Añade favoritos para sugerencias personalizadas",
|
||||||
|
"noRecent": "Sin elementos vistos recientemente",
|
||||||
|
"noTrending": "Sin tendencias aún",
|
||||||
|
"noCollections": "Sin colecciones aún",
|
||||||
|
"noTags": "Sin etiquetas aún"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Ajustes",
|
||||||
|
"appearance": "Apariencia",
|
||||||
|
"content": "Contenido",
|
||||||
|
"shortcuts": "Atajos",
|
||||||
|
"notifications": "Notificaciones",
|
||||||
|
"storage": "Almacenamiento",
|
||||||
|
"chat": "Chat",
|
||||||
|
"accentColor": "Color de acento",
|
||||||
|
"glassIntensity": "Intensidad del cristal",
|
||||||
|
"fontSize": "Tamaño de fuente",
|
||||||
|
"exportData": "Exportar todos los datos",
|
||||||
|
"wipeData": "Borrar todos los datos",
|
||||||
|
"wipeConfirm": "¿Estás seguro?",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"feed": "Feed",
|
||||||
|
"messages": "Mensajes",
|
||||||
|
"relays": "Relés",
|
||||||
|
"profile": "Perfil",
|
||||||
|
"lists": "Listas",
|
||||||
|
"articles": "Artículos",
|
||||||
|
"publish": "Publicar",
|
||||||
|
"signIn": "Iniciar sesión con Nostr"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"error": "Error",
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"search": "Buscar",
|
||||||
|
"back": "Atrás",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"previous": "Anterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "AIUI",
|
||||||
|
"tagline": "Chat IA avec des surfaces de contenu enrichies"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"newChat": "Nouveau Chat",
|
||||||
|
"sendMessage": "Envoyer le message",
|
||||||
|
"typeMessage": "Tapez un message...",
|
||||||
|
"stopGenerating": "Arrêter la génération",
|
||||||
|
"regenerate": "Régénérer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"reply": "Répondre",
|
||||||
|
"branch": "Bifurquer à partir d'ici",
|
||||||
|
"search": "Rechercher des messages...",
|
||||||
|
"noMessages": "Pas encore de messages",
|
||||||
|
"thinking": "Réflexion..."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"films": "Films",
|
||||||
|
"music": "Musique",
|
||||||
|
"books": "Livres",
|
||||||
|
"tv": "TV",
|
||||||
|
"images": "Images",
|
||||||
|
"places": "Lieux",
|
||||||
|
"podcasts": "Podcasts",
|
||||||
|
"news": "Actualités",
|
||||||
|
"web": "Web",
|
||||||
|
"brief": "Résumé",
|
||||||
|
"code": "Code",
|
||||||
|
"design": "Design",
|
||||||
|
"nostr": "Nostr",
|
||||||
|
"favorites": "Favoris",
|
||||||
|
"discover": "Découvrir"
|
||||||
|
},
|
||||||
|
"discover": {
|
||||||
|
"forYou": "Pour Vous",
|
||||||
|
"recent": "Récents",
|
||||||
|
"trending": "Tendances",
|
||||||
|
"collections": "Collections",
|
||||||
|
"tags": "Étiquettes",
|
||||||
|
"playlists": "Playlists",
|
||||||
|
"noFavorites": "Ajoutez des favoris pour des suggestions personnalisées",
|
||||||
|
"noRecent": "Aucun élément consulté récemment",
|
||||||
|
"noTrending": "Pas encore de tendances",
|
||||||
|
"noCollections": "Pas encore de collections",
|
||||||
|
"noTags": "Pas encore d'étiquettes"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"appearance": "Apparence",
|
||||||
|
"content": "Contenu",
|
||||||
|
"shortcuts": "Raccourcis",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"storage": "Stockage",
|
||||||
|
"chat": "Chat",
|
||||||
|
"accentColor": "Couleur d'accent",
|
||||||
|
"glassIntensity": "Intensité du verre",
|
||||||
|
"fontSize": "Taille de police",
|
||||||
|
"exportData": "Exporter toutes les données",
|
||||||
|
"wipeData": "Effacer toutes les données",
|
||||||
|
"wipeConfirm": "Êtes-vous sûr ?",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"feed": "Fil",
|
||||||
|
"messages": "Messages",
|
||||||
|
"relays": "Relais",
|
||||||
|
"profile": "Profil",
|
||||||
|
"lists": "Listes",
|
||||||
|
"articles": "Articles",
|
||||||
|
"publish": "Publier",
|
||||||
|
"signIn": "Se connecter avec Nostr"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"error": "Erreur",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"close": "Fermer",
|
||||||
|
"search": "Rechercher",
|
||||||
|
"back": "Retour",
|
||||||
|
"next": "Suivant",
|
||||||
|
"previous": "Précédent"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import en from './en.json'
|
||||||
|
import es from './es.json'
|
||||||
|
import fr from './fr.json'
|
||||||
|
|
||||||
|
type Messages = typeof en
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'aiui-locale'
|
||||||
|
|
||||||
|
const locales: Record<string, Messages> = { en, es, fr }
|
||||||
|
|
||||||
|
const currentLocale = ref(loadLocale())
|
||||||
|
|
||||||
|
function loadLocale(): string {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved && locales[saved]) return saved
|
||||||
|
|
||||||
|
// Auto-detect from browser
|
||||||
|
const browserLang = navigator.language.split('-')[0]
|
||||||
|
if (locales[browserLang]) return browserLang
|
||||||
|
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const locale = computed({
|
||||||
|
get: () => currentLocale.value,
|
||||||
|
set: (v: string) => {
|
||||||
|
currentLocale.value = v
|
||||||
|
localStorage.setItem(STORAGE_KEY, v)
|
||||||
|
// Set dir attribute for RTL
|
||||||
|
document.documentElement.dir = isRtl(v) ? 'rtl' : 'ltr'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = computed(() => locales[currentLocale.value] ?? en)
|
||||||
|
|
||||||
|
function t(path: string): string {
|
||||||
|
const keys = path.split('.')
|
||||||
|
let result: unknown = messages.value
|
||||||
|
for (const key of keys) {
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
result = (result as Record<string, unknown>)[key]
|
||||||
|
} else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof result === 'string' ? result : path
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableLocales = Object.keys(locales)
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages,
|
||||||
|
t,
|
||||||
|
availableLocales,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtl(locale: string): boolean {
|
||||||
|
return ['ar', 'he', 'fa', 'ur'].includes(locale)
|
||||||
|
}
|
||||||
@@ -842,6 +842,60 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== HIGH CONTRAST MODE ===== */
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
.glass,
|
||||||
|
.glass-strong,
|
||||||
|
.glass-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-white\/90,
|
||||||
|
.text-white\/80,
|
||||||
|
.text-white\/70 {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.high-contrast .glass,
|
||||||
|
.high-contrast .glass-strong,
|
||||||
|
.high-contrast .glass-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SKIP NAVIGATION ===== */
|
||||||
|
|
||||||
|
.skip-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #F7931A;
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-nav:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== DYSLEXIA FONT ===== */
|
||||||
|
|
||||||
|
.font-dyslexia {
|
||||||
|
font-family: 'OpenDyslexic', sans-serif;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== REDUCED MOTION ===== */
|
/* ===== REDUCED MOTION ===== */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
Reference in New Issue
Block a user