@countriesdb/widget 0.1.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,863 @@
1
+ import { applyCountryNameFilter, sortCountries, buildSubdivisionTree, parseNestingPrefixes, flattenSubdivisionOptions, CountriesDBClient } from '@countriesdb/widget-core';
2
+
3
+ /**
4
+ * DOM manipulation utilities
5
+ */
6
+ /**
7
+ * Initialize a select element with default option
8
+ */
9
+ function initializeSelect(select, fallbackLabel = 'Not Applicable', isSubdivision = false) {
10
+ const isMultiple = select.hasAttribute('multiple');
11
+ // For multi-select, don't add default option or use data-label/data-default-value
12
+ if (isMultiple) {
13
+ select.innerHTML = '';
14
+ // Remove data-label and data-default-value for multi-select (they're ignored)
15
+ if (select.dataset.label !== undefined) {
16
+ delete select.dataset.label;
17
+ }
18
+ if (select.dataset.defaultValue !== undefined) {
19
+ delete select.dataset.defaultValue;
20
+ }
21
+ }
22
+ else {
23
+ const dataLabel = select.dataset.label;
24
+ const dataDefaultValue = select.dataset.defaultValue;
25
+ const label = dataLabel || fallbackLabel;
26
+ const defaultValue = dataDefaultValue ?? '';
27
+ select.innerHTML = `<option value="${defaultValue}">${label}</option>`;
28
+ if (dataLabel !== undefined) {
29
+ select.dataset.label = dataLabel;
30
+ }
31
+ if (dataDefaultValue !== undefined) {
32
+ select.dataset.defaultValue = dataDefaultValue;
33
+ }
34
+ }
35
+ if (isSubdivision) {
36
+ select.disabled = true;
37
+ }
38
+ }
39
+ /**
40
+ * Populate a select element with countries
41
+ */
42
+ function populateCountrySelect(select, countries, language, countryNameFilter) {
43
+ // Apply filter and get display names
44
+ let filteredCountries = applyCountryNameFilter(countries, countryNameFilter, language);
45
+ // Sort if filter was applied
46
+ if (countryNameFilter) {
47
+ filteredCountries = sortCountries(filteredCountries, language);
48
+ }
49
+ // Clear existing options (except default)
50
+ const defaultOption = select.querySelector('option[value=""]') ||
51
+ (select.dataset.defaultValue ? select.querySelector(`option[value="${select.dataset.defaultValue}"]`) : null);
52
+ const defaultValue = select.dataset.defaultValue ?? '';
53
+ // Keep default option if it exists
54
+ select.innerHTML = defaultOption ? defaultOption.outerHTML : `<option value="${defaultValue}">${select.dataset.label || '&mdash;'}</option>`;
55
+ // Add country options
56
+ filteredCountries.forEach((country) => {
57
+ const option = document.createElement('option');
58
+ option.value = country.iso_alpha_2;
59
+ option.textContent = country._displayName;
60
+ select.appendChild(option);
61
+ });
62
+ }
63
+ /**
64
+ * Populate a select element with subdivisions
65
+ */
66
+ function buildSubdivisionOptionsHTML(subdivisions, select, language, showSubdivisionType, allowParentSelection, subdivisionNameFilter) {
67
+ const tree = buildSubdivisionTree(subdivisions);
68
+ // Parse nesting prefixes from data attributes
69
+ const dataAttributes = {};
70
+ for (let lvl = 1; lvl <= 10; lvl++) {
71
+ const key = `nested${lvl}Prefix`;
72
+ const value = select.dataset[key];
73
+ if (value !== undefined) {
74
+ dataAttributes[`nested${lvl}Prefix`] = value;
75
+ }
76
+ else {
77
+ break;
78
+ }
79
+ }
80
+ const prefixes = parseNestingPrefixes(dataAttributes);
81
+ const isMultiple = select.hasAttribute('multiple');
82
+ const labelKey = showSubdivisionType ? 'full_name' : 'name';
83
+ let html = '';
84
+ // For multi-select, don't add default option
85
+ if (!isMultiple) {
86
+ const defaultLabel = select.dataset.label || '&mdash;';
87
+ const defaultValue = select.dataset.defaultValue ?? '';
88
+ html = `<option value="${defaultValue}">${defaultLabel}</option>`;
89
+ }
90
+ html += flattenSubdivisionOptions(tree, 0, prefixes, labelKey, language, allowParentSelection, subdivisionNameFilter);
91
+ return html;
92
+ }
93
+ /**
94
+ * Apply preselected value to a select element
95
+ */
96
+ function applyPreselectedValue(select, value) {
97
+ if (!value || value.trim() === '') {
98
+ return;
99
+ }
100
+ const isMultiple = select.hasAttribute('multiple');
101
+ if (isMultiple) {
102
+ // For multi-select, parse comma-separated values
103
+ const values = value
104
+ .split(',')
105
+ .map((v) => v.trim())
106
+ .filter((v) => v !== '');
107
+ // Select all matching options
108
+ Array.from(select.options).forEach((option) => {
109
+ option.selected = values.includes(option.value);
110
+ });
111
+ }
112
+ else {
113
+ // Single select: set single value
114
+ select.value = value;
115
+ }
116
+ }
117
+ /**
118
+ * Handle API error by showing error message in select
119
+ */
120
+ function handleApiError(select, errorMessage) {
121
+ const message = errorMessage instanceof Error ? errorMessage.message : errorMessage;
122
+ const defaultValue = select.dataset.defaultValue ?? '';
123
+ select.innerHTML += `<option value="${defaultValue}" disabled>${message}</option>`;
124
+ }
125
+
126
+ /**
127
+ * Event system for widget updates
128
+ */
129
+ /**
130
+ * Dispatch a custom update event for widget changes
131
+ */
132
+ function dispatchUpdateEvent(select, detail = {}) {
133
+ let selectedValues = [];
134
+ if (select.multiple) {
135
+ // For multi-select, selectedOptions is a HTMLCollection, convert to array
136
+ selectedValues = Array.from(select.selectedOptions || [])
137
+ .map((opt) => opt.value)
138
+ .filter((v) => v !== '');
139
+ }
140
+ else {
141
+ // For single-select, use value
142
+ selectedValues = select.value ? [select.value] : [];
143
+ }
144
+ const evt = new CustomEvent('countriesWidget:update', {
145
+ bubbles: true,
146
+ detail: {
147
+ value: select.value || '',
148
+ selectedValues,
149
+ name: select.dataset.name || null,
150
+ country: select.dataset.country || null,
151
+ isSubdivision: select.classList.contains('subdivision-selection'),
152
+ ...detail,
153
+ },
154
+ });
155
+ select.dispatchEvent(evt);
156
+ }
157
+ /**
158
+ * Check if an event was initiated by the widget (not user)
159
+ */
160
+ function isWidgetInitiatedEvent(event) {
161
+ return event.isWidgetInitiated === true;
162
+ }
163
+
164
+ /**
165
+ * Follow logic for related and upward navigation
166
+ */
167
+ /**
168
+ * Trigger follow logic when a subdivision is selected
169
+ */
170
+ async function triggerFollowLogic(select, apiKey, backendUrl, state, followRelated, followUpward, updateSubdivisionSelectFn) {
171
+ {
172
+ return;
173
+ }
174
+ }
175
+ /**
176
+ * Handle follow_related from subdivision change event
177
+ */
178
+ async function handleFollowRelatedFromSubdivision(select, apiKey, backendUrl, state, followRelated, updateSubdivisionSelectFn) {
179
+ if (!followRelated) {
180
+ return;
181
+ }
182
+ const linkedCountrySelect = document.querySelector(`.country-selection[data-name="${select.dataset.country}"]`);
183
+ // Only work if country is single-select (never when country is multi)
184
+ if (!linkedCountrySelect || linkedCountrySelect.multiple) {
185
+ return;
186
+ }
187
+ const allSubs = state.subdivisionsMap.get(select);
188
+ if (!allSubs) {
189
+ return;
190
+ }
191
+ // Get selected code (for multi-select, use first selected value)
192
+ const selectedCode = select.multiple
193
+ ? (select.selectedOptions[0]?.value || null)
194
+ : select.value;
195
+ if (!selectedCode) {
196
+ return;
197
+ }
198
+ const picked = allSubs.find((s) => s.code === selectedCode);
199
+ if (!picked || !picked.related_country_code) {
200
+ return;
201
+ }
202
+ const targetCountry = picked.related_country_code;
203
+ const targetSubdivision = picked.related_subdivision_code;
204
+ const relatedSubsSelect = document.querySelector(`.subdivision-selection[data-country="${linkedCountrySelect.dataset.name}"]`);
205
+ if (relatedSubsSelect && targetSubdivision) {
206
+ relatedSubsSelect.dataset.preselected = targetSubdivision;
207
+ }
208
+ if (linkedCountrySelect.value !== targetCountry) {
209
+ linkedCountrySelect.value = targetCountry;
210
+ dispatchUpdateEvent(linkedCountrySelect, { type: 'country', reason: 'regular' });
211
+ await updateSubdivisionSelectFn(linkedCountrySelect, apiKey, targetCountry);
212
+ }
213
+ else {
214
+ if (relatedSubsSelect && targetSubdivision) {
215
+ await updateSubdivisionSelectFn(relatedSubsSelect, apiKey, targetCountry);
216
+ }
217
+ }
218
+ }
219
+ /**
220
+ * Handle follow_upward from subdivision change event
221
+ */
222
+ async function handleFollowUpwardFromSubdivision(select, apiKey, backendUrl, state, followUpward, updateSubdivisionSelectFn) {
223
+ if (!followUpward) {
224
+ return;
225
+ }
226
+ // Disable for multi-select subdivisions
227
+ if (select.multiple) {
228
+ return;
229
+ }
230
+ const linkedCountrySelect = document.querySelector(`.country-selection[data-name="${select.dataset.country}"]`);
231
+ // Only work if country is single-select (never when country is multi)
232
+ if (!linkedCountrySelect || linkedCountrySelect.multiple) {
233
+ return;
234
+ }
235
+ const allSubs = state.subdivisionsMap.get(select);
236
+ if (!allSubs) {
237
+ return;
238
+ }
239
+ const selectedCode = select.value;
240
+ if (!selectedCode) {
241
+ return;
242
+ }
243
+ const picked = allSubs.find((s) => s.code === selectedCode);
244
+ if (!picked || !picked.is_subdivision_of) {
245
+ return;
246
+ }
247
+ const parentCode = picked.is_subdivision_of.parent_country_code;
248
+ const parentSub = picked.is_subdivision_of.subdivision_code;
249
+ if (parentSub) {
250
+ const relatedSubsSelect = document.querySelector(`.subdivision-selection[data-country="${linkedCountrySelect.dataset.name}"]`);
251
+ if (relatedSubsSelect) {
252
+ relatedSubsSelect.dataset.preselected = parentSub;
253
+ }
254
+ }
255
+ if (linkedCountrySelect.value !== parentCode) {
256
+ linkedCountrySelect.value = parentCode;
257
+ dispatchUpdateEvent(linkedCountrySelect, { type: 'country', reason: 'follow' });
258
+ await updateSubdivisionSelectFn(linkedCountrySelect, apiKey, parentCode);
259
+ }
260
+ else if (parentSub) {
261
+ const relatedSubsSelect = document.querySelector(`.subdivision-selection[data-country="${linkedCountrySelect.dataset.name}"]`);
262
+ if (relatedSubsSelect) {
263
+ await updateSubdivisionSelectFn(relatedSubsSelect, apiKey, parentCode);
264
+ }
265
+ }
266
+ }
267
+ /**
268
+ * Handle follow_upward from country change event
269
+ */
270
+ async function handleFollowUpwardFromCountry(select, apiKey, backendUrl, state, followUpward, updateSubdivisionSelectFn) {
271
+ // Only works when country is single-select (never for multi-select)
272
+ if (!followUpward || select.multiple) {
273
+ return;
274
+ }
275
+ const countries = state.countriesMap.get(select);
276
+ if (!countries) {
277
+ return;
278
+ }
279
+ const chosen = select.value;
280
+ if (!chosen) {
281
+ return;
282
+ }
283
+ const picked = countries.find((c) => c.iso_alpha_2 === chosen);
284
+ if (!picked || !picked.is_subdivision_of) {
285
+ return;
286
+ }
287
+ const parentCode = picked.is_subdivision_of.parent_country_code;
288
+ const parentSub = picked.is_subdivision_of.subdivision_code;
289
+ if (select.value !== parentCode) {
290
+ if (parentSub) {
291
+ const linkedSubdivisionSelects = Array.from(document.querySelectorAll(`.subdivision-selection[data-country="${select.dataset.name}"]`));
292
+ for (const s of linkedSubdivisionSelects) {
293
+ // Only preselect if subdivision is single-select (follow_upward doesn't work with multi)
294
+ if (!s.multiple) {
295
+ s.dataset.preselected = parentSub;
296
+ }
297
+ }
298
+ }
299
+ select.value = parentCode;
300
+ dispatchUpdateEvent(select, { type: 'country', reason: 'regular' });
301
+ await updateSubdivisionSelectFn(select, apiKey, parentCode);
302
+ }
303
+ else if (parentSub) {
304
+ const linkedSubdivisionSelects = Array.from(document.querySelectorAll(`.subdivision-selection[data-country="${select.dataset.name}"]`));
305
+ for (const s of linkedSubdivisionSelects) {
306
+ // Only update if subdivision is single-select
307
+ if (!s.multiple) {
308
+ await updateSubdivisionSelectFn(s, apiKey, parentCode);
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Widget initialization logic
316
+ */
317
+ /**
318
+ * Setup subdivision selection elements
319
+ */
320
+ async function setupSubdivisionSelection(apiKey, backendUrl, state, config) {
321
+ const subdivisionSelects = Array.from(document.querySelectorAll('.subdivision-selection'));
322
+ for (const select of subdivisionSelects) {
323
+ // Initialize with a default option
324
+ initializeSelect(select, '&mdash;', true);
325
+ // Linked country select (if any)
326
+ const countryName = select.dataset.country;
327
+ const linkedCountrySelect = countryName
328
+ ? document.querySelector(`.country-selection[data-name="${countryName}"]`)
329
+ : null;
330
+ // Check if linked country select is multi-select (not allowed)
331
+ if (linkedCountrySelect && linkedCountrySelect.hasAttribute('multiple')) {
332
+ const defaultValue = select.dataset.defaultValue ?? '';
333
+ select.innerHTML = `<option value="${defaultValue}" disabled>Error: Cannot link to multi-select country. Use data-country-code instead.</option>`;
334
+ continue;
335
+ }
336
+ // No direct link → maybe data-country-code
337
+ if (!countryName || !linkedCountrySelect) {
338
+ if (select.hasAttribute('data-country-code') &&
339
+ select.dataset.countryCode) {
340
+ await updateSubdivisionSelect(select, apiKey, backendUrl, state, config, select.dataset.countryCode);
341
+ }
342
+ else {
343
+ const defaultValue = select.dataset.defaultValue ?? '';
344
+ select.innerHTML += `<option value="${defaultValue}" disabled>Error: No country select present</option>`;
345
+ }
346
+ }
347
+ // Always dispatch an update event for user-initiated subdivision changes
348
+ select.addEventListener('change', (event) => {
349
+ if (isWidgetInitiatedEvent(event)) {
350
+ return;
351
+ }
352
+ dispatchUpdateEvent(select, { type: 'subdivision', reason: 'regular' });
353
+ });
354
+ // --- follow_related (forward direction) ---
355
+ select.addEventListener('change', async (event) => {
356
+ if (isWidgetInitiatedEvent(event)) {
357
+ return;
358
+ }
359
+ await handleFollowRelatedFromSubdivision(select, apiKey, backendUrl, state, config.followRelated, (s, key, code) => updateSubdivisionSelect(s, key, backendUrl, state, config, code));
360
+ });
361
+ // --- follow_upward (reverse direction) ---
362
+ select.addEventListener('change', async (event) => {
363
+ if (isWidgetInitiatedEvent(event)) {
364
+ return;
365
+ }
366
+ await handleFollowUpwardFromSubdivision(select, apiKey, backendUrl, state, config.followUpward, (s, key, code) => updateSubdivisionSelect(s, key, backendUrl, state, config, code));
367
+ });
368
+ }
369
+ }
370
+ /**
371
+ * Update subdivision select with data for a specific country
372
+ */
373
+ async function updateSubdivisionSelect(select, apiKey, backendUrl, state, config, countryCode) {
374
+ const preselectedValue = select.getAttribute('data-preselected') || select.dataset.preselected;
375
+ // Check if this is a reload (select was already populated before)
376
+ const isReload = state.subdivisionsMap.has(select);
377
+ initializeSelect(select, '&mdash;', true);
378
+ // Mark as initializing to prevent change events
379
+ state.isInitializing.add(select);
380
+ let effectiveCountryCode = countryCode;
381
+ if (!effectiveCountryCode) {
382
+ effectiveCountryCode = select.dataset.countryCode || '';
383
+ }
384
+ if (effectiveCountryCode) {
385
+ let valueSetByWidget = false; // Track if a value was set by widget
386
+ try {
387
+ // Use GeoIP only if data-preselected attribute is not set at all
388
+ const shouldUseGeoIP = preselectedValue === undefined || preselectedValue === null;
389
+ // Check if this subdivision select prefers official subdivisions
390
+ const preferOfficial = select.hasAttribute('data-prefer-official');
391
+ const languageHeaders = CountriesDBClient.getLanguageHeaders(undefined, undefined);
392
+ const subdivisionsResult = await CountriesDBClient.fetchSubdivisions({
393
+ apiKey,
394
+ backendUrl,
395
+ countryCode: effectiveCountryCode,
396
+ shouldUseGeoIP,
397
+ preferOfficial,
398
+ subdivisionRomanizationPreference: config.subdivisionRomanizationPreference,
399
+ preferLocalVariant: config.preferLocalVariant,
400
+ languageHeaders,
401
+ });
402
+ const subdivisions = subdivisionsResult.data;
403
+ const subdivisionsLanguage = subdivisionsResult.language || 'en';
404
+ // Store in memory
405
+ state.subdivisionsMap.set(select, subdivisions);
406
+ state.subdivisionsLanguageMap.set(select, subdivisionsLanguage);
407
+ if (subdivisions.length > 0) {
408
+ // Populate <option>
409
+ select.disabled = false;
410
+ // Preserve data attributes before setting innerHTML (only for non-multi-select)
411
+ const isMultiple = select.hasAttribute('multiple');
412
+ const dataLabel = !isMultiple ? select.dataset.label : undefined;
413
+ const dataDefaultValue = !isMultiple
414
+ ? select.dataset.defaultValue
415
+ : undefined;
416
+ select.innerHTML = buildSubdivisionOptionsHTML(subdivisions, select, subdivisionsLanguage, config.showSubdivisionType, config.allowParentSelection, config.subdivisionNameFilter);
417
+ // Restore data attributes after setting innerHTML
418
+ if (!isMultiple) {
419
+ if (dataLabel !== undefined) {
420
+ select.dataset.label = dataLabel;
421
+ }
422
+ if (dataDefaultValue !== undefined) {
423
+ select.dataset.defaultValue = dataDefaultValue;
424
+ }
425
+ }
426
+ // Manual preselection wins
427
+ if (preselectedValue !== undefined &&
428
+ preselectedValue !== null &&
429
+ preselectedValue.trim() !== '') {
430
+ applyPreselectedValue(select, preselectedValue);
431
+ dispatchUpdateEvent(select, {
432
+ type: 'subdivision',
433
+ reason: 'preselected',
434
+ });
435
+ valueSetByWidget = true;
436
+ await triggerFollowLogic(select, apiKey, backendUrl, state, false, // followRelated - handled in event listeners
437
+ false, // followUpward - handled in event listeners
438
+ (s, key, code) => updateSubdivisionSelect(s, key, backendUrl, state, config, code));
439
+ }
440
+ else {
441
+ // Try GeoIP preselect
442
+ if (shouldUseGeoIP) {
443
+ const preselectedSubdivision = subdivisions.find((subdivision) => subdivision.preselected);
444
+ if (preselectedSubdivision) {
445
+ const isMultiple = select.hasAttribute('multiple');
446
+ if (isMultiple) {
447
+ // For multi-select, find and select the option
448
+ const option = Array.from(select.options).find((opt) => opt.value === preselectedSubdivision.code);
449
+ if (option) {
450
+ option.selected = true;
451
+ }
452
+ }
453
+ else {
454
+ // Single select: set value directly
455
+ select.value = preselectedSubdivision.code;
456
+ }
457
+ dispatchUpdateEvent(select, {
458
+ type: 'subdivision',
459
+ reason: 'geoip',
460
+ });
461
+ valueSetByWidget = true;
462
+ await triggerFollowLogic(select, apiKey, backendUrl, state, false, false, (s, key, code) => updateSubdivisionSelect(s, key, backendUrl, state, config, code));
463
+ }
464
+ }
465
+ }
466
+ }
467
+ else {
468
+ select.disabled = true;
469
+ }
470
+ }
471
+ catch (error) {
472
+ console.error('Failed to fetch subdivisions:', error);
473
+ handleApiError(select, error);
474
+ }
475
+ finally {
476
+ // Mark initialization as complete
477
+ state.isInitializing.delete(select);
478
+ // Only fire 'reload' if this is a reload, not initial load
479
+ if (isReload && !valueSetByWidget) {
480
+ dispatchUpdateEvent(select, {
481
+ type: 'subdivision',
482
+ reason: 'reload',
483
+ });
484
+ }
485
+ }
486
+ }
487
+ else if (!select.dataset.country ||
488
+ !document.querySelector(`.country-selection[data-name="${select.dataset.country}"]`)) {
489
+ const defaultValue = select.dataset.defaultValue ?? '';
490
+ select.innerHTML += `<option value="${defaultValue}" disabled>Error: No country select present</option>`;
491
+ }
492
+ }
493
+ /**
494
+ * Update subdivisions for all linked subdivision selects when country changes
495
+ */
496
+ async function updateSubdivisions(countrySelect, apiKey, backendUrl, state, config) {
497
+ // Don't update subdivisions for multi-select countries (not supported)
498
+ if (countrySelect.hasAttribute('multiple')) {
499
+ return;
500
+ }
501
+ const selectedCountry = countrySelect.value;
502
+ const linkedSubdivisionSelects = Array.from(document.querySelectorAll(`.subdivision-selection[data-country="${countrySelect.dataset.name}"]`));
503
+ for (const select of linkedSubdivisionSelects) {
504
+ await updateSubdivisionSelect(select, apiKey, backendUrl, state, config, selectedCountry);
505
+ }
506
+ }
507
+ /**
508
+ * Setup country selection elements
509
+ */
510
+ async function setupCountrySelection(apiKey, backendUrl, state, config, subdivisionConfig) {
511
+ const countrySelects = Array.from(document.querySelectorAll('.country-selection'));
512
+ const seenNames = {}; // track data-name to detect duplicates
513
+ for (const select of countrySelects) {
514
+ const name = select.dataset.name;
515
+ // Duplicates
516
+ if (name && seenNames[name]) {
517
+ select.removeAttribute('data-name');
518
+ initializeSelect(select, '&mdash;');
519
+ const defaultValue = select.dataset.defaultValue ?? '';
520
+ select.innerHTML += `<option value="${defaultValue}" disabled>Error: Duplicate field</option>`;
521
+ continue;
522
+ }
523
+ if (name) {
524
+ seenNames[name] = true;
525
+ }
526
+ // Avoid conflict if mis-classed
527
+ if (select.classList.contains('subdivision-selection')) {
528
+ select.classList.remove('subdivision-selection');
529
+ select.classList.add('subdivision-selection-removed');
530
+ }
531
+ // Initialize
532
+ initializeSelect(select, '&mdash;');
533
+ // Mark as initializing to prevent change events
534
+ state.isInitializing.add(select);
535
+ let valueSetByWidget = false; // Track if value was set by widget
536
+ let loadedInitialSubdivisions = false; // Track if subdivisions were loaded
537
+ try {
538
+ const preselectedValue = select.getAttribute('data-preselected') || select.dataset.preselected;
539
+ // Use GeoIP only if data-preselected attribute is not set at all
540
+ const shouldUseGeoIP = preselectedValue === undefined || preselectedValue === null;
541
+ const languageHeaders = CountriesDBClient.getLanguageHeaders(config.forcedLanguage, config.defaultLanguage);
542
+ // Fetch & populate countries
543
+ const countriesResult = await CountriesDBClient.fetchCountries({
544
+ apiKey,
545
+ backendUrl,
546
+ shouldUseGeoIP,
547
+ isoCountryNames: config.isoCountryNames,
548
+ languageHeaders,
549
+ });
550
+ const countries = countriesResult.data;
551
+ const countriesLanguage = countriesResult.language || 'en';
552
+ state.countriesMap.set(select, countries);
553
+ populateCountrySelect(select, countries, countriesLanguage, config.countryNameFilter);
554
+ // Apply preselected (manual)
555
+ if (preselectedValue !== undefined &&
556
+ preselectedValue !== null &&
557
+ preselectedValue.trim() !== '') {
558
+ applyPreselectedValue(select, preselectedValue);
559
+ dispatchUpdateEvent(select, { type: 'country', reason: 'preselected' });
560
+ valueSetByWidget = true;
561
+ // Load subdivisions after applying preselected value
562
+ if (select.value &&
563
+ select.value !== (select.dataset.defaultValue || '')) {
564
+ // This will be handled by the updateSubdivisions call below
565
+ // We need to pass the config for subdivisions
566
+ }
567
+ }
568
+ // GeoIP auto-select
569
+ if (shouldUseGeoIP) {
570
+ const preselectedCountry = countries.find((country) => country.preselected);
571
+ if (preselectedCountry) {
572
+ select.value = preselectedCountry.iso_alpha_2;
573
+ dispatchUpdateEvent(select, { type: 'country', reason: 'geoip' });
574
+ valueSetByWidget = true;
575
+ }
576
+ }
577
+ // If already chosen, load subdivisions
578
+ if (!loadedInitialSubdivisions &&
579
+ select.value &&
580
+ select.value !== (select.dataset.defaultValue || '')) {
581
+ // Subdivisions will be loaded by event handler
582
+ loadedInitialSubdivisions = true;
583
+ }
584
+ // On change => update subdivisions
585
+ select.addEventListener('change', async (event) => {
586
+ if (isWidgetInitiatedEvent(event)) {
587
+ return;
588
+ }
589
+ // Dispatch update event for user-initiated country change
590
+ dispatchUpdateEvent(select, { type: 'country', reason: 'regular' });
591
+ // Update subdivisions
592
+ await updateSubdivisions(select, apiKey, backendUrl, state, subdivisionConfig);
593
+ const chosen = select.value;
594
+ if (!chosen) {
595
+ return;
596
+ }
597
+ const stored = state.countriesMap.get(select) || [];
598
+ const picked = stored.find((c) => c.iso_alpha_2 === chosen);
599
+ if (!picked) {
600
+ return;
601
+ }
602
+ // followUpward from country perspective
603
+ // Only works when country is single-select (never for multi-select)
604
+ if (config.followUpward && !select.multiple && picked.is_subdivision_of) {
605
+ await handleFollowUpwardFromCountry(select, apiKey, backendUrl, state, config.followUpward, (s, key, code) => updateSubdivisionSelect(s, key, backendUrl, state, subdivisionConfig, code));
606
+ }
607
+ });
608
+ }
609
+ catch (error) {
610
+ console.error('Failed to fetch countries:', error);
611
+ handleApiError(select, error);
612
+ // Handle subdivision errors
613
+ if (select.dataset.name) {
614
+ const linkedSubdivisionSelects = Array.from(document.querySelectorAll(`.subdivision-selection[data-country="${select.dataset.name}"]`));
615
+ for (const s of linkedSubdivisionSelects) {
616
+ initializeSelect(s, '&mdash;');
617
+ handleApiError(s, error);
618
+ }
619
+ }
620
+ }
621
+ finally {
622
+ // Mark initialization as complete
623
+ state.isInitializing.delete(select);
624
+ // If no preselected and no geoip selection happened, emit a regular update
625
+ if (!valueSetByWidget) {
626
+ dispatchUpdateEvent(select, { type: 'country', reason: 'regular' });
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ /**
633
+ * @countriesdb/widget
634
+ *
635
+ * Plain JavaScript widget for CountriesDB.
636
+ * Provides DOM manipulation and auto-initialization for country/subdivision selects.
637
+ */
638
+ // Global namespace to prevent double initialization
639
+ const NS_KEY = '__CountriesWidgetNS__';
640
+ /**
641
+ * Main widget initialization function
642
+ */
643
+ async function CountriesWidgetLoad(options = {}) {
644
+ // Initialize namespace
645
+ if (!window[NS_KEY]) {
646
+ window[NS_KEY] = {
647
+ initialized: false,
648
+ initPromise: null,
649
+ version: 0,
650
+ };
651
+ }
652
+ const NS = window[NS_KEY];
653
+ // Share the same promise across concurrent calls
654
+ NS.initPromise = (async () => {
655
+ // Wait for DOM if needed
656
+ if (document.readyState === 'loading') {
657
+ await new Promise((resolve) => {
658
+ document.addEventListener('DOMContentLoaded', () => resolve(), {
659
+ once: true,
660
+ });
661
+ });
662
+ }
663
+ // Get configuration from options or script URL
664
+ const config = getConfigFromOptionsOrScript(options);
665
+ // Validate configuration
666
+ if (!config.publicKey) {
667
+ console.error('CountriesDB Widget: publicKey is required');
668
+ return false;
669
+ }
670
+ // Check for conflicting parameters
671
+ if (config.followRelated && config.followUpward) {
672
+ showParamConflictError();
673
+ return false;
674
+ }
675
+ // Initialize widget state
676
+ const state = {
677
+ countriesMap: new WeakMap(),
678
+ subdivisionsMap: new WeakMap(),
679
+ subdivisionsLanguageMap: new WeakMap(),
680
+ isInitializing: new Set(),
681
+ };
682
+ // Setup subdivisions first (they depend on countries)
683
+ await setupSubdivisionSelection(config.publicKey, config.backendUrl, state, {
684
+ followRelated: config.followRelated || false,
685
+ followUpward: config.followUpward || false,
686
+ showSubdivisionType: config.showSubdivisionType !== false,
687
+ allowParentSelection: config.allowParentSelection || false,
688
+ subdivisionRomanizationPreference: config.subdivisionRomanizationPreference,
689
+ preferLocalVariant: config.preferLocalVariant || false,
690
+ subdivisionNameFilter: config.subdivisionNameFilter,
691
+ });
692
+ const subdivisionConfig = {
693
+ showSubdivisionType: config.showSubdivisionType !== false,
694
+ allowParentSelection: config.allowParentSelection || false,
695
+ subdivisionRomanizationPreference: config.subdivisionRomanizationPreference,
696
+ preferLocalVariant: config.preferLocalVariant || false,
697
+ subdivisionNameFilter: config.subdivisionNameFilter,
698
+ };
699
+ // Setup countries
700
+ await setupCountrySelection(config.publicKey, config.backendUrl, state, {
701
+ defaultLanguage: config.defaultLanguage,
702
+ forcedLanguage: config.forcedLanguage,
703
+ isoCountryNames: config.isoCountryNames || false,
704
+ followRelated: config.followRelated || false,
705
+ followUpward: config.followUpward || false,
706
+ countryNameFilter: config.countryNameFilter,
707
+ }, subdivisionConfig);
708
+ // After countries are set up, update subdivisions for any preselected countries
709
+ const countrySelects = Array.from(document.querySelectorAll('.country-selection'));
710
+ for (const select of countrySelects) {
711
+ if (select.value &&
712
+ select.value !== (select.dataset.defaultValue || '')) {
713
+ await updateSubdivisions(select, config.publicKey, config.backendUrl, state, subdivisionConfig);
714
+ }
715
+ }
716
+ NS.initialized = true;
717
+ return true;
718
+ })();
719
+ return NS.initPromise;
720
+ }
721
+ /**
722
+ * Get configuration from options or script URL parameters
723
+ */
724
+ function getConfigFromOptionsOrScript(options) {
725
+ // Try to get config from script URL (for backward compatibility with widget.blade.php)
726
+ let scriptUrl = null;
727
+ try {
728
+ const loaderScript = document.currentScript ||
729
+ Array.from(document.getElementsByTagName('script'))
730
+ .filter((s) => s.src)
731
+ .pop();
732
+ if (loaderScript && loaderScript.src) {
733
+ scriptUrl = new URL(loaderScript.src);
734
+ }
735
+ }
736
+ catch {
737
+ // Ignore errors
738
+ }
739
+ const config = {
740
+ publicKey: options.publicKey || scriptUrl?.searchParams.get('public_key') || '',
741
+ backendUrl: options.backendUrl || scriptUrl?.searchParams.get('backend_url') || getBackendUrlFromScript(),
742
+ defaultLanguage: options.defaultLanguage || scriptUrl?.searchParams.get('default_language') || undefined,
743
+ forcedLanguage: options.forcedLanguage || scriptUrl?.searchParams.get('forced_language') || undefined,
744
+ showSubdivisionType: options.showSubdivisionType !== undefined
745
+ ? options.showSubdivisionType
746
+ : parseBoolean(scriptUrl?.searchParams.get('show_subdivision_type') ?? '1'),
747
+ followRelated: options.followRelated !== undefined
748
+ ? options.followRelated
749
+ : parseBoolean(scriptUrl?.searchParams.get('follow_related') ?? 'false'),
750
+ followUpward: options.followUpward !== undefined
751
+ ? options.followUpward
752
+ : parseBoolean(scriptUrl?.searchParams.get('follow_upward') ?? 'false'),
753
+ allowParentSelection: options.allowParentSelection !== undefined
754
+ ? options.allowParentSelection
755
+ : parseBoolean(scriptUrl?.searchParams.get('allow_parent_selection') ?? 'false'),
756
+ isoCountryNames: options.isoCountryNames !== undefined
757
+ ? options.isoCountryNames
758
+ : parseBoolean(scriptUrl?.searchParams.get('iso_country_names') ?? 'false'),
759
+ subdivisionRomanizationPreference: options.subdivisionRomanizationPreference ||
760
+ scriptUrl?.searchParams.get('subdivision_romanization_preference') ||
761
+ undefined,
762
+ preferLocalVariant: options.preferLocalVariant !== undefined
763
+ ? options.preferLocalVariant
764
+ : parseBoolean(scriptUrl?.searchParams.get('prefer_local_variant') ?? 'false'),
765
+ countryNameFilter: options.countryNameFilter,
766
+ subdivisionNameFilter: options.subdivisionNameFilter,
767
+ autoInit: options.autoInit !== undefined
768
+ ? options.autoInit
769
+ : parseBoolean(scriptUrl?.searchParams.get('auto_init') ?? 'true'),
770
+ };
771
+ // Resolve filter functions from global scope if specified by name
772
+ if (scriptUrl) {
773
+ const countryNameFilterName = scriptUrl.searchParams.get('countryNameFilter');
774
+ if (countryNameFilterName && typeof window !== 'undefined') {
775
+ const filter = window[countryNameFilterName];
776
+ if (typeof filter === 'function') {
777
+ config.countryNameFilter = filter;
778
+ }
779
+ }
780
+ const subdivisionNameFilterName = scriptUrl.searchParams.get('subdivisionNameFilter');
781
+ if (subdivisionNameFilterName && typeof window !== 'undefined') {
782
+ const filter = window[subdivisionNameFilterName];
783
+ if (typeof filter === 'function') {
784
+ config.subdivisionNameFilter = filter;
785
+ }
786
+ }
787
+ }
788
+ return config;
789
+ }
790
+ /**
791
+ * Get backend URL from script tag or use default
792
+ */
793
+ function getBackendUrlFromScript() {
794
+ try {
795
+ const loaderScript = document.currentScript ||
796
+ Array.from(document.getElementsByTagName('script'))
797
+ .filter((s) => s.src)
798
+ .pop();
799
+ if (loaderScript && loaderScript.src) {
800
+ const scriptUrl = new URL(loaderScript.src);
801
+ const scheme = scriptUrl.protocol;
802
+ const host = scriptUrl.hostname;
803
+ const port = scriptUrl.port ? `:${scriptUrl.port}` : '';
804
+ return `${scheme}//${host}${port}`;
805
+ }
806
+ }
807
+ catch {
808
+ // Ignore errors
809
+ }
810
+ // Default to API domain (never use embedding site's domain)
811
+ return 'https://api.countriesdb.com';
812
+ }
813
+ /**
814
+ * Parse boolean from string value
815
+ */
816
+ function parseBoolean(value) {
817
+ if (value === null || value === undefined) {
818
+ return false;
819
+ }
820
+ const lowered = String(value).trim().toLowerCase();
821
+ return !(lowered === '0' || lowered === 'false');
822
+ }
823
+ /**
824
+ * Show error when both follow_related and follow_upward are enabled
825
+ */
826
+ function showParamConflictError() {
827
+ const errorMessage = 'Error: Cannot enable both follow_related and follow_upward';
828
+ const countrySelects = Array.from(document.querySelectorAll('.country-selection'));
829
+ for (const select of countrySelects) {
830
+ select.innerHTML = `<option value="${select.dataset.defaultValue ?? ''}" disabled>${errorMessage}</option>`;
831
+ }
832
+ const subdivisionSelects = Array.from(document.querySelectorAll('.subdivision-selection'));
833
+ for (const select of subdivisionSelects) {
834
+ select.innerHTML = `<option value="${select.dataset.defaultValue ?? ''}" disabled>${errorMessage}</option>`;
835
+ }
836
+ }
837
+ // Expose public loader
838
+ if (typeof window !== 'undefined') {
839
+ window.CountriesWidgetLoad = CountriesWidgetLoad;
840
+ // Auto-init if script URL has auto_init=true (or not set, default is true)
841
+ const loaderScript = document.currentScript ||
842
+ Array.from(document.getElementsByTagName('script'))
843
+ .filter((s) => s.src)
844
+ .pop();
845
+ if (loaderScript && loaderScript.src) {
846
+ try {
847
+ const scriptUrl = new URL(loaderScript.src);
848
+ const autoInit = scriptUrl.searchParams.get('auto_init');
849
+ const shouldAutoInit = autoInit === null || autoInit === 'true' || autoInit === '1';
850
+ if (shouldAutoInit) {
851
+ Promise.resolve().then(() => {
852
+ CountriesWidgetLoad().catch(console.error);
853
+ });
854
+ }
855
+ }
856
+ catch {
857
+ // Ignore errors, don't auto-init
858
+ }
859
+ }
860
+ }
861
+
862
+ export { CountriesWidgetLoad as default };
863
+ //# sourceMappingURL=index.esm.js.map