/** * VoiceOver/TalkBack accessibility label generators for common UI patterns. * * Returns A11yProps objects compatible with React Native accessibilityLabel/Role. * Web apps can map to aria-label / role. * Pure client-side TS — no backend dependency. */ import type { A11yProps } from './types.js'; export function buttonLabel(label: string, hint?: string): A11yProps { return { accessible: true, accessibilityLabel: label, accessibilityHint: hint, accessibilityRole: 'button', }; } export function timerLabel(status: string, elapsedText: string, context?: string): A11yProps { const parts = [`Timer ${status}`, elapsedText]; if (context) parts.push(context); return { accessible: true, accessibilityLabel: parts.join(', '), accessibilityRole: 'timer', }; } export function progressLabel(name: string, percent: number, description?: string): A11yProps { const clamped = Math.max(0, Math.min(100, Math.round(percent))); const label = description ? `${name}: ${clamped} percent complete. ${description}` : `${name}: ${clamped} percent complete`; return { accessible: true, accessibilityLabel: label, accessibilityRole: 'progressbar', accessibilityValue: { min: 0, max: 100, now: clamped, text: `${clamped}%` }, }; } export function sliderLabel(metric: string, value: number, max?: number): A11yProps { const label = max !== undefined ? `${metric}: ${value} of ${max}` : `${metric}: ${value}`; return { accessible: true, accessibilityLabel: label, accessibilityRole: 'adjustable', accessibilityValue: { now: value, max, text: String(value) }, }; } export function alertLabel(severity: string, message: string): A11yProps { return { accessible: true, accessibilityLabel: `${severity} alert: ${message}`, accessibilityRole: 'alert', }; } export function achievementLabel(name: string, description: string, earned: boolean): A11yProps { const status = earned ? 'Earned' : 'Locked'; return { accessible: true, accessibilityLabel: `${status} achievement: ${name}. ${description}`, accessibilityRole: 'image', accessibilityState: { selected: earned }, }; } export function streakLabel(current: number, longest: number): A11yProps { return { accessible: true, accessibilityLabel: `Current streak: ${current} days. Longest streak: ${longest} days`, accessibilityRole: 'text', }; } export function listItemLabel(title: string, subtitle?: string, badge?: string): A11yProps { const parts = [title]; if (subtitle) parts.push(subtitle); if (badge) parts.push(badge); return { accessible: true, accessibilityLabel: parts.join(', '), }; } export function formatDurationForA11y(ms: number): string { const safeMs = Math.max(0, ms); const totalSeconds = Math.floor(safeMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; const parts: string[] = []; if (hours > 0) parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`); if (minutes > 0) parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`); if (seconds > 0 || parts.length === 0) parts.push(`${seconds} ${seconds === 1 ? 'second' : 'seconds'}`); return parts.join(' '); } const ONES = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; const TEENS = [ 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', ]; const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; function numberToWords(n: number): string { if (n < 0) return `negative ${numberToWords(-n)}`; if (n === 0) return 'zero'; const parts: string[] = []; let remaining = n; if (remaining >= 1_000_000) { parts.push(`${numberToWords(Math.floor(remaining / 1_000_000))} million`); remaining %= 1_000_000; } if (remaining >= 1000) { parts.push(`${numberToWords(Math.floor(remaining / 1000))} thousand`); remaining %= 1000; } if (remaining >= 100) { parts.push(`${ONES[Math.floor(remaining / 100)]} hundred`); remaining %= 100; } if (remaining >= 20) { const t = TENS[Math.floor(remaining / 10)]; const o = ONES[remaining % 10]; parts.push(o ? `${t} ${o}` : t); } else if (remaining >= 10) { parts.push(TEENS[remaining - 10]); } else if (remaining > 0) { parts.push(ONES[remaining]); } return parts.join(' '); } export function formatNumberForA11y(n: number): string { return numberToWords(Math.round(n)); } export function buildAnnouncement(headline: string, detail: string): string { return `${headline}. ${detail}`; } const POSITIVE_BREAK_MESSAGES = [ 'Every bit of progress counts!', 'You showed up today — that matters!', 'Rest is part of the journey. You are doing great!', 'You can always come back — no pressure!', 'Progress, not perfection. Well done!', ]; export function getPositiveBreakMessage(progressPercent: number): string { const clamped = Math.max(0, Math.min(100, progressPercent)); const index = Math.floor((clamped / 100) * (POSITIVE_BREAK_MESSAGES.length - 1)); return POSITIVE_BREAK_MESSAGES[index]; }