Skip to content

Commit 03a1d6f

Browse files
Copilothotlong
andcommitted
feat: implement Q1 2026 roadmap - accessibility, responsive, i18n deep integration
- Add AriaPropsSchema injection in SchemaRenderer (ariaLabel, ariaDescribedBy, role) - Add WcagContrastLevel checking utility (contrastRatio, meetsContrastLevel) - Add ResponsiveGrid layout component with BreakpointColumnMapSchema support - Add useResponsiveConfig hook consuming ResponsiveConfigSchema - Add spec-aligned i18n formatters (PluralRuleSchema, DateFormatSchema, NumberFormatSchema) - Add LocaleConfigSchema consumer (applyLocaleConfig) - Add dynamic language pack loading to I18nProvider Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 682d6a2 commit 03a1d6f

11 files changed

Lines changed: 594 additions & 2 deletions

File tree

packages/core/src/theme/ThemeEngine.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,81 @@ export function resolveMode(
450450

451451
return 'light'; // fallback
452452
}
453+
454+
// ============================================================================
455+
// WCAG Contrast Checking (v2.0.7)
456+
// ============================================================================
457+
458+
/**
459+
* Parse a hex color string to RGB values [0-255].
460+
*/
461+
function hexToRGB(hex: string): [number, number, number] | null {
462+
let clean = hex.replace(/^#/, '');
463+
if (clean.length === 3) {
464+
clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2];
465+
}
466+
const match = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(clean);
467+
if (!match) return null;
468+
return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
469+
}
470+
471+
/**
472+
* Calculate relative luminance per WCAG 2.1 spec.
473+
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
474+
*/
475+
function relativeLuminance(r: number, g: number, b: number): number {
476+
const [rs, gs, bs] = [r, g, b].map(c => {
477+
const s = c / 255;
478+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
479+
});
480+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
481+
}
482+
483+
/**
484+
* Calculate the WCAG 2.1 contrast ratio between two hex colors.
485+
* Returns a value between 1 and 21.
486+
*
487+
* @param hex1 - First color in hex format (#RGB or #RRGGBB)
488+
* @param hex2 - Second color in hex format (#RGB or #RRGGBB)
489+
* @returns Contrast ratio (1-21), or null if colors are invalid
490+
*/
491+
export function contrastRatio(hex1: string, hex2: string): number | null {
492+
const rgb1 = hexToRGB(hex1);
493+
const rgb2 = hexToRGB(hex2);
494+
if (!rgb1 || !rgb2) return null;
495+
496+
const l1 = relativeLuminance(...rgb1);
497+
const l2 = relativeLuminance(...rgb2);
498+
const lighter = Math.max(l1, l2);
499+
const darker = Math.min(l1, l2);
500+
return (lighter + 0.05) / (darker + 0.05);
501+
}
502+
503+
/**
504+
* Check if two colors meet the specified WCAG contrast level.
505+
*
506+
* WCAG levels:
507+
* - AA: 4.5:1 for normal text, 3:1 for large text
508+
* - AAA: 7:1 for normal text, 4.5:1 for large text
509+
*
510+
* @param hex1 - First color in hex format
511+
* @param hex2 - Second color in hex format
512+
* @param level - WCAG level: 'AA' or 'AAA'
513+
* @param isLargeText - Whether the text is large (18pt+ or 14pt+ bold)
514+
* @returns true if the color pair meets the required contrast level
515+
*/
516+
export function meetsContrastLevel(
517+
hex1: string,
518+
hex2: string,
519+
level: 'AA' | 'AAA' = 'AA',
520+
isLargeText = false,
521+
): boolean {
522+
const ratio = contrastRatio(hex1, hex2);
523+
if (ratio === null) return false;
524+
525+
if (level === 'AAA') {
526+
return isLargeText ? ratio >= 4.5 : ratio >= 7;
527+
}
528+
// AA
529+
return isLargeText ? ratio >= 3 : ratio >= 4.5;
530+
}

packages/core/src/theme/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ export {
1919
mergeThemes,
2020
resolveThemeInheritance,
2121
resolveMode,
22+
contrastRatio,
23+
meetsContrastLevel,
2224
} from './ThemeEngine';

packages/i18n/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,15 @@ export {
6161
type CurrencyFormatOptions,
6262
type NumberFormatOptions,
6363
} from './utils/index';
64+
65+
// Spec-aligned formatters (v2.0.7)
66+
export {
67+
resolvePlural,
68+
formatDateSpec,
69+
formatNumberSpec,
70+
applyLocaleConfig,
71+
type SpecPluralRule,
72+
type SpecDateFormat,
73+
type SpecNumberFormat,
74+
type SpecLocaleConfig,
75+
} from './utils/index';

packages/i18n/src/provider.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ export interface I18nProviderProps {
2626
config?: I18nConfig;
2727
/** Pre-created i18next instance (overrides config) */
2828
instance?: I18nInstance;
29+
/**
30+
* Dynamic language pack loader (v2.0.7).
31+
* When set, language packs are loaded lazily instead of being bundled.
32+
* Should return a translation resource object for the given language code.
33+
*
34+
* @example
35+
* ```tsx
36+
* <I18nProvider
37+
* loadLanguage={async (lang) => {
38+
* const mod = await import(`./locales/${lang}.json`);
39+
* return mod.default;
40+
* }}
41+
* >
42+
* <App />
43+
* </I18nProvider>
44+
* ```
45+
*/
46+
loadLanguage?: (lang: string) => Promise<Record<string, unknown>>;
2947
/** Children to render */
3048
children: React.ReactNode;
3149
}
@@ -40,7 +58,7 @@ export interface I18nProviderProps {
4058
* </I18nProvider>
4159
* ```
4260
*/
43-
export function I18nProvider({ config, instance: externalInstance, children }: I18nProviderProps) {
61+
export function I18nProvider({ config, instance: externalInstance, loadLanguage, children }: I18nProviderProps) {
4462
const i18nInstance = useMemo(
4563
() => externalInstance || createI18n(config),
4664
[externalInstance, config],
@@ -69,12 +87,17 @@ export function I18nProvider({ config, instance: externalInstance, children }: I
6987
() => ({
7088
language,
7189
changeLanguage: async (lang: string) => {
90+
// Dynamic language pack loading (v2.0.7)
91+
if (loadLanguage && !i18nInstance.hasResourceBundle(lang, 'translation')) {
92+
const resources = await loadLanguage(lang);
93+
i18nInstance.addResourceBundle(lang, 'translation', resources, true, true);
94+
}
7295
await i18nInstance.changeLanguage(lang);
7396
},
7497
direction,
7598
i18n: i18nInstance,
7699
}),
77-
[language, direction, i18nInstance],
100+
[language, direction, i18nInstance, loadLanguage],
78101
);
79102

80103
return React.createElement(

packages/i18n/src/utils/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,14 @@ export {
88
type CurrencyFormatOptions,
99
type NumberFormatOptions,
1010
} from './formatting';
11+
12+
export {
13+
resolvePlural,
14+
formatDateSpec,
15+
formatNumberSpec,
16+
applyLocaleConfig,
17+
type SpecPluralRule,
18+
type SpecDateFormat,
19+
type SpecNumberFormat,
20+
type SpecLocaleConfig,
21+
} from './spec-formatters';
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* @object-ui/i18n - Spec-aligned i18n utilities
11+
*
12+
* Runtime consumers for @objectstack/spec v2.0.7 i18n types:
13+
* - PluralRuleSchema → resolvePlural()
14+
* - DateFormatSchema → formatDateSpec()
15+
* - NumberFormatSchema → formatNumberSpec()
16+
* - LocaleConfigSchema → applyLocaleConfig()
17+
*
18+
* @module spec-formatters
19+
*/
20+
21+
// ============================================================================
22+
// PluralRuleSchema Consumer
23+
// ============================================================================
24+
25+
/**
26+
* Spec-aligned PluralRule (mirrors @objectstack/spec PluralRuleSchema).
27+
*/
28+
export interface SpecPluralRule {
29+
/** Translation key */
30+
key: string;
31+
/** Form for zero items */
32+
zero?: string;
33+
/** Form for exactly one item */
34+
one?: string;
35+
/** Form for exactly two items */
36+
two?: string;
37+
/** Form for few items (language-specific) */
38+
few?: string;
39+
/** Form for many items (language-specific) */
40+
many?: string;
41+
/** Default/fallback form (required) */
42+
other: string;
43+
}
44+
45+
/**
46+
* Resolve a plural form based on count, following CLDR plural rules.
47+
* Uses the Intl.PluralRules API for correct locale-aware pluralization.
48+
*
49+
* @example
50+
* ```ts
51+
* const rule: SpecPluralRule = {
52+
* key: 'items.count',
53+
* zero: 'No items',
54+
* one: '{count} item',
55+
* other: '{count} items',
56+
* };
57+
* resolvePlural(rule, 0, 'en'); // → 'No items'
58+
* resolvePlural(rule, 1, 'en'); // → '1 item'
59+
* resolvePlural(rule, 5, 'en'); // → '5 items'
60+
* ```
61+
*/
62+
export function resolvePlural(
63+
rule: SpecPluralRule,
64+
count: number,
65+
locale = 'en',
66+
): string {
67+
const pr = new Intl.PluralRules(locale);
68+
const category = pr.select(count);
69+
70+
// Prefer the explicit form, fall back to 'other'
71+
const template =
72+
(category === 'zero' && rule.zero) ||
73+
(category === 'one' && rule.one) ||
74+
(category === 'two' && rule.two) ||
75+
(category === 'few' && rule.few) ||
76+
(category === 'many' && rule.many) ||
77+
rule.other;
78+
79+
// Replace {count} placeholder
80+
return template.replace(/\{count\}/g, String(count));
81+
}
82+
83+
// ============================================================================
84+
// DateFormatSchema Consumer
85+
// ============================================================================
86+
87+
/**
88+
* Spec-aligned DateFormat (mirrors @objectstack/spec DateFormatSchema).
89+
*/
90+
export interface SpecDateFormat {
91+
dateStyle?: 'full' | 'long' | 'medium' | 'short';
92+
timeStyle?: 'full' | 'long' | 'medium' | 'short';
93+
timeZone?: string;
94+
hour12?: boolean;
95+
}
96+
97+
/**
98+
* Format a date using @objectstack/spec DateFormatSchema configuration.
99+
*
100+
* @example
101+
* ```ts
102+
* formatDateSpec(new Date(), {
103+
* dateStyle: 'medium',
104+
* timeStyle: 'short',
105+
* timeZone: 'America/New_York',
106+
* }, 'en-US');
107+
* ```
108+
*/
109+
export function formatDateSpec(
110+
date: Date | string | number,
111+
format: SpecDateFormat,
112+
locale = 'en',
113+
): string {
114+
const d = date instanceof Date ? date : new Date(date);
115+
if (isNaN(d.getTime())) return String(date);
116+
117+
const options: Intl.DateTimeFormatOptions = {};
118+
if (format.dateStyle) options.dateStyle = format.dateStyle;
119+
if (format.timeStyle) options.timeStyle = format.timeStyle;
120+
if (format.timeZone) options.timeZone = format.timeZone;
121+
if (format.hour12 !== undefined) options.hour12 = format.hour12;
122+
123+
return new Intl.DateTimeFormat(locale, options).format(d);
124+
}
125+
126+
// ============================================================================
127+
// NumberFormatSchema Consumer
128+
// ============================================================================
129+
130+
/**
131+
* Spec-aligned NumberFormat (mirrors @objectstack/spec NumberFormatSchema).
132+
*/
133+
export interface SpecNumberFormat {
134+
style?: 'currency' | 'percent' | 'decimal' | 'unit';
135+
currency?: string;
136+
unit?: string;
137+
minimumFractionDigits?: number;
138+
maximumFractionDigits?: number;
139+
useGrouping?: boolean;
140+
}
141+
142+
/**
143+
* Format a number using @objectstack/spec NumberFormatSchema configuration.
144+
*
145+
* @example
146+
* ```ts
147+
* formatNumberSpec(1234.56, {
148+
* style: 'currency',
149+
* currency: 'USD',
150+
* maximumFractionDigits: 2,
151+
* }, 'en-US'); // → '$1,234.56'
152+
* ```
153+
*/
154+
export function formatNumberSpec(
155+
value: number,
156+
format: SpecNumberFormat,
157+
locale = 'en',
158+
): string {
159+
const options: Intl.NumberFormatOptions = {
160+
style: format.style || 'decimal',
161+
};
162+
163+
if (format.currency) options.currency = format.currency;
164+
if (format.unit) options.unit = format.unit;
165+
if (format.minimumFractionDigits !== undefined) options.minimumFractionDigits = format.minimumFractionDigits;
166+
if (format.maximumFractionDigits !== undefined) options.maximumFractionDigits = format.maximumFractionDigits;
167+
if (format.useGrouping !== undefined) options.useGrouping = format.useGrouping;
168+
169+
return new Intl.NumberFormat(locale, options).format(value);
170+
}
171+
172+
// ============================================================================
173+
// LocaleConfigSchema Consumer
174+
// ============================================================================
175+
176+
/**
177+
* Spec-aligned LocaleConfig (mirrors @objectstack/spec LocaleConfigSchema).
178+
*/
179+
export interface SpecLocaleConfig {
180+
/** BCP 47 language code (e.g., 'en-US', 'zh-CN') */
181+
code: string;
182+
/** Fallback locale chain */
183+
fallbackChain?: string[];
184+
/** Text direction */
185+
direction?: 'ltr' | 'rtl';
186+
/** Number formatting defaults */
187+
numberFormat?: SpecNumberFormat;
188+
/** Date formatting defaults */
189+
dateFormat?: SpecDateFormat;
190+
}
191+
192+
/**
193+
* Apply a LocaleConfigSchema to configure i18n formatting defaults.
194+
* Returns resolved formatting functions bound to the locale config.
195+
*
196+
* @example
197+
* ```ts
198+
* const locale = applyLocaleConfig({
199+
* code: 'zh-CN',
200+
* direction: 'ltr',
201+
* numberFormat: { style: 'decimal', useGrouping: true },
202+
* dateFormat: { dateStyle: 'medium' },
203+
* });
204+
* locale.formatDate(new Date()); // Chinese medium date
205+
* locale.formatNumber(1234.5); // 1,234.5
206+
* ```
207+
*/
208+
export function applyLocaleConfig(config: SpecLocaleConfig) {
209+
return {
210+
code: config.code,
211+
direction: config.direction || 'ltr',
212+
fallbackChain: config.fallbackChain || [],
213+
formatDate: (date: Date | string | number, overrides?: Partial<SpecDateFormat>) =>
214+
formatDateSpec(date, { ...config.dateFormat, ...overrides }, config.code),
215+
formatNumber: (value: number, overrides?: Partial<SpecNumberFormat>) =>
216+
formatNumberSpec(value, { ...config.numberFormat, ...overrides }, config.code),
217+
resolvePlural: (rule: SpecPluralRule, count: number) =>
218+
resolvePlural(rule, count, config.code),
219+
};
220+
}

0 commit comments

Comments
 (0)