@refraction-ui/astro 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ ---
2
+ import { createLocationSelector } from '../location-selector/index.ts'
3
+ import { cn } from '../shared/index.ts'
4
+
5
+ export interface Props {
6
+ defaultCountry?: string
7
+ defaultLanguage?: string
8
+ className?: string
9
+ }
10
+
11
+ const {
12
+ defaultCountry = 'US',
13
+ defaultLanguage = 'en',
14
+ className
15
+ } = Astro.props
16
+
17
+ const api = createLocationSelector({
18
+ defaultCountry,
19
+ defaultLanguage
20
+ })
21
+ ---
22
+
23
+ <div class={cn('flex flex-col gap-4 sm:flex-row', className)}>
24
+ <div class="flex flex-col gap-1.5 flex-1">
25
+ <label for={api.countryProps.id} class="text-sm font-medium">Country</label>
26
+ <select
27
+ id={api.countryProps.id}
28
+ name={api.countryProps.name}
29
+ class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
30
+ >
31
+ {api.countries.map((c: any) => {
32
+ const value = c.code || c.id || c;
33
+ const label = c.name || c.label || c;
34
+ return (
35
+ <option value={value} selected={value === defaultCountry}>{label}</option>
36
+ )
37
+ })}
38
+ </select>
39
+ </div>
40
+ <div class="flex flex-col gap-1.5 flex-1">
41
+ <label for={api.languageProps.id} class="text-sm font-medium">Language</label>
42
+ <select
43
+ id={api.languageProps.id}
44
+ name={api.languageProps.name}
45
+ class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
46
+ >
47
+ {api.languages.map((l: any) => {
48
+ const value = l.code || l.id || l;
49
+ const label = l.name || l.label || l;
50
+ return (
51
+ <option value={value} selected={value === defaultLanguage}>{label}</option>
52
+ )
53
+ })}
54
+ </select>
55
+ </div>
56
+ </div>
@@ -0,0 +1,15 @@
1
+ import Component from './LocationSelector.astro'
2
+
3
+ const meta = {
4
+ title: 'Astro/LocationSelector',
5
+ component: Component,
6
+ }
7
+
8
+ export default meta
9
+
10
+ export const Default = {
11
+ args: {
12
+ defaultCountry: 'Example defaultCountry',
13
+ defaultLanguage: 'Example defaultLanguage'
14
+ }
15
+ }
@@ -0,0 +1 @@
1
+ export { default as LocationSelector } from './LocationSelector.astro'
@@ -97,10 +97,10 @@ export class CommandInput {
97
97
  const matchedTrigger = this.options.triggers.find((t) => t.char === char);
98
98
 
99
99
  if (matchedTrigger) {
100
- const isStartOfWord = i === 0 || /\\s/.test(rawText[i - 1]);
100
+ const isStartOfWord = i === 0 || /\s/.test(rawText[i - 1]);
101
101
  if (isStartOfWord) {
102
102
  const commandText = rawText.slice(i + 1, cursorPosition);
103
- if (!/\\s/.test(commandText)) {
103
+ if (!/\s/.test(commandText)) {
104
104
  let isValid = true;
105
105
  if (matchedTrigger.pattern) {
106
106
  isValid = matchedTrigger.pattern.test(commandText);
@@ -118,7 +118,7 @@ export class CommandInput {
118
118
  break;
119
119
  }
120
120
 
121
- if (/\\s/.test(char)) {
121
+ if (/\s/.test(char)) {
122
122
  break;
123
123
  }
124
124
  }
@@ -130,8 +130,8 @@ export class CommandInput {
130
130
  return;
131
131
  }
132
132
 
133
- const triggerChars = this.options.triggers.map((t) => t.char.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'));
134
- const patternStr = `(^|\\\\s)(${triggerChars.join('|')})([^\\\\s]*)`;
133
+ const triggerChars = this.options.triggers.map((t) => t.char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
134
+ const patternStr = `(^|\\s)(${triggerChars.join('|')})([^\\s]*)`;
135
135
  const regex = new RegExp(patternStr, 'g');
136
136
 
137
137
  let currentIndex = 0;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Country type and registry for i18n support.
3
+ */
4
+
5
+ export interface Country {
6
+ code: string
7
+ name: string
8
+ flag: string
9
+ defaultLanguages: string[]
10
+ timezone?: string
11
+ }
12
+
13
+ export const COUNTRIES: Record<string, Country> = {
14
+ // North America
15
+ 'US': { code: 'US', name: 'United States', flag: '🇺🇸', defaultLanguages: ['en'], timezone: 'America/New_York' },
16
+ 'CA': { code: 'CA', name: 'Canada', flag: '🇨🇦', defaultLanguages: ['en', 'fr'], timezone: 'America/Toronto' },
17
+ 'MX': { code: 'MX', name: 'Mexico', flag: '🇲🇽', defaultLanguages: ['es'], timezone: 'America/Mexico_City' },
18
+
19
+ // UK & Ireland
20
+ 'GB': { code: 'GB', name: 'United Kingdom', flag: '🇬🇧', defaultLanguages: ['en'], timezone: 'Europe/London' },
21
+ 'IE': { code: 'IE', name: 'Ireland', flag: '🇮🇪', defaultLanguages: ['en'], timezone: 'Europe/Dublin' },
22
+
23
+ // Oceania
24
+ 'AU': { code: 'AU', name: 'Australia', flag: '🇦🇺', defaultLanguages: ['en'], timezone: 'Australia/Sydney' },
25
+ 'NZ': { code: 'NZ', name: 'New Zealand', flag: '🇳🇿', defaultLanguages: ['en'], timezone: 'Pacific/Auckland' },
26
+
27
+ // South Asia
28
+ 'IN': { code: 'IN', name: 'India', flag: '🇮🇳', defaultLanguages: ['hi', 'en'], timezone: 'Asia/Kolkata' },
29
+ 'PK': { code: 'PK', name: 'Pakistan', flag: '🇵🇰', defaultLanguages: ['ur', 'en'], timezone: 'Asia/Karachi' },
30
+ 'BD': { code: 'BD', name: 'Bangladesh', flag: '🇧🇩', defaultLanguages: ['bn'], timezone: 'Asia/Dhaka' },
31
+ 'LK': { code: 'LK', name: 'Sri Lanka', flag: '🇱🇰', defaultLanguages: ['ta', 'en'], timezone: 'Asia/Colombo' },
32
+ 'NP': { code: 'NP', name: 'Nepal', flag: '🇳🇵', defaultLanguages: ['hi', 'en'], timezone: 'Asia/Kathmandu' },
33
+
34
+ // Western Europe
35
+ 'FR': { code: 'FR', name: 'France', flag: '🇫🇷', defaultLanguages: ['fr'], timezone: 'Europe/Paris' },
36
+ 'DE': { code: 'DE', name: 'Germany', flag: '🇩🇪', defaultLanguages: ['de'], timezone: 'Europe/Berlin' },
37
+ 'ES': { code: 'ES', name: 'Spain', flag: '🇪🇸', defaultLanguages: ['es'], timezone: 'Europe/Madrid' },
38
+ 'IT': { code: 'IT', name: 'Italy', flag: '🇮🇹', defaultLanguages: ['it'], timezone: 'Europe/Rome' },
39
+ 'PT': { code: 'PT', name: 'Portugal', flag: '🇵🇹', defaultLanguages: ['pt'], timezone: 'Europe/Lisbon' },
40
+ 'NL': { code: 'NL', name: 'Netherlands', flag: '🇳🇱', defaultLanguages: ['nl'], timezone: 'Europe/Amsterdam' },
41
+ 'BE': { code: 'BE', name: 'Belgium', flag: '🇧🇪', defaultLanguages: ['nl', 'fr', 'de'], timezone: 'Europe/Brussels' },
42
+ 'CH': { code: 'CH', name: 'Switzerland', flag: '🇨🇭', defaultLanguages: ['de', 'fr', 'it'], timezone: 'Europe/Zurich' },
43
+ 'AT': { code: 'AT', name: 'Austria', flag: '🇦🇹', defaultLanguages: ['de'], timezone: 'Europe/Vienna' },
44
+
45
+ // Nordic
46
+ 'SE': { code: 'SE', name: 'Sweden', flag: '🇸🇪', defaultLanguages: ['sv'], timezone: 'Europe/Stockholm' },
47
+ 'NO': { code: 'NO', name: 'Norway', flag: '🇳🇴', defaultLanguages: ['no'], timezone: 'Europe/Oslo' },
48
+ 'DK': { code: 'DK', name: 'Denmark', flag: '🇩🇰', defaultLanguages: ['da'], timezone: 'Europe/Copenhagen' },
49
+ 'FI': { code: 'FI', name: 'Finland', flag: '🇫🇮', defaultLanguages: ['fi'], timezone: 'Europe/Helsinki' },
50
+
51
+ // Eastern Europe
52
+ 'PL': { code: 'PL', name: 'Poland', flag: '🇵🇱', defaultLanguages: ['pl'], timezone: 'Europe/Warsaw' },
53
+ 'CZ': { code: 'CZ', name: 'Czech Republic', flag: '🇨🇿', defaultLanguages: ['cs'], timezone: 'Europe/Prague' },
54
+ 'RO': { code: 'RO', name: 'Romania', flag: '🇷🇴', defaultLanguages: ['ro'], timezone: 'Europe/Bucharest' },
55
+ 'HU': { code: 'HU', name: 'Hungary', flag: '🇭🇺', defaultLanguages: ['hu'], timezone: 'Europe/Budapest' },
56
+ 'BG': { code: 'BG', name: 'Bulgaria', flag: '🇧🇬', defaultLanguages: ['bg'], timezone: 'Europe/Sofia' },
57
+ 'HR': { code: 'HR', name: 'Croatia', flag: '🇭🇷', defaultLanguages: ['hr'], timezone: 'Europe/Zagreb' },
58
+ 'SK': { code: 'SK', name: 'Slovakia', flag: '🇸🇰', defaultLanguages: ['sk'], timezone: 'Europe/Bratislava' },
59
+ 'SI': { code: 'SI', name: 'Slovenia', flag: '🇸🇮', defaultLanguages: ['sl'], timezone: 'Europe/Ljubljana' },
60
+ 'RS': { code: 'RS', name: 'Serbia', flag: '🇷🇸', defaultLanguages: ['sr'], timezone: 'Europe/Belgrade' },
61
+ 'UA': { code: 'UA', name: 'Ukraine', flag: '🇺🇦', defaultLanguages: ['uk'], timezone: 'Europe/Kyiv' },
62
+ 'RU': { code: 'RU', name: 'Russia', flag: '🇷🇺', defaultLanguages: ['ru'], timezone: 'Europe/Moscow' },
63
+ 'TR': { code: 'TR', name: 'Turkey', flag: '🇹🇷', defaultLanguages: ['tr'], timezone: 'Europe/Istanbul' },
64
+ 'GR': { code: 'GR', name: 'Greece', flag: '🇬🇷', defaultLanguages: ['el'], timezone: 'Europe/Athens' },
65
+
66
+ // East Asia
67
+ 'JP': { code: 'JP', name: 'Japan', flag: '🇯🇵', defaultLanguages: ['ja'], timezone: 'Asia/Tokyo' },
68
+ 'KR': { code: 'KR', name: 'South Korea', flag: '🇰🇷', defaultLanguages: ['ko'], timezone: 'Asia/Seoul' },
69
+ 'CN': { code: 'CN', name: 'China', flag: '🇨🇳', defaultLanguages: ['zh'], timezone: 'Asia/Shanghai' },
70
+ 'TW': { code: 'TW', name: 'Taiwan', flag: '🇹🇼', defaultLanguages: ['zh'], timezone: 'Asia/Taipei' },
71
+
72
+ // Southeast Asia
73
+ 'TH': { code: 'TH', name: 'Thailand', flag: '🇹🇭', defaultLanguages: ['th'], timezone: 'Asia/Bangkok' },
74
+ 'VN': { code: 'VN', name: 'Vietnam', flag: '🇻🇳', defaultLanguages: ['vi'], timezone: 'Asia/Ho_Chi_Minh' },
75
+ 'ID': { code: 'ID', name: 'Indonesia', flag: '🇮🇩', defaultLanguages: ['id'], timezone: 'Asia/Jakarta' },
76
+ 'MY': { code: 'MY', name: 'Malaysia', flag: '🇲🇾', defaultLanguages: ['ms', 'en'], timezone: 'Asia/Kuala_Lumpur' },
77
+ 'PH': { code: 'PH', name: 'Philippines', flag: '🇵🇭', defaultLanguages: ['en'], timezone: 'Asia/Manila' },
78
+ 'SG': { code: 'SG', name: 'Singapore', flag: '🇸🇬', defaultLanguages: ['en', 'ms', 'zh'], timezone: 'Asia/Singapore' },
79
+
80
+ // South America
81
+ 'BR': { code: 'BR', name: 'Brazil', flag: '🇧🇷', defaultLanguages: ['pt'], timezone: 'America/Sao_Paulo' },
82
+ 'AR': { code: 'AR', name: 'Argentina', flag: '🇦🇷', defaultLanguages: ['es'], timezone: 'America/Argentina/Buenos_Aires' },
83
+ 'CO': { code: 'CO', name: 'Colombia', flag: '🇨🇴', defaultLanguages: ['es'], timezone: 'America/Bogota' },
84
+ 'CL': { code: 'CL', name: 'Chile', flag: '🇨🇱', defaultLanguages: ['es'], timezone: 'America/Santiago' },
85
+
86
+ // Middle East & North Africa
87
+ 'SA': { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦', defaultLanguages: ['ar'], timezone: 'Asia/Riyadh' },
88
+ 'AE': { code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪', defaultLanguages: ['ar', 'en'], timezone: 'Asia/Dubai' },
89
+ 'IL': { code: 'IL', name: 'Israel', flag: '🇮🇱', defaultLanguages: ['he'], timezone: 'Asia/Jerusalem' },
90
+ 'EG': { code: 'EG', name: 'Egypt', flag: '🇪🇬', defaultLanguages: ['ar'], timezone: 'Africa/Cairo' },
91
+ }
92
+
93
+ /**
94
+ * Get a country by its code.
95
+ */
96
+ export function getCountry(code: string): Country | undefined {
97
+ return COUNTRIES[code]
98
+ }
99
+
100
+ /**
101
+ * Get the flag emoji for a country code.
102
+ * Returns empty string if the code is not found.
103
+ */
104
+ export function getCountryFlag(code: string): string {
105
+ return COUNTRIES[code]?.flag ?? ''
106
+ }
107
+
108
+ /**
109
+ * Get the display name for a country code.
110
+ * Returns empty string if the code is not found.
111
+ */
112
+ export function getCountryName(code: string): string {
113
+ return COUNTRIES[code]?.name ?? ''
114
+ }
115
+
116
+ /**
117
+ * Get default languages for a country.
118
+ * Returns empty array if the country is not found.
119
+ */
120
+ export function getDefaultLanguagesForCountry(code: string): string[] {
121
+ return COUNTRIES[code]?.defaultLanguages ?? []
122
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Region detection utilities.
3
+ * Detects user country and language from browser/environment signals.
4
+ */
5
+
6
+ /** Mapping of IANA timezone identifiers to ISO country codes */
7
+ export const TIMEZONE_TO_COUNTRY: Record<string, string> = {
8
+ // United States
9
+ 'America/New_York': 'US',
10
+ 'America/Chicago': 'US',
11
+ 'America/Denver': 'US',
12
+ 'America/Los_Angeles': 'US',
13
+ 'America/Phoenix': 'US',
14
+ 'America/Anchorage': 'US',
15
+ 'Pacific/Honolulu': 'US',
16
+ 'America/Detroit': 'US',
17
+ 'America/Indiana/Indianapolis': 'US',
18
+ 'America/Boise': 'US',
19
+
20
+ // Canada
21
+ 'America/Toronto': 'CA',
22
+ 'America/Vancouver': 'CA',
23
+ 'America/Edmonton': 'CA',
24
+ 'America/Winnipeg': 'CA',
25
+ 'America/Halifax': 'CA',
26
+ 'America/St_Johns': 'CA',
27
+
28
+ // Mexico
29
+ 'America/Mexico_City': 'MX',
30
+ 'America/Cancun': 'MX',
31
+ 'America/Tijuana': 'MX',
32
+
33
+ // UK & Ireland
34
+ 'Europe/London': 'GB',
35
+ 'Europe/Dublin': 'IE',
36
+
37
+ // Western Europe
38
+ 'Europe/Paris': 'FR',
39
+ 'Europe/Berlin': 'DE',
40
+ 'Europe/Madrid': 'ES',
41
+ 'Europe/Rome': 'IT',
42
+ 'Europe/Lisbon': 'PT',
43
+ 'Europe/Amsterdam': 'NL',
44
+ 'Europe/Brussels': 'BE',
45
+ 'Europe/Zurich': 'CH',
46
+ 'Europe/Vienna': 'AT',
47
+
48
+ // Nordic
49
+ 'Europe/Stockholm': 'SE',
50
+ 'Europe/Oslo': 'NO',
51
+ 'Europe/Copenhagen': 'DK',
52
+ 'Europe/Helsinki': 'FI',
53
+
54
+ // Eastern Europe
55
+ 'Europe/Warsaw': 'PL',
56
+ 'Europe/Prague': 'CZ',
57
+ 'Europe/Bucharest': 'RO',
58
+ 'Europe/Budapest': 'HU',
59
+ 'Europe/Sofia': 'BG',
60
+ 'Europe/Zagreb': 'HR',
61
+ 'Europe/Bratislava': 'SK',
62
+ 'Europe/Ljubljana': 'SI',
63
+ 'Europe/Belgrade': 'RS',
64
+ 'Europe/Kyiv': 'UA',
65
+ 'Europe/Moscow': 'RU',
66
+ 'Europe/Istanbul': 'TR',
67
+ 'Europe/Athens': 'GR',
68
+
69
+ // South Asia
70
+ 'Asia/Kolkata': 'IN',
71
+ 'Asia/Calcutta': 'IN',
72
+ 'Asia/Karachi': 'PK',
73
+ 'Asia/Dhaka': 'BD',
74
+ 'Asia/Colombo': 'LK',
75
+ 'Asia/Kathmandu': 'NP',
76
+
77
+ // East Asia
78
+ 'Asia/Tokyo': 'JP',
79
+ 'Asia/Seoul': 'KR',
80
+ 'Asia/Shanghai': 'CN',
81
+ 'Asia/Hong_Kong': 'CN',
82
+ 'Asia/Taipei': 'TW',
83
+
84
+ // Southeast Asia
85
+ 'Asia/Bangkok': 'TH',
86
+ 'Asia/Ho_Chi_Minh': 'VN',
87
+ 'Asia/Jakarta': 'ID',
88
+ 'Asia/Kuala_Lumpur': 'MY',
89
+ 'Asia/Manila': 'PH',
90
+ 'Asia/Singapore': 'SG',
91
+
92
+ // Oceania
93
+ 'Australia/Sydney': 'AU',
94
+ 'Australia/Melbourne': 'AU',
95
+ 'Australia/Brisbane': 'AU',
96
+ 'Australia/Perth': 'AU',
97
+ 'Australia/Adelaide': 'AU',
98
+ 'Pacific/Auckland': 'NZ',
99
+
100
+ // South America
101
+ 'America/Sao_Paulo': 'BR',
102
+ 'America/Rio_Branco': 'BR',
103
+ 'America/Argentina/Buenos_Aires': 'AR',
104
+ 'America/Bogota': 'CO',
105
+ 'America/Santiago': 'CL',
106
+
107
+ // Middle East
108
+ 'Asia/Riyadh': 'SA',
109
+ 'Asia/Dubai': 'AE',
110
+ 'Asia/Jerusalem': 'IL',
111
+ 'Africa/Cairo': 'EG',
112
+ }
113
+
114
+ /** Mapping of BCP-47 locale strings to ISO country codes */
115
+ export const LOCALE_TO_COUNTRY: Record<string, string> = {
116
+ 'en-US': 'US',
117
+ 'en-GB': 'GB',
118
+ 'en-AU': 'AU',
119
+ 'en-CA': 'CA',
120
+ 'en-NZ': 'NZ',
121
+ 'en-IE': 'IE',
122
+ 'en-IN': 'IN',
123
+ 'en-SG': 'SG',
124
+ 'en-PH': 'PH',
125
+ 'en-MY': 'MY',
126
+ 'en-ZA': 'ZA',
127
+ 'hi-IN': 'IN',
128
+ 'te-IN': 'IN',
129
+ 'ta-IN': 'IN',
130
+ 'ur-PK': 'PK',
131
+ 'pa-IN': 'IN',
132
+ 'bn-IN': 'IN',
133
+ 'bn-BD': 'BD',
134
+ 'gu-IN': 'IN',
135
+ 'mr-IN': 'IN',
136
+ 'kn-IN': 'IN',
137
+ 'ml-IN': 'IN',
138
+ 'fr-FR': 'FR',
139
+ 'fr-CA': 'CA',
140
+ 'fr-BE': 'BE',
141
+ 'fr-CH': 'CH',
142
+ 'es-ES': 'ES',
143
+ 'es-MX': 'MX',
144
+ 'es-AR': 'AR',
145
+ 'es-CO': 'CO',
146
+ 'es-CL': 'CL',
147
+ 'de-DE': 'DE',
148
+ 'de-AT': 'AT',
149
+ 'de-CH': 'CH',
150
+ 'it-IT': 'IT',
151
+ 'it-CH': 'CH',
152
+ 'pt-PT': 'PT',
153
+ 'pt-BR': 'BR',
154
+ 'nl-NL': 'NL',
155
+ 'nl-BE': 'BE',
156
+ 'sv-SE': 'SE',
157
+ 'da-DK': 'DK',
158
+ 'nb-NO': 'NO',
159
+ 'nn-NO': 'NO',
160
+ 'fi-FI': 'FI',
161
+ 'pl-PL': 'PL',
162
+ 'cs-CZ': 'CZ',
163
+ 'ro-RO': 'RO',
164
+ 'hu-HU': 'HU',
165
+ 'bg-BG': 'BG',
166
+ 'hr-HR': 'HR',
167
+ 'sk-SK': 'SK',
168
+ 'sl-SI': 'SI',
169
+ 'sr-RS': 'SR',
170
+ 'uk-UA': 'UA',
171
+ 'el-GR': 'GR',
172
+ 'ru-RU': 'RU',
173
+ 'ja-JP': 'JP',
174
+ 'ko-KR': 'KR',
175
+ 'zh-CN': 'CN',
176
+ 'zh-TW': 'TW',
177
+ 'zh-HK': 'CN',
178
+ 'ar-SA': 'SA',
179
+ 'ar-AE': 'AE',
180
+ 'ar-EG': 'EG',
181
+ 'he-IL': 'IL',
182
+ 'tr-TR': 'TR',
183
+ 'th-TH': 'TH',
184
+ 'vi-VN': 'VN',
185
+ 'id-ID': 'ID',
186
+ 'ms-MY': 'MY',
187
+ }
188
+
189
+ /**
190
+ * Detect the user's country from environment signals.
191
+ * Strategy: timezone mapping -> navigator.languages locale mapping -> fallback 'US'
192
+ */
193
+ export function detectCountry(): string {
194
+ // 1. Try timezone mapping
195
+ try {
196
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
197
+ if (tz && TIMEZONE_TO_COUNTRY[tz]) {
198
+ return TIMEZONE_TO_COUNTRY[tz]
199
+ }
200
+ } catch {
201
+ // Intl may not be available
202
+ }
203
+
204
+ // 2. Try navigator.languages locale mapping
205
+ try {
206
+ if (typeof navigator !== 'undefined') {
207
+ const languages = navigator.languages
208
+ if (languages && languages.length > 0) {
209
+ for (const locale of languages) {
210
+ if (LOCALE_TO_COUNTRY[locale]) {
211
+ return LOCALE_TO_COUNTRY[locale]
212
+ }
213
+ }
214
+ }
215
+ }
216
+ } catch {
217
+ // navigator may not be available
218
+ }
219
+
220
+ // 3. Fallback
221
+ return 'US'
222
+ }
223
+
224
+ /**
225
+ * Detect the user's preferred language from navigator.languages.
226
+ * Returns the first language from navigator.languages, or 'en' as fallback.
227
+ */
228
+ export function detectLanguage(): string {
229
+ try {
230
+ if (typeof navigator !== 'undefined') {
231
+ const languages = (navigator as any).languages
232
+ if (languages && languages.length > 0) {
233
+ return languages[0]
234
+ }
235
+ if ((navigator as any).language) {
236
+ return (navigator as any).language
237
+ }
238
+ }
239
+ } catch {
240
+ // navigator may not be available
241
+ }
242
+ return 'en'
243
+ }
@@ -0,0 +1,47 @@
1
+ // Languages
2
+ export type { Language } from './languages.js'
3
+ export {
4
+ LANGUAGES,
5
+ getLanguage,
6
+ getSupportedLanguages,
7
+ getLanguagesByGroup,
8
+ getLanguageName,
9
+ isRTL,
10
+ getTtsLang,
11
+ } from './languages.js'
12
+
13
+ // Countries
14
+ export type { Country } from './countries.js'
15
+ export {
16
+ COUNTRIES,
17
+ getCountry,
18
+ getCountryFlag,
19
+ getCountryName,
20
+ getDefaultLanguagesForCountry,
21
+ } from './countries.js'
22
+
23
+ // Region detection
24
+ export {
25
+ TIMEZONE_TO_COUNTRY,
26
+ LOCALE_TO_COUNTRY,
27
+ detectCountry,
28
+ detectLanguage,
29
+ } from './detect-region.js'
30
+
31
+ // Voice registry
32
+ export type { VoiceOption } from './voice-registry.js'
33
+ export {
34
+ VOICES,
35
+ getVoicesForLanguage,
36
+ getBestVoice,
37
+ getVoicesByProvider,
38
+ } from './voice-registry.js'
39
+
40
+ // Locale utilities
41
+ export {
42
+ LOCALE_DISPLAY_NAMES,
43
+ getLocaleDisplayName,
44
+ getStoredLocale,
45
+ setStoredLocale,
46
+ getLocalizedValue,
47
+ } from './locale-utils.js'
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Language type and registry for i18n support.
3
+ */
4
+
5
+ export interface Language {
6
+ code: string
7
+ name: string
8
+ nativeName: string
9
+ direction: 'ltr' | 'rtl'
10
+ group?: string
11
+ }
12
+
13
+ export const LANGUAGES: Record<string, Language> = {
14
+ // English variants
15
+ 'en': { code: 'en', name: 'English', nativeName: 'English', direction: 'ltr', group: 'English' },
16
+ 'en-US': { code: 'en-US', name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', group: 'English' },
17
+ 'en-GB': { code: 'en-GB', name: 'English (UK)', nativeName: 'English (UK)', direction: 'ltr', group: 'English' },
18
+ 'en-IN': { code: 'en-IN', name: 'English (India)', nativeName: 'English (India)',direction: 'ltr', group: 'English' },
19
+
20
+ // Indian languages
21
+ 'hi': { code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', direction: 'ltr', group: 'Indian' },
22
+ 'te': { code: 'te', name: 'Telugu', nativeName: 'తెలుగు', direction: 'ltr', group: 'Indian' },
23
+ 'ta': { code: 'ta', name: 'Tamil', nativeName: 'தமிழ்', direction: 'ltr', group: 'Indian' },
24
+ 'ur': { code: 'ur', name: 'Urdu', nativeName: 'اردو', direction: 'rtl', group: 'Indian' },
25
+ 'pa': { code: 'pa', name: 'Punjabi', nativeName: 'ਪੰਜਾਬੀ', direction: 'ltr', group: 'Indian' },
26
+ 'bn': { code: 'bn', name: 'Bengali', nativeName: 'বাংলা', direction: 'ltr', group: 'Indian' },
27
+ 'gu': { code: 'gu', name: 'Gujarati', nativeName: 'ગુજરાતી', direction: 'ltr', group: 'Indian' },
28
+ 'mr': { code: 'mr', name: 'Marathi', nativeName: 'मराठी', direction: 'ltr', group: 'Indian' },
29
+ 'kn': { code: 'kn', name: 'Kannada', nativeName: 'ಕನ್ನಡ', direction: 'ltr', group: 'Indian' },
30
+ 'ml': { code: 'ml', name: 'Malayalam', nativeName: 'മലയാളം', direction: 'ltr', group: 'Indian' },
31
+
32
+ // Western European
33
+ 'fr': { code: 'fr', name: 'French', nativeName: 'Français', direction: 'ltr', group: 'European' },
34
+ 'es': { code: 'es', name: 'Spanish', nativeName: 'Español', direction: 'ltr', group: 'European' },
35
+ 'de': { code: 'de', name: 'German', nativeName: 'Deutsch', direction: 'ltr', group: 'European' },
36
+ 'it': { code: 'it', name: 'Italian', nativeName: 'Italiano', direction: 'ltr', group: 'European' },
37
+ 'pt': { code: 'pt', name: 'Portuguese', nativeName: 'Português', direction: 'ltr', group: 'European' },
38
+ 'pt-BR': { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', direction: 'ltr', group: 'European' },
39
+ 'nl': { code: 'nl', name: 'Dutch', nativeName: 'Nederlands', direction: 'ltr', group: 'European' },
40
+
41
+ // Nordic
42
+ 'sv': { code: 'sv', name: 'Swedish', nativeName: 'Svenska', direction: 'ltr', group: 'European' },
43
+ 'da': { code: 'da', name: 'Danish', nativeName: 'Dansk', direction: 'ltr', group: 'European' },
44
+ 'no': { code: 'no', name: 'Norwegian', nativeName: 'Norsk', direction: 'ltr', group: 'European' },
45
+ 'fi': { code: 'fi', name: 'Finnish', nativeName: 'Suomi', direction: 'ltr', group: 'European' },
46
+
47
+ // Eastern European
48
+ 'pl': { code: 'pl', name: 'Polish', nativeName: 'Polski', direction: 'ltr', group: 'European' },
49
+ 'cs': { code: 'cs', name: 'Czech', nativeName: 'Čeština', direction: 'ltr', group: 'European' },
50
+ 'ro': { code: 'ro', name: 'Romanian', nativeName: 'Română', direction: 'ltr', group: 'European' },
51
+ 'hu': { code: 'hu', name: 'Hungarian', nativeName: 'Magyar', direction: 'ltr', group: 'European' },
52
+ 'bg': { code: 'bg', name: 'Bulgarian', nativeName: 'Български', direction: 'ltr', group: 'European' },
53
+ 'hr': { code: 'hr', name: 'Croatian', nativeName: 'Hrvatski', direction: 'ltr', group: 'European' },
54
+ 'sk': { code: 'sk', name: 'Slovak', nativeName: 'Slovenčina', direction: 'ltr', group: 'European' },
55
+ 'sl': { code: 'sl', name: 'Slovenian', nativeName: 'Slovenščina', direction: 'ltr', group: 'European' },
56
+ 'sr': { code: 'sr', name: 'Serbian', nativeName: 'Srpski', direction: 'ltr', group: 'European' },
57
+ 'uk': { code: 'uk', name: 'Ukrainian', nativeName: 'Українська', direction: 'ltr', group: 'European' },
58
+ 'el': { code: 'el', name: 'Greek', nativeName: 'Ελληνικά', direction: 'ltr', group: 'European' },
59
+ 'ru': { code: 'ru', name: 'Russian', nativeName: 'Русский', direction: 'ltr', group: 'European' },
60
+
61
+ // East Asian
62
+ 'ja': { code: 'ja', name: 'Japanese', nativeName: '日本語', direction: 'ltr', group: 'East Asian' },
63
+ 'ko': { code: 'ko', name: 'Korean', nativeName: '한국어', direction: 'ltr', group: 'East Asian' },
64
+ 'zh': { code: 'zh', name: 'Chinese (Simplified)', nativeName: '中文(简体)', direction: 'ltr', group: 'East Asian' },
65
+ 'zh-TW': { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '中文(繁體)', direction: 'ltr', group: 'East Asian' },
66
+
67
+ // Middle Eastern
68
+ 'ar': { code: 'ar', name: 'Arabic', nativeName: 'العربية', direction: 'rtl', group: 'Middle Eastern' },
69
+ 'he': { code: 'he', name: 'Hebrew', nativeName: 'עברית', direction: 'rtl', group: 'Middle Eastern' },
70
+ 'tr': { code: 'tr', name: 'Turkish', nativeName: 'Türkçe', direction: 'ltr', group: 'Middle Eastern' },
71
+
72
+ // Southeast Asian
73
+ 'th': { code: 'th', name: 'Thai', nativeName: 'ไทย', direction: 'ltr', group: 'Southeast Asian' },
74
+ 'vi': { code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', direction: 'ltr', group: 'Southeast Asian' },
75
+ 'id': { code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', direction: 'ltr', group: 'Southeast Asian' },
76
+ 'ms': { code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu', direction: 'ltr', group: 'Southeast Asian' },
77
+ }
78
+
79
+ /** BCP-47 TTS language tag mappings for base language codes */
80
+ const TTS_LANG_MAP: Record<string, string> = {
81
+ 'en': 'en-US',
82
+ 'en-US': 'en-US',
83
+ 'en-GB': 'en-GB',
84
+ 'en-IN': 'en-IN',
85
+ 'hi': 'hi-IN',
86
+ 'te': 'te-IN',
87
+ 'ta': 'ta-IN',
88
+ 'ur': 'ur-PK',
89
+ 'pa': 'pa-IN',
90
+ 'bn': 'bn-IN',
91
+ 'gu': 'gu-IN',
92
+ 'mr': 'mr-IN',
93
+ 'kn': 'kn-IN',
94
+ 'ml': 'ml-IN',
95
+ 'fr': 'fr-FR',
96
+ 'es': 'es-ES',
97
+ 'de': 'de-DE',
98
+ 'it': 'it-IT',
99
+ 'pt': 'pt-PT',
100
+ 'pt-BR': 'pt-BR',
101
+ 'nl': 'nl-NL',
102
+ 'sv': 'sv-SE',
103
+ 'da': 'da-DK',
104
+ 'no': 'nb-NO',
105
+ 'fi': 'fi-FI',
106
+ 'pl': 'pl-PL',
107
+ 'cs': 'cs-CZ',
108
+ 'ro': 'ro-RO',
109
+ 'hu': 'hu-HU',
110
+ 'bg': 'bg-BG',
111
+ 'hr': 'hr-HR',
112
+ 'sk': 'sk-SK',
113
+ 'sl': 'sl-SI',
114
+ 'sr': 'sr-RS',
115
+ 'uk': 'uk-UA',
116
+ 'el': 'el-GR',
117
+ 'ru': 'ru-RU',
118
+ 'ja': 'ja-JP',
119
+ 'ko': 'ko-KR',
120
+ 'zh': 'zh-CN',
121
+ 'zh-TW': 'zh-TW',
122
+ 'ar': 'ar-SA',
123
+ 'he': 'he-IL',
124
+ 'tr': 'tr-TR',
125
+ 'th': 'th-TH',
126
+ 'vi': 'vi-VN',
127
+ 'id': 'id-ID',
128
+ 'ms': 'ms-MY',
129
+ }
130
+
131
+ /**
132
+ * Get a language by its code.
133
+ */
134
+ export function getLanguage(code: string): Language | undefined {
135
+ return LANGUAGES[code]
136
+ }
137
+
138
+ /**
139
+ * Get all supported languages as an array.
140
+ */
141
+ export function getSupportedLanguages(): Language[] {
142
+ return Object.values(LANGUAGES)
143
+ }
144
+
145
+ /**
146
+ * Get languages grouped by their language family/group.
147
+ */
148
+ export function getLanguagesByGroup(): Record<string, Language[]> {
149
+ const groups: Record<string, Language[]> = {}
150
+ for (const lang of Object.values(LANGUAGES)) {
151
+ const group = lang.group ?? 'Other'
152
+ if (!groups[group]) {
153
+ groups[group] = []
154
+ }
155
+ groups[group].push(lang)
156
+ }
157
+ return groups
158
+ }
159
+
160
+ /**
161
+ * Get the display name for a language code.
162
+ * Returns empty string if the code is not found.
163
+ */
164
+ export function getLanguageName(code: string): string {
165
+ return LANGUAGES[code]?.name ?? ''
166
+ }
167
+
168
+ /**
169
+ * Check if a language code represents a right-to-left language.
170
+ */
171
+ export function isRTL(code: string): boolean {
172
+ return LANGUAGES[code]?.direction === 'rtl'
173
+ }
174
+
175
+ /**
176
+ * Get the BCP-47 language tag suitable for TTS engines.
177
+ * Returns the code itself if no mapping exists.
178
+ */
179
+ export function getTtsLang(code: string): string {
180
+ return TTS_LANG_MAP[code] ?? code
181
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Locale utilities for display names, storage, and localized value resolution.
3
+ */
4
+
5
+ /** Display names for common locale codes (in their native language) */
6
+ export const LOCALE_DISPLAY_NAMES: Record<string, string> = {
7
+ 'en': 'English',
8
+ 'en-US': 'English (US)',
9
+ 'en-GB': 'English (UK)',
10
+ 'en-IN': 'English (India)',
11
+ 'hi': 'हिन्दी',
12
+ 'te': 'తెలుగు',
13
+ 'ta': 'தமிழ்',
14
+ 'ur': 'اردو',
15
+ 'pa': 'ਪੰਜਾਬੀ',
16
+ 'bn': 'বাংলা',
17
+ 'gu': 'ગુજરાતી',
18
+ 'mr': 'मराठी',
19
+ 'kn': 'ಕನ್ನಡ',
20
+ 'ml': 'മലയാളം',
21
+ 'fr': 'Français',
22
+ 'es': 'Español',
23
+ 'de': 'Deutsch',
24
+ 'it': 'Italiano',
25
+ 'pt': 'Português',
26
+ 'pt-BR': 'Português (Brasil)',
27
+ 'nl': 'Nederlands',
28
+ 'sv': 'Svenska',
29
+ 'da': 'Dansk',
30
+ 'no': 'Norsk',
31
+ 'fi': 'Suomi',
32
+ 'pl': 'Polski',
33
+ 'cs': 'Čeština',
34
+ 'ro': 'Română',
35
+ 'hu': 'Magyar',
36
+ 'bg': 'Български',
37
+ 'hr': 'Hrvatski',
38
+ 'sk': 'Slovenčina',
39
+ 'sl': 'Slovenščina',
40
+ 'sr': 'Srpski',
41
+ 'uk': 'Українська',
42
+ 'el': 'Ελληνικά',
43
+ 'ru': 'Русский',
44
+ 'ja': '日本語',
45
+ 'ko': '한국어',
46
+ 'zh': '中文',
47
+ 'zh-TW': '中文(繁體)',
48
+ 'ar': 'العربية',
49
+ 'he': 'עברית',
50
+ 'tr': 'Türkçe',
51
+ 'th': 'ไทย',
52
+ 'vi': 'Tiếng Việt',
53
+ 'id': 'Bahasa Indonesia',
54
+ 'ms': 'Bahasa Melayu',
55
+ }
56
+
57
+ const DEFAULT_STORAGE_KEY = 'refraction-ui-locale'
58
+
59
+ /**
60
+ * Get the display name for a locale code.
61
+ * Returns the code itself if not found in the display names map.
62
+ */
63
+ export function getLocaleDisplayName(code: string): string {
64
+ return LOCALE_DISPLAY_NAMES[code] ?? code
65
+ }
66
+
67
+ /**
68
+ * Get the stored locale from localStorage (or compatible storage).
69
+ * Returns null if not stored or localStorage is not available.
70
+ */
71
+ export function getStoredLocale(storageKey?: string): string | null {
72
+ try {
73
+ if (typeof localStorage !== 'undefined') {
74
+ return localStorage.getItem(storageKey ?? DEFAULT_STORAGE_KEY)
75
+ }
76
+ } catch {
77
+ // localStorage may not be available
78
+ }
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Store a locale code in localStorage (or compatible storage).
84
+ * Does nothing if localStorage is not available.
85
+ */
86
+ export function setStoredLocale(code: string, storageKey?: string): void {
87
+ try {
88
+ if (typeof localStorage !== 'undefined') {
89
+ localStorage.setItem(storageKey ?? DEFAULT_STORAGE_KEY, code)
90
+ }
91
+ } catch {
92
+ // localStorage may not be available
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve a localized value from a record of locale -> value.
98
+ * Tries exact locale, then base language code (for regional variants like 'en-US' -> 'en'),
99
+ * then the fallback locale.
100
+ */
101
+ export function getLocalizedValue<T>(
102
+ localized: Record<string, T>,
103
+ locale: string,
104
+ fallbackLocale?: string,
105
+ ): T | undefined {
106
+ // 1. Exact match
107
+ if (locale in localized) {
108
+ return localized[locale]
109
+ }
110
+
111
+ // 2. Try base language code (e.g., 'en-US' -> 'en')
112
+ const baseLang = locale.split('-')[0]
113
+ if (baseLang !== locale && baseLang in localized) {
114
+ return localized[baseLang]
115
+ }
116
+
117
+ // 3. Fallback locale
118
+ if (fallbackLocale && fallbackLocale in localized) {
119
+ return localized[fallbackLocale]
120
+ }
121
+
122
+ return undefined
123
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Voice registry for TTS providers.
3
+ */
4
+
5
+ export interface VoiceOption {
6
+ id: string
7
+ name: string
8
+ language: string
9
+ provider: 'browser' | 'google' | 'elevenlabs' | 'openai'
10
+ gender?: 'male' | 'female'
11
+ }
12
+
13
+ export const VOICES: VoiceOption[] = [
14
+ // Browser voices — English
15
+ { id: 'browser-en-us-female', name: 'English (US) Female', language: 'en-US', provider: 'browser', gender: 'female' },
16
+ { id: 'browser-en-us-male', name: 'English (US) Male', language: 'en-US', provider: 'browser', gender: 'male' },
17
+ { id: 'browser-en-gb-female', name: 'English (UK) Female', language: 'en-GB', provider: 'browser', gender: 'female' },
18
+ { id: 'browser-en-gb-male', name: 'English (UK) Male', language: 'en-GB', provider: 'browser', gender: 'male' },
19
+ { id: 'browser-en-in-female', name: 'English (India) Female', language: 'en-IN', provider: 'browser', gender: 'female' },
20
+
21
+ // Browser voices — European
22
+ { id: 'browser-fr-female', name: 'French Female', language: 'fr', provider: 'browser', gender: 'female' },
23
+ { id: 'browser-de-female', name: 'German Female', language: 'de', provider: 'browser', gender: 'female' },
24
+ { id: 'browser-es-female', name: 'Spanish Female', language: 'es', provider: 'browser', gender: 'female' },
25
+ { id: 'browser-it-female', name: 'Italian Female', language: 'it', provider: 'browser', gender: 'female' },
26
+ { id: 'browser-pt-female', name: 'Portuguese Female', language: 'pt', provider: 'browser', gender: 'female' },
27
+
28
+ // Browser voices — Asian
29
+ { id: 'browser-ja-female', name: 'Japanese Female', language: 'ja', provider: 'browser', gender: 'female' },
30
+ { id: 'browser-ko-female', name: 'Korean Female', language: 'ko', provider: 'browser', gender: 'female' },
31
+ { id: 'browser-zh-female', name: 'Chinese Female', language: 'zh', provider: 'browser', gender: 'female' },
32
+ { id: 'browser-hi-female', name: 'Hindi Female', language: 'hi', provider: 'browser', gender: 'female' },
33
+ { id: 'browser-ar-male', name: 'Arabic Male', language: 'ar', provider: 'browser', gender: 'male' },
34
+
35
+ // Google TTS voices
36
+ { id: 'google-en-us-wavenet-d', name: 'Google WaveNet English (US)', language: 'en-US', provider: 'google', gender: 'male' },
37
+ { id: 'google-en-us-wavenet-f', name: 'Google WaveNet English (US) Female', language: 'en-US', provider: 'google', gender: 'female' },
38
+ { id: 'google-en-gb-wavenet-a', name: 'Google WaveNet English (UK)', language: 'en-GB', provider: 'google', gender: 'female' },
39
+ { id: 'google-fr-wavenet-a', name: 'Google WaveNet French', language: 'fr', provider: 'google', gender: 'female' },
40
+ { id: 'google-de-wavenet-a', name: 'Google WaveNet German', language: 'de', provider: 'google', gender: 'female' },
41
+ { id: 'google-es-wavenet-a', name: 'Google WaveNet Spanish', language: 'es', provider: 'google', gender: 'female' },
42
+ { id: 'google-ja-wavenet-a', name: 'Google WaveNet Japanese', language: 'ja', provider: 'google', gender: 'female' },
43
+ { id: 'google-hi-wavenet-a', name: 'Google WaveNet Hindi', language: 'hi', provider: 'google', gender: 'female' },
44
+ { id: 'google-ar-wavenet-a', name: 'Google WaveNet Arabic', language: 'ar', provider: 'google', gender: 'female' },
45
+
46
+ // ElevenLabs voices
47
+ { id: 'elevenlabs-rachel', name: 'Rachel', language: 'en', provider: 'elevenlabs', gender: 'female' },
48
+ { id: 'elevenlabs-adam', name: 'Adam', language: 'en', provider: 'elevenlabs', gender: 'male' },
49
+ { id: 'elevenlabs-antoni', name: 'Antoni', language: 'en', provider: 'elevenlabs', gender: 'male' },
50
+ { id: 'elevenlabs-bella', name: 'Bella', language: 'es', provider: 'elevenlabs', gender: 'female' },
51
+ { id: 'elevenlabs-elli', name: 'Elli', language: 'de', provider: 'elevenlabs', gender: 'female' },
52
+
53
+ // OpenAI TTS voices
54
+ { id: 'openai-alloy', name: 'Alloy', language: 'en', provider: 'openai' },
55
+ { id: 'openai-echo', name: 'Echo', language: 'en', provider: 'openai', gender: 'male' },
56
+ { id: 'openai-fable', name: 'Fable', language: 'en', provider: 'openai', gender: 'male' },
57
+ { id: 'openai-onyx', name: 'Onyx', language: 'en', provider: 'openai', gender: 'male' },
58
+ { id: 'openai-nova', name: 'Nova', language: 'en', provider: 'openai', gender: 'female' },
59
+ { id: 'openai-shimmer', name: 'Shimmer', language: 'en', provider: 'openai', gender: 'female' },
60
+ ]
61
+
62
+ /**
63
+ * Get all voices that match a given language code.
64
+ * Matches both exact codes and prefix codes (e.g., 'en' matches 'en', 'en-US', 'en-GB').
65
+ */
66
+ export function getVoicesForLanguage(langCode: string): VoiceOption[] {
67
+ return VOICES.filter((v) => v.language === langCode || v.language.startsWith(langCode + '-') || langCode.startsWith(v.language + '-') || v.language.split('-')[0] === langCode)
68
+ }
69
+
70
+ /**
71
+ * Get the best voice for a language, optionally preferring a specific provider.
72
+ * Returns undefined if no voice matches.
73
+ */
74
+ export function getBestVoice(langCode: string, preferredProvider?: VoiceOption['provider']): VoiceOption | undefined {
75
+ const voices = getVoicesForLanguage(langCode)
76
+ if (voices.length === 0) return undefined
77
+
78
+ if (preferredProvider) {
79
+ const preferred = voices.find((v) => v.provider === preferredProvider)
80
+ if (preferred) return preferred
81
+ }
82
+
83
+ // Return first available voice
84
+ return voices[0]
85
+ }
86
+
87
+ /**
88
+ * Get all voices from a specific provider.
89
+ */
90
+ export function getVoicesByProvider(provider: VoiceOption['provider']): VoiceOption[] {
91
+ return VOICES.filter((v) => v.provider === provider)
92
+ }
package/dist/index.ts CHANGED
@@ -64,6 +64,7 @@ export * from './astro-link-card/index.ts';
64
64
  export * from './astro-live-captions/index.ts';
65
65
  export * from './astro-live-cursors/index.ts';
66
66
  export * from './astro-live-transcript/index.ts';
67
+ export * from './astro-location-selector/index.ts';
67
68
  export * from './astro-logger/index.ts';
68
69
  export * from './astro-markdown-renderer/index.ts';
69
70
  export * from './astro-marquee-strip/index.ts';
@@ -0,0 +1 @@
1
+ export * from './location-selector.ts'
@@ -0,0 +1,60 @@
1
+ import { COUNTRIES, LANGUAGES, detectCountry, detectLanguage } from '../i18n/index.ts'
2
+ import { generateId } from '../shared/index.ts'
3
+
4
+ export interface LocationSelectorState {
5
+ country: string
6
+ language: string
7
+ }
8
+
9
+ export interface LocationSelectorOptions {
10
+ defaultCountry?: string
11
+ defaultLanguage?: string
12
+ onCountryChange?: (country: string) => void
13
+ onLanguageChange?: (language: string) => void
14
+ }
15
+
16
+ export function createLocationSelector(options: LocationSelectorOptions = {}) {
17
+ const {
18
+ defaultCountry = 'US',
19
+ defaultLanguage = 'en',
20
+ onCountryChange,
21
+ onLanguageChange,
22
+ } = options
23
+
24
+ let state: LocationSelectorState = {
25
+ country: defaultCountry,
26
+ language: defaultLanguage,
27
+ }
28
+
29
+ const countryId = generateId('location-sel-country')
30
+ const languageId = generateId('location-sel-language')
31
+
32
+ function setCountry(country: string) {
33
+ state.country = country
34
+ onCountryChange?.(country)
35
+ }
36
+
37
+ function setLanguage(language: string) {
38
+ state.language = language
39
+ onLanguageChange?.(language)
40
+ }
41
+
42
+ const countries = Array.isArray(COUNTRIES) ? COUNTRIES : Object.values(COUNTRIES)
43
+ const languages = Array.isArray(LANGUAGES) ? LANGUAGES : Object.values(LANGUAGES)
44
+
45
+ return {
46
+ state,
47
+ setCountry,
48
+ setLanguage,
49
+ countries,
50
+ languages,
51
+ countryProps: {
52
+ id: countryId,
53
+ name: 'country',
54
+ },
55
+ languageProps: {
56
+ id: languageId,
57
+ name: 'language',
58
+ },
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refraction-ui/astro",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "All Refraction UI Astro components in one package",
5
5
  "type": "module",
6
6
  "exports": {
@@ -84,6 +84,7 @@
84
84
  "@refraction-ui/astro-live-captions": "workspace:*",
85
85
  "@refraction-ui/astro-live-cursors": "workspace:*",
86
86
  "@refraction-ui/astro-live-transcript": "workspace:*",
87
+ "@refraction-ui/astro-location-selector": "workspace:*",
87
88
  "@refraction-ui/astro-logger": "workspace:*",
88
89
  "@refraction-ui/astro-markdown-renderer": "workspace:*",
89
90
  "@refraction-ui/astro-marquee-strip": "workspace:*",