learning_ai_common_plat/packages/accessibility/src/client.ts
saravanakumardb1 be03efa111 feat(shared-packages): add 9 @bytelyst/* client packages with 100% API coverage
Packages added:
- @bytelyst/referral-client — referral API client + share helpers
- @bytelyst/subscription-client — subscription/plan API client + cache
- @bytelyst/celebrations — milestone triggers, confetti, positive messages
- @bytelyst/gentle-notifications — ND-friendly messaging, forbidden phrases
- @bytelyst/accessibility — VoiceOver/TalkBack label generators
- @bytelyst/quick-actions — progressive disclosure, smart defaults
- @bytelyst/time-references — familiar duration references
- @bytelyst/org-client — org/workspace/membership/license API client
- @bytelyst/marketplace-client — listing/review/install API client

All packages: pure TS, ESM, globalThis.fetch, no Node.js deps.
99 Vitest tests across 9 packages, 79/79 public methods covered.

Review fixes applied:
- time-references: fix module-level mutable state leak + add clearCustomReferences()
- accessibility: fix parameter reassignment in formatDurationForA11y/numberToWords
- subscription-client: fix flaky daysRemaining test (ms boundary race)
2026-03-19 13:10:09 -07:00

173 lines
5.2 KiB
TypeScript

/**
* 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];
}