@countriesdb/widget 0.1.23 → 0.1.24

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/README.md CHANGED
@@ -89,7 +89,6 @@ CountriesWidgetLoad({
89
89
  ### Options
90
90
 
91
91
  - `publicKey` (required): Your CountriesDB public API key. [Get your API key](https://countriesdb.com) by creating an account.
92
- - `backendUrl` (optional): Backend API URL (defaults to script origin or `https://api.countriesdb.com`)
93
92
  - `defaultLanguage` (optional): Default language for country/subdivision names
94
93
  - `forcedLanguage` (optional): Force a specific language
95
94
  - `showSubdivisionType` (optional): Show subdivision type names (default: `true`)
@@ -99,6 +98,7 @@ CountriesWidgetLoad({
99
98
  - `isoCountryNames` (optional): Use ISO country names (default: `false`)
100
99
  - `subdivisionRomanizationPreference` (optional): Preferred romanization system
101
100
  - `preferLocalVariant` (optional): Prefer local name variants (default: `false`)
101
+ - `preferOfficialSubdivisions` (optional): Prefer official ISO subdivisions (default: `false`)
102
102
  - `countryNameFilter` (optional): Custom function to filter/transform country names
103
103
  - `subdivisionNameFilter` (optional): Custom function to filter/transform subdivision names
104
104
  - `autoInit` (optional): Auto-initialize on load (default: `true`)
@@ -143,6 +143,25 @@ document.addEventListener('countriesWidget:update', (event) => {
143
143
  });
144
144
  ```
145
145
 
146
+ It also emits `countriesWidget:ready` whenever a country or subdivision select
147
+ finishes loading options (initial load and subsequent reloads, like when a country
148
+ changes). Listen once on `document` to react as soon as the widget is fully wired:
149
+
150
+ ```javascript
151
+ document.addEventListener('countriesWidget:ready', (event) => {
152
+ console.log('Select ready:', event.detail);
153
+ // {
154
+ // value: 'US',
155
+ // selectedValues: ['US'],
156
+ // name: 'country1',
157
+ // country: null,
158
+ // isSubdivision: false,
159
+ // type: 'country' | 'subdivision',
160
+ // phase: 'initial' | 'reload'
161
+ // }
162
+ });
163
+ ```
164
+
146
165
  ## Examples
147
166
 
148
167
  ### Basic Usage
@@ -196,7 +215,7 @@ For complete API documentation, visit [countriesdb.com/docs](https://countriesdb
196
215
 
197
216
  ## License
198
217
 
199
- PROPRIETARY - Copyright (c) NAYEE LLC. See [LICENSE](LICENSE) for details.
218
+ PROPRIETARY - Copyright (c) NAYEE LLC. See [LICENSE](https://github.com/countriesdb/countriesdb/blob/main/packages/npm/widget/LICENSE) for details.
200
219
 
201
220
  **Developed by [NAYEE LLC](https://nayee.net)**
202
221
 
@@ -153,8 +153,6 @@ export function handleApiError(select, errorMessage, replace = false) {
153
153
  else {
154
154
  select.innerHTML += `<option value="${defaultValue}" disabled>${formattedMessage}</option>`;
155
155
  }
156
- // Ensure select is enabled so users can see the error
157
- select.disabled = false;
158
156
  }
159
157
  /**
160
158
  * Parse boolean from string value
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Event system for widget updates
3
3
  */
4
- import type { SelectElement, UpdateEventDetail } from './types';
4
+ import type { ReadyEventDetail, SelectElement, UpdateEventDetail } from './types';
5
5
  /**
6
6
  * Dispatch a custom update event for widget changes
7
7
  */
8
8
  export declare function dispatchUpdateEvent(select: SelectElement, detail?: Partial<UpdateEventDetail>): void;
9
+ /**
10
+ * Dispatch a custom ready event once a select has been populated.
11
+ */
12
+ export declare function dispatchReadyEvent(select: SelectElement, detail?: Partial<ReadyEventDetail>): void;
9
13
  /**
10
14
  * Check if an event was initiated by the widget (not user)
11
15
  */
@@ -29,6 +29,34 @@ export function dispatchUpdateEvent(select, detail = {}) {
29
29
  });
30
30
  select.dispatchEvent(evt);
31
31
  }
32
+ /**
33
+ * Dispatch a custom ready event once a select has been populated.
34
+ */
35
+ export function dispatchReadyEvent(select, detail = {}) {
36
+ const selectedValues = select.multiple
37
+ ? Array.from(select.selectedOptions || [])
38
+ .map((opt) => opt.value)
39
+ .filter((v) => v !== '')
40
+ : select.value
41
+ ? [select.value]
42
+ : [];
43
+ const evt = new CustomEvent('countriesWidget:ready', {
44
+ bubbles: true,
45
+ detail: {
46
+ value: select.value || '',
47
+ selectedValues,
48
+ name: select.dataset.name || null,
49
+ country: select.dataset.country || null,
50
+ isSubdivision: select.classList.contains('subdivision-selection'),
51
+ type: select.classList.contains('subdivision-selection')
52
+ ? 'subdivision'
53
+ : 'country',
54
+ phase: 'initial',
55
+ ...detail,
56
+ },
57
+ });
58
+ select.dispatchEvent(evt);
59
+ }
32
60
  /**
33
61
  * Check if an event was initiated by the widget (not user)
34
62
  */
package/dist/index.esm.js CHANGED
@@ -154,8 +154,6 @@ function handleApiError(select, errorMessage, replace = false) {
154
154
  else {
155
155
  select.innerHTML += `<option value="${defaultValue}" disabled>${formattedMessage}</option>`;
156
156
  }
157
- // Ensure select is enabled so users can see the error
158
- select.disabled = false;
159
157
  }
160
158
 
161
159
  /**
@@ -189,6 +187,34 @@ function dispatchUpdateEvent(select, detail = {}) {
189
187
  });
190
188
  select.dispatchEvent(evt);
191
189
  }
190
+ /**
191
+ * Dispatch a custom ready event once a select has been populated.
192
+ */
193
+ function dispatchReadyEvent(select, detail = {}) {
194
+ const selectedValues = select.multiple
195
+ ? Array.from(select.selectedOptions || [])
196
+ .map((opt) => opt.value)
197
+ .filter((v) => v !== '')
198
+ : select.value
199
+ ? [select.value]
200
+ : [];
201
+ const evt = new CustomEvent('countriesWidget:ready', {
202
+ bubbles: true,
203
+ detail: {
204
+ value: select.value || '',
205
+ selectedValues,
206
+ name: select.dataset.name || null,
207
+ country: select.dataset.country || null,
208
+ isSubdivision: select.classList.contains('subdivision-selection'),
209
+ type: select.classList.contains('subdivision-selection')
210
+ ? 'subdivision'
211
+ : 'country',
212
+ phase: 'initial',
213
+ ...detail,
214
+ },
215
+ });
216
+ select.dispatchEvent(evt);
217
+ }
192
218
  /**
193
219
  * Check if an event was initiated by the widget (not user)
194
220
  */
@@ -465,7 +491,8 @@ async function setupSubdivisionSelection(apiKey, backendUrl, state, config) {
465
491
  : null;
466
492
  // Check if linked country select is multi-select (not allowed)
467
493
  if (linkedCountrySelect && linkedCountrySelect.hasAttribute('multiple')) {
468
- handleApiError(select, 'Cannot link to multi-select country. Use data-country-code instead.', true);
494
+ const defaultValue = select.dataset.defaultValue ?? '';
495
+ select.innerHTML = `<option value="${defaultValue}" disabled>Error: Cannot link to multi-select country. Use data-country-code instead.</option>`;
469
496
  continue;
470
497
  }
471
498
  // No direct link → maybe data-country-code
@@ -475,7 +502,8 @@ async function setupSubdivisionSelection(apiKey, backendUrl, state, config) {
475
502
  await updateSubdivisionSelect(select, apiKey, backendUrl, state, config, select.dataset.countryCode);
476
503
  }
477
504
  else {
478
- handleApiError(select, 'No country select present');
505
+ const defaultValue = select.dataset.defaultValue ?? '';
506
+ select.innerHTML += `<option value="${defaultValue}" disabled>Error: No country select present</option>`;
479
507
  }
480
508
  }
481
509
  // Always dispatch an update event for user-initiated subdivision changes
@@ -521,8 +549,10 @@ async function updateSubdivisionSelect(select, apiKey, backendUrl, state, config
521
549
  // Use GeoIP only if data-preselected attribute is not set at all
522
550
  const shouldUseGeoIP = preselectedValue === undefined || preselectedValue === null;
523
551
  // Check if this subdivision select prefers official subdivisions
524
- const preferOfficial = select.hasAttribute('data-prefer-official') ||
525
- config.preferOfficialSubdivisions;
552
+ // Use data attribute if present, otherwise use config
553
+ const preferOfficial = select.hasAttribute('data-prefer-official')
554
+ ? true
555
+ : config.preferOfficialSubdivisions;
526
556
  const languageHeaders = CountriesDBClient.getLanguageHeaders(config.forcedLanguage, config.defaultLanguage);
527
557
  const subdivisionsResult = await CountriesDBClient.fetchSubdivisions({
528
558
  apiKey,
@@ -608,6 +638,10 @@ async function updateSubdivisionSelect(select, apiKey, backendUrl, state, config
608
638
  finally {
609
639
  // Mark initialization as complete
610
640
  state.isInitializing.delete(select);
641
+ dispatchReadyEvent(select, {
642
+ type: 'subdivision',
643
+ phase: isReload ? 'reload' : 'initial',
644
+ });
611
645
  // Only fire 'reload' if this is a reload, not initial load
612
646
  if (isReload && !valueSetByWidget) {
613
647
  dispatchUpdateEvent(select, {
@@ -619,7 +653,8 @@ async function updateSubdivisionSelect(select, apiKey, backendUrl, state, config
619
653
  }
620
654
  else if (!select.dataset.country ||
621
655
  !document.querySelector(`.country-selection[data-name="${select.dataset.country}"]`)) {
622
- handleApiError(select, 'No country select present');
656
+ const defaultValue = select.dataset.defaultValue ?? '';
657
+ select.innerHTML += `<option value="${defaultValue}" disabled>Error: No country select present</option>`;
623
658
  }
624
659
  }
625
660
  /**
@@ -648,7 +683,8 @@ async function setupCountrySelection(apiKey, backendUrl, state, config, subdivis
648
683
  if (name && seenNames[name]) {
649
684
  select.removeAttribute('data-name');
650
685
  initializeSelect(select, '&mdash;');
651
- handleApiError(select, 'Duplicate field');
686
+ const defaultValue = select.dataset.defaultValue ?? '';
687
+ select.innerHTML += `<option value="${defaultValue}" disabled>Error: Duplicate field</option>`;
652
688
  continue;
653
689
  }
654
690
  if (name) {
@@ -749,6 +785,10 @@ async function setupCountrySelection(apiKey, backendUrl, state, config, subdivis
749
785
  finally {
750
786
  // Mark initialization as complete
751
787
  state.isInitializing.delete(select);
788
+ dispatchReadyEvent(select, {
789
+ type: 'country',
790
+ phase: 'initial',
791
+ });
752
792
  // If no preselected and no geoip selection happened, emit a regular update
753
793
  if (!valueSetByWidget) {
754
794
  dispatchUpdateEvent(select, { type: 'country', reason: 'regular' });
@@ -855,29 +895,23 @@ async function CountriesWidgetLoad(options = {}) {
855
895
  * Get configuration from options or script URL parameters
856
896
  */
857
897
  function getConfigFromOptionsOrScript(options) {
858
- // Check for global config first (for bundled widgets that need config before auto-init)
859
- const globalConfig = typeof window !== 'undefined' && window.CountriesDBConfig
860
- ? window.CountriesDBConfig
861
- : null;
862
898
  // Try to get config from script URL (for backward compatibility with widget.blade.php)
863
899
  let scriptUrl = null;
864
900
  try {
865
901
  let loaderScript = null;
866
902
  // First try document.currentScript (works during script execution)
867
- // But only if it matches the widget pattern (not a bundled file)
868
903
  if (document.currentScript && document.currentScript instanceof HTMLScriptElement) {
869
- const src = document.currentScript.src;
870
- if (src && (src.includes('@countriesdb/widget') ||
871
- src.includes('widget/dist/index.js'))) {
872
- loaderScript = document.currentScript;
873
- }
904
+ loaderScript = document.currentScript;
874
905
  }
875
- // If currentScript didn't match, search for widget script
876
- if (!loaderScript) {
877
- // Only consider script tags that loaded the widget bundle
906
+ else {
907
+ // Fallback: find script tag with @countriesdb/widget in src
878
908
  const scripts = Array.from(document.getElementsByTagName('script'));
879
909
  loaderScript = scripts.find((s) => s.src && (s.src.includes('@countriesdb/widget') ||
880
910
  s.src.includes('widget/dist/index.js'))) || null;
911
+ // If still not found, try the last script with src
912
+ if (!loaderScript) {
913
+ loaderScript = scripts.filter((s) => s.src).pop() || null;
914
+ }
881
915
  }
882
916
  if (loaderScript && loaderScript.src) {
883
917
  scriptUrl = new URL(loaderScript.src);
@@ -887,57 +921,39 @@ function getConfigFromOptionsOrScript(options) {
887
921
  // Ignore errors
888
922
  }
889
923
  const config = {
890
- // Priority: options > globalConfig > scriptUrl params > defaults
891
- publicKey: options.publicKey ?? globalConfig?.publicKey ?? scriptUrl?.searchParams.get('public_key') ?? '',
892
- backendUrl: options.backendUrl ?? globalConfig?.backendUrl ?? scriptUrl?.searchParams.get('backend_url') ?? getBackendUrlFromScript(),
893
- defaultLanguage: options.defaultLanguage ?? globalConfig?.defaultLanguage ?? scriptUrl?.searchParams.get('default_language') ?? undefined,
894
- forcedLanguage: options.forcedLanguage ?? globalConfig?.forcedLanguage ?? scriptUrl?.searchParams.get('forced_language') ?? undefined,
924
+ publicKey: options.publicKey || scriptUrl?.searchParams.get('public_key') || '',
925
+ backendUrl: options.backendUrl || scriptUrl?.searchParams.get('backend_url') || getBackendUrlFromScript(),
926
+ defaultLanguage: options.defaultLanguage || scriptUrl?.searchParams.get('default_language') || undefined,
927
+ forcedLanguage: options.forcedLanguage || scriptUrl?.searchParams.get('forced_language') || undefined,
895
928
  showSubdivisionType: options.showSubdivisionType !== undefined
896
929
  ? options.showSubdivisionType
897
- : globalConfig?.showSubdivisionType !== undefined
898
- ? globalConfig.showSubdivisionType
899
- : parseBoolean(scriptUrl?.searchParams.get('show_subdivision_type') ?? '1'),
930
+ : parseBoolean(scriptUrl?.searchParams.get('show_subdivision_type') ?? '1'),
900
931
  followRelated: options.followRelated !== undefined
901
932
  ? options.followRelated
902
- : globalConfig?.followRelated !== undefined
903
- ? globalConfig.followRelated
904
- : parseBoolean(scriptUrl?.searchParams.get('follow_related') ?? 'false'),
933
+ : parseBoolean(scriptUrl?.searchParams.get('follow_related') ?? 'false'),
905
934
  followUpward: options.followUpward !== undefined
906
935
  ? options.followUpward
907
- : globalConfig?.followUpward !== undefined
908
- ? globalConfig.followUpward
909
- : parseBoolean(scriptUrl?.searchParams.get('follow_upward') ?? 'false'),
936
+ : parseBoolean(scriptUrl?.searchParams.get('follow_upward') ?? 'false'),
910
937
  allowParentSelection: options.allowParentSelection !== undefined
911
938
  ? options.allowParentSelection
912
- : globalConfig?.allowParentSelection !== undefined
913
- ? globalConfig.allowParentSelection
914
- : parseBoolean(scriptUrl?.searchParams.get('allow_parent_selection') ?? 'false'),
939
+ : parseBoolean(scriptUrl?.searchParams.get('allow_parent_selection') ?? 'false'),
915
940
  isoCountryNames: options.isoCountryNames !== undefined
916
941
  ? options.isoCountryNames
917
- : globalConfig?.isoCountryNames !== undefined
918
- ? globalConfig.isoCountryNames
919
- : parseBoolean(scriptUrl?.searchParams.get('iso_country_names') ?? 'false'),
920
- preferOfficialSubdivisions: options.preferOfficialSubdivisions !== undefined
921
- ? options.preferOfficialSubdivisions
922
- : globalConfig?.preferOfficialSubdivisions !== undefined
923
- ? globalConfig.preferOfficialSubdivisions
924
- : parseBoolean(scriptUrl?.searchParams.get('prefer_official') ?? 'false'),
942
+ : parseBoolean(scriptUrl?.searchParams.get('iso_country_names') ?? 'false'),
925
943
  subdivisionRomanizationPreference: options.subdivisionRomanizationPreference ||
926
- globalConfig?.subdivisionRomanizationPreference ||
927
944
  scriptUrl?.searchParams.get('subdivision_romanization_preference') ||
928
945
  undefined,
929
946
  preferLocalVariant: options.preferLocalVariant !== undefined
930
947
  ? options.preferLocalVariant
931
- : globalConfig?.preferLocalVariant !== undefined
932
- ? globalConfig.preferLocalVariant
933
- : parseBoolean(scriptUrl?.searchParams.get('prefer_local_variant') ?? 'false'),
934
- countryNameFilter: options.countryNameFilter ?? globalConfig?.countryNameFilter,
935
- subdivisionNameFilter: options.subdivisionNameFilter ?? globalConfig?.subdivisionNameFilter,
948
+ : parseBoolean(scriptUrl?.searchParams.get('prefer_local_variant') ?? 'false'),
949
+ preferOfficialSubdivisions: options.preferOfficialSubdivisions !== undefined
950
+ ? options.preferOfficialSubdivisions
951
+ : parseBoolean(scriptUrl?.searchParams.get('prefer_official') ?? 'false'),
952
+ countryNameFilter: options.countryNameFilter,
953
+ subdivisionNameFilter: options.subdivisionNameFilter,
936
954
  autoInit: options.autoInit !== undefined
937
955
  ? options.autoInit
938
- : globalConfig?.autoInit !== undefined
939
- ? globalConfig.autoInit
940
- : parseBoolean(scriptUrl?.searchParams.get('auto_init') ?? 'true'),
956
+ : parseBoolean(scriptUrl?.searchParams.get('auto_init') ?? 'true'),
941
957
  };
942
958
  // Resolve filter functions from global scope if specified by name
943
959
  if (scriptUrl) {
@@ -965,20 +981,18 @@ function getBackendUrlFromScript() {
965
981
  try {
966
982
  let loaderScript = null;
967
983
  // First try document.currentScript (works during script execution)
968
- // But only if it matches the widget pattern (not a bundled file)
969
984
  if (document.currentScript && document.currentScript instanceof HTMLScriptElement) {
970
- const src = document.currentScript.src;
971
- if (src && (src.includes('@countriesdb/widget') ||
972
- src.includes('widget/dist/index.js'))) {
973
- loaderScript = document.currentScript;
974
- }
985
+ loaderScript = document.currentScript;
975
986
  }
976
- // If currentScript didn't match, search for widget script
977
- if (!loaderScript) {
978
- // Only consider script tags that loaded the widget bundle
987
+ else {
988
+ // Fallback: find script tag with @countriesdb/widget in src
979
989
  const scripts = Array.from(document.getElementsByTagName('script'));
980
990
  loaderScript = scripts.find((s) => s.src && (s.src.includes('@countriesdb/widget') ||
981
991
  s.src.includes('widget/dist/index.js'))) || null;
992
+ // If still not found, try the last script with src
993
+ if (!loaderScript) {
994
+ loaderScript = scripts.filter((s) => s.src).pop() || null;
995
+ }
982
996
  }
983
997
  if (loaderScript && loaderScript.src) {
984
998
  const scriptUrl = new URL(loaderScript.src);
@@ -1037,26 +1051,19 @@ function parseBoolean(value) {
1037
1051
  * Show error when both follow_related and follow_upward are enabled
1038
1052
  */
1039
1053
  function showParamConflictError() {
1040
- const errorMessage = 'Cannot enable both follow_related and follow_upward';
1054
+ const errorMessage = 'Error: Cannot enable both follow_related and follow_upward';
1041
1055
  const countrySelects = Array.from(document.querySelectorAll('.country-selection'));
1042
1056
  for (const select of countrySelects) {
1043
- handleApiError(select, errorMessage, true);
1057
+ select.innerHTML = `<option value="${select.dataset.defaultValue ?? ''}" disabled>${errorMessage}</option>`;
1044
1058
  }
1045
1059
  const subdivisionSelects = Array.from(document.querySelectorAll('.subdivision-selection'));
1046
1060
  for (const select of subdivisionSelects) {
1047
- handleApiError(select, errorMessage, true);
1061
+ select.innerHTML = `<option value="${select.dataset.defaultValue ?? ''}" disabled>${errorMessage}</option>`;
1048
1062
  }
1049
1063
  }
1050
1064
  // Expose public loader
1051
1065
  if (typeof window !== 'undefined') {
1052
1066
  window.CountriesWidgetLoad = CountriesWidgetLoad;
1053
- // Dispatch ready event to notify consumers that the widget is available
1054
- // Use setTimeout(0) to ensure it fires after any synchronous code completes
1055
- setTimeout(() => {
1056
- if (typeof window !== 'undefined') {
1057
- window.dispatchEvent(new CustomEvent('countriesWidget:ready'));
1058
- }
1059
- }, 0);
1060
1067
  // Auto-init if script URL has auto_init=true (or not set, default is true)
1061
1068
  // Use a function that waits for DOM to be ready and finds the script tag reliably
1062
1069
  (function checkAutoInit() {
@@ -1068,16 +1075,10 @@ if (typeof window !== 'undefined') {
1068
1075
  // Find the script tag that loaded this widget
1069
1076
  let loaderScript = null;
1070
1077
  // First try document.currentScript (works during script execution)
1071
- // But only if it matches the widget pattern (not a bundled file)
1072
1078
  if (document.currentScript && document.currentScript instanceof HTMLScriptElement) {
1073
- const src = document.currentScript.src;
1074
- if (src && (src.includes('@countriesdb/widget') ||
1075
- src.includes('widget/dist/index.js'))) {
1076
- loaderScript = document.currentScript;
1077
- }
1079
+ loaderScript = document.currentScript;
1078
1080
  }
1079
- // If currentScript didn't match, search for widget script
1080
- if (!loaderScript) {
1081
+ else {
1081
1082
  // Fallback: find script tag with @countriesdb/widget in src
1082
1083
  const scripts = Array.from(document.getElementsByTagName('script'));
1083
1084
  loaderScript = scripts.find((s) => s.src && (s.src.includes('@countriesdb/widget') ||
@@ -1085,13 +1086,7 @@ if (typeof window !== 'undefined') {
1085
1086
  }
1086
1087
  // Default to auto-init = true (only disable if explicitly set to false)
1087
1088
  let shouldAutoInit = true;
1088
- const globalConfig = typeof window !== 'undefined'
1089
- ? window.CountriesDBConfig || null
1090
- : null;
1091
- if (globalConfig && typeof globalConfig.autoInit !== 'undefined') {
1092
- shouldAutoInit = !!globalConfig.autoInit;
1093
- }
1094
- else if (loaderScript && loaderScript.src) {
1089
+ if (loaderScript && loaderScript.src) {
1095
1090
  try {
1096
1091
  const scriptUrl = new URL(loaderScript.src);
1097
1092
  const autoInit = scriptUrl.searchParams.get('auto_init');