country_state_select 4.0.0 → 4.0.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,150 @@
1
+ import { test, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { JSDOM } from 'jsdom';
4
+
5
+ const dom = new JSDOM('<!doctype html><html><body></body></html>');
6
+ globalThis.window = dom.window;
7
+ globalThis.document = dom.window.document;
8
+
9
+ const { default: CountryStateSelect } = await import('../../app/javascript/country_state_select/country_state_select.js');
10
+
11
+ let requests;
12
+ let instances = [];
13
+
14
+ function stubFetch() {
15
+ requests = [];
16
+ globalThis.fetch = (url) => new Promise((resolve) => {
17
+ requests.push({
18
+ url,
19
+ respond: (data) => resolve({ ok: true, json: () => Promise.resolve(data) })
20
+ });
21
+ });
22
+ }
23
+
24
+ function requestFor(path, param) {
25
+ const found = requests.find((r) => r.url.startsWith(`${path}?`) && r.url.includes(param));
26
+ assert.ok(found, `expected a request to ${path} with ${param}`);
27
+ return found;
28
+ }
29
+
30
+ function settle() {
31
+ return new Promise((resolve) => setTimeout(resolve, 0));
32
+ }
33
+
34
+ function changeCountry(value) {
35
+ const country = document.getElementById('country');
36
+ country.value = value;
37
+ country.dispatchEvent(new window.Event('change', { bubbles: true }));
38
+ }
39
+
40
+ function setup(options = {}) {
41
+ const instance = CountryStateSelect({ country_id: 'country', state_id: 'state', city_id: 'city', announce: false, ...options });
42
+ instances.push(instance);
43
+ return instance;
44
+ }
45
+
46
+ beforeEach(() => {
47
+ document.body.innerHTML = `
48
+ <select id="country" name="address[country]">
49
+ <option value=""></option><option value="US">United States</option><option value="IN">India</option>
50
+ </select>
51
+ <select id="state" name="address[state]" class="form-select" required aria-describedby="state-help"
52
+ data-country-state-select-target="state"></select>
53
+ <select id="city" name="address[city]" data-country-state-select-target="city"></select>`;
54
+ stubFetch();
55
+ });
56
+
57
+ afterEach(() => {
58
+ instances.forEach((instance) => instance.destroy());
59
+ instances = [];
60
+ });
61
+
62
+ test('keeps the state field attributes when rebuilding it', async () => {
63
+ setup();
64
+
65
+ changeCountry('US');
66
+ requestFor('/find_states', 'country_id=US').respond([['CA', 'California']]);
67
+ await settle();
68
+
69
+ const state = document.getElementById('state');
70
+ assert.equal(state.tagName, 'SELECT');
71
+ assert.equal(state.name, 'address[state]');
72
+ assert.equal(state.className, 'form-select');
73
+ assert.equal(state.required, true);
74
+ assert.equal(state.getAttribute('aria-describedby'), 'state-help');
75
+ assert.equal(state.getAttribute('data-country-state-select-target'), 'state');
76
+ });
77
+
78
+ test('keeps the attributes when a field becomes a text input', async () => {
79
+ setup();
80
+
81
+ changeCountry('US');
82
+ requestFor('/find_states', 'country_id=US').respond([]);
83
+ await settle();
84
+
85
+ const state = document.getElementById('state');
86
+ assert.equal(state.tagName, 'INPUT');
87
+ assert.equal(state.type, 'text');
88
+ assert.equal(state.required, true);
89
+ assert.equal(state.getAttribute('data-country-state-select-target'), 'state');
90
+ });
91
+
92
+ test('ignores a states response that arrives after a newer country change', async () => {
93
+ setup();
94
+
95
+ changeCountry('US');
96
+ changeCountry('IN');
97
+ requestFor('/find_states', 'country_id=IN').respond([['MP', 'Madhya Pradesh']]);
98
+ await settle();
99
+ requestFor('/find_states', 'country_id=US').respond([['CA', 'California']]);
100
+ await settle();
101
+
102
+ const values = Array.from(document.getElementById('state').options).map((o) => o.value);
103
+ assert.deepEqual(values, ['', 'MP']);
104
+ });
105
+
106
+ test('ignores a cities response for a state that is no longer selected', async () => {
107
+ setup();
108
+ changeCountry('US');
109
+ requestFor('/find_states', 'country_id=US').respond([['CA', 'California'], ['NY', 'New York']]);
110
+ await settle();
111
+
112
+ const state = document.getElementById('state');
113
+ state.value = 'CA';
114
+ state.dispatchEvent(new window.Event('change', { bubbles: true }));
115
+ state.value = 'NY';
116
+ state.dispatchEvent(new window.Event('change', { bubbles: true }));
117
+ requestFor('/find_cities', 'state_id=NY').respond(['New York City']);
118
+ await settle();
119
+ requestFor('/find_cities', 'state_id=CA').respond(['Los Angeles']);
120
+ await settle();
121
+
122
+ const cities = Array.from(document.getElementById('city').options).map((o) => o.value);
123
+ assert.deepEqual(cities, ['', 'New York City']);
124
+ });
125
+
126
+ test('changing the country does not request cities', async () => {
127
+ setup();
128
+
129
+ changeCountry('US');
130
+ requestFor('/find_states', 'country_id=US').respond([['CA', 'California']]);
131
+ await settle();
132
+
133
+ assert.deepEqual(requests.map((r) => r.url.split('?')[0]), ['/find_states']);
134
+ });
135
+
136
+ test('destroy removes the country listener', () => {
137
+ setup().destroy();
138
+
139
+ changeCountry('US');
140
+
141
+ assert.equal(requests.length, 0);
142
+ });
143
+
144
+ test('refresh does not add a second country listener', () => {
145
+ setup().refresh();
146
+
147
+ changeCountry('US');
148
+
149
+ assert.equal(requests.length, 1);
150
+ });
@@ -28,10 +28,14 @@
28
28
  var liveRegion = announce ? ensureLiveRegion() : null;
29
29
 
30
30
  var enhancedInstances = {};
31
+ var boundCountryEl = null;
32
+ var statesRequestId = 0;
33
+ var citiesRequestId = 0;
34
+ var NON_TRANSFERABLE_ATTRIBUTES = ['type', 'value'];
31
35
 
32
36
  init();
33
37
 
34
- return { refresh: init, destroy: destroy };
38
+ return { refresh: refresh, destroy: destroy };
35
39
 
36
40
  // ====== ***** INITIALIZATION ***** ===================================== //
37
41
 
@@ -41,9 +45,8 @@
41
45
  var countryEl = document.getElementById(countryId);
42
46
  if (!countryEl) return;
43
47
 
44
- countryEl.addEventListener('change', function () {
45
- findStates(countryEl.value, { userInitiated: true });
46
- });
48
+ countryEl.addEventListener('change', countryChange);
49
+ boundCountryEl = countryEl;
47
50
 
48
51
  if (hasCityField) {
49
52
  document.addEventListener('change', delegatedStateChange);
@@ -83,10 +86,26 @@
83
86
  return !!el && el.tagName === 'SELECT' && el.options.length > 1;
84
87
  }
85
88
 
89
+ function refresh() {
90
+ removeListeners();
91
+ init();
92
+ }
93
+
86
94
  function destroy() {
95
+ removeListeners();
96
+ Object.keys(enhancedInstances).forEach(destroyEnhancement);
97
+ }
98
+
99
+ function removeListeners() {
100
+ if (boundCountryEl) boundCountryEl.removeEventListener('change', countryChange);
101
+ boundCountryEl = null;
87
102
  document.removeEventListener('change', delegatedStateChange);
88
103
  }
89
104
 
105
+ function countryChange(event) {
106
+ findStates(event.target.value, { userInitiated: true });
107
+ }
108
+
90
109
  function delegatedStateChange(event) {
91
110
  if (event.target && event.target.id === stateId) {
92
111
  findCities(event.target.value, document.getElementById(countryId).value, { userInitiated: true });
@@ -95,8 +114,13 @@
95
114
 
96
115
  // ====== ***** DATA LOOKUP ***** ========================================= //
97
116
 
117
+ // Each lookup takes a fresh request id; a response is only applied if no
118
+ // newer lookup for the same field started while it was in flight, so a
119
+ // slow reply for a previously selected country/state can't overwrite the
120
+ // current one.
98
121
  function findStates(countryCode, ctx) {
99
122
  ctx = ctx || {};
123
+ var requestId = ++statesRequestId;
100
124
  if (ctx.userInitiated) findCities('', '', { silent: true });
101
125
 
102
126
  if (offlineData && offlineData.states) {
@@ -105,6 +129,7 @@
105
129
  }
106
130
 
107
131
  fetchJson(statesUrl, { country_id: countryCode }).then(function (data) {
132
+ if (requestId !== statesRequestId) return;
108
133
  buildStatesDropdown(data || [], ctx);
109
134
  });
110
135
  }
@@ -112,6 +137,12 @@
112
137
  function findCities(stateCode, countryCode, ctx) {
113
138
  ctx = ctx || {};
114
139
  if (!hasCityField) return;
140
+ var requestId = ++citiesRequestId;
141
+
142
+ if (!stateCode) {
143
+ buildCitiesDropdown([], ctx);
144
+ return;
145
+ }
115
146
 
116
147
  if (offlineData && offlineData.cities) {
117
148
  var key = countryCode + ':' + stateCode;
@@ -120,6 +151,7 @@
120
151
  }
121
152
 
122
153
  fetchJson(citiesUrl, { country_id: countryCode, state_id: stateCode }).then(function (data) {
154
+ if (requestId !== citiesRequestId) return;
123
155
  buildCitiesDropdown(data || [], ctx);
124
156
  });
125
157
  }
@@ -146,18 +178,15 @@
146
178
  rebuildField(stateId, data, ctx.preselect, options.state_placeholder);
147
179
  if (!ctx.silent) announceUpdate('State options updated');
148
180
 
149
- var countryHasChanged = ctx.userInitiated;
150
- if (hasCityField) {
151
- if (countryHasChanged) {
152
- findCities('', '', { silent: true });
153
- } else if (ctx.preselect !== undefined) {
154
- var stateEl = document.getElementById(stateId);
155
- var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
156
- findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
157
- preselect: preselectedCity,
158
- silent: true
159
- });
160
- }
181
+ // A user-initiated country change already cleared the city field in
182
+ // findStates, so only the initial (pre-selected) load cascades here.
183
+ if (hasCityField && !ctx.userInitiated && ctx.preselect !== undefined) {
184
+ var stateEl = document.getElementById(stateId);
185
+ var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
186
+ findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
187
+ preselect: preselectedCity,
188
+ silent: true
189
+ });
161
190
  }
162
191
  }
163
192
 
@@ -167,9 +196,12 @@
167
196
  }
168
197
 
169
198
  // Rebuilds `fieldId` as a <select> (data present) or a plain text <input>
170
- // (no data for the parent selection) while preserving its id/name/class —
171
- // mutating the existing node in place rather than replaceWith() keeps
172
- // external references (Tom Select instances, other listeners) valid.
199
+ // (no data for the parent selection). The new element replaces the old
200
+ // one, so every attribute is carried over (id, name, class, required,
201
+ // aria-*, Stimulus target data attributes, ...) — otherwise validation,
202
+ // accessibility wiring and Stimulus targets are lost after the first
203
+ // rebuild. Tom Select instances are torn down first and re-created on
204
+ // the new node.
173
205
  function rebuildField(fieldId, pairs, preselectValue, placeholder) {
174
206
  var el = document.getElementById(fieldId);
175
207
  if (!el) return;
@@ -178,18 +210,14 @@
178
210
 
179
211
  if (!pairs || pairs.length === 0) {
180
212
  var input = document.createElement('input');
213
+ copyAttributes(el, input);
181
214
  input.type = 'text';
182
- input.id = fieldId;
183
- input.name = el.name;
184
- input.className = el.className;
185
215
  el.replaceWith(input);
186
216
  return;
187
217
  }
188
218
 
189
219
  var select = document.createElement('select');
190
- select.id = fieldId;
191
- select.name = el.name;
192
- select.className = el.className;
220
+ copyAttributes(el, select);
193
221
 
194
222
  var blank = document.createElement('option');
195
223
  blank.textContent = placeholder || '';
@@ -210,6 +238,12 @@
210
238
  enhance(fieldId);
211
239
  }
212
240
 
241
+ function copyAttributes(from, to) {
242
+ Array.prototype.forEach.call(from.attributes, function (attr) {
243
+ if (NON_TRANSFERABLE_ATTRIBUTES.indexOf(attr.name) === -1) to.setAttribute(attr.name, attr.value);
244
+ });
245
+ }
246
+
213
247
  function enhance(fieldId) {
214
248
  if (!fieldId || typeof enhancer !== 'function') return;
215
249
  var el = document.getElementById(fieldId);
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: country_state_select
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arvind Vyas
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: bundler
@@ -185,6 +186,7 @@ executables: []
185
186
  extensions: []
186
187
  extra_rdoc_files: []
187
188
  files:
189
+ - ".github/dependabot.yml"
188
190
  - ".github/workflows/ci.yml"
189
191
  - ".gitignore"
190
192
  - ".rspec"
@@ -207,6 +209,7 @@ files:
207
209
  - gemfiles/rails_7.1.gemfile
208
210
  - gemfiles/rails_7.2.gemfile
209
211
  - gemfiles/rails_8.0.gemfile
212
+ - gemfiles/rails_8.1.gemfile
210
213
  - lib/country_state_select.rb
211
214
  - lib/country_state_select/configuration.rb
212
215
  - lib/country_state_select/country_metadata.rb
@@ -220,6 +223,7 @@ files:
220
223
  - lib/generators/country_state_select/install/install_generator.rb
221
224
  - lib/generators/country_state_select/install/templates/README
222
225
  - lib/generators/country_state_select/install/templates/initializer.rb
226
+ - package-lock.json
223
227
  - package.json
224
228
  - spec/country_state_select_spec.rb
225
229
  - spec/lib/country_state_select/configuration_spec.rb
@@ -229,6 +233,7 @@ files:
229
233
  - spec/requests/business_form_spec.rb
230
234
  - spec/requests/lookups_spec.rb
231
235
  - spec/spec_helper.rb
236
+ - test/javascript/country_state_select.test.mjs
232
237
  - vendor/assets/javascript/country_state_select.js.erb
233
238
  - vendor/assets/javascript/country_state_select_vanilla.js
234
239
  homepage: https://github.com/arvindvyas/Country-State-Select
@@ -236,6 +241,7 @@ licenses:
236
241
  - MIT
237
242
  metadata:
238
243
  changelog_uri: https://github.com/arvindvyas/Country-State-Select/blob/master/CHANGELOG.md
244
+ post_install_message:
239
245
  rdoc_options: []
240
246
  require_paths:
241
247
  - lib
@@ -250,7 +256,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
250
256
  - !ruby/object:Gem::Version
251
257
  version: '0'
252
258
  requirements: []
253
- rubygems_version: 4.0.11
259
+ rubygems_version: 3.5.22
260
+ signing_key:
254
261
  specification_version: 4
255
262
  summary: Dynamically select Country and State.
256
263
  test_files:
@@ -262,3 +269,4 @@ test_files:
262
269
  - spec/requests/business_form_spec.rb
263
270
  - spec/requests/lookups_spec.rb
264
271
  - spec/spec_helper.rb
272
+ - test/javascript/country_state_select.test.mjs