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)
173 lines
5.2 KiB
TypeScript
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];
|
|
}
|