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