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 ===== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
Reference in New Issue
Block a user