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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '07096615806cb6927cc87f41dd9f52a468a4abbbc3995f14510e5d4a9c76586f'
4
- data.tar.gz: 106a0cd51d710611182c4f07c2b28de1e2efde86bd0170f8ce0cedb784bfcee7
3
+ metadata.gz: b44110d0208f2adeaa74cd4300fdae9edc51dffc5d7f4523cf23a720fd79c352
4
+ data.tar.gz: ed1bb261ce072b64e3b03434ae6157fbaa01fc830501285cbb5ed8d4f4b71a74
5
5
  SHA512:
6
- metadata.gz: 4b478c6e74ed49cde30de69c0695d6ba82b32931bf778acd723d848b1e749f5389c359010f4c32a4753c8cea682839d28c1e741fe4f083eb8c03d2a6f065a23f
7
- data.tar.gz: e3f8d5c2f1aebee96f819354a56d9d0d227c490134d4f8c1a012e434ad0500b7cad1227f604a0bd155b9d7a82f9258f239054daeb93bbbe437854440e780038d
6
+ metadata.gz: '0378bdd52c565fa664d7df27ef41e339392d4a94262db5b5bd8f074b80c10412af1f2ab546880f540f9cb27937bebcdc415275ead5f8d1113a4e538a15ff30b5'
7
+ data.tar.gz: 7c0fda7c020be2912f2337e52761610ac58886bdeecb61cd396afa175f2036e9c12913bdd6203798be7fbb471e6d63e33abdb8f22f3df53a5f4df798669ae9df
@@ -0,0 +1,14 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: monthly
7
+ - package-ecosystem: bundler
8
+ directory: /
9
+ schedule:
10
+ interval: monthly
11
+ - package-ecosystem: npm
12
+ directory: /
13
+ schedule:
14
+ interval: monthly
@@ -5,6 +5,9 @@ on:
5
5
  branches: [master]
6
6
  pull_request:
7
7
 
8
+ permissions:
9
+ contents: read
10
+
8
11
  jobs:
9
12
  ruby-versions:
10
13
  name: Ruby ${{ matrix.ruby }} (latest Rails)
@@ -12,9 +15,9 @@ jobs:
12
15
  strategy:
13
16
  fail-fast: false
14
17
  matrix:
15
- ruby: ['3.1', '3.2', '3.3', '3.4']
18
+ ruby: ['3.1', '3.2', '3.3', '3.4', '4.0']
16
19
  steps:
17
- - uses: actions/checkout@v4
20
+ - uses: actions/checkout@v5
18
21
  - uses: ruby/setup-ruby@v1
19
22
  with:
20
23
  ruby-version: ${{ matrix.ruby }}
@@ -28,14 +31,26 @@ jobs:
28
31
  strategy:
29
32
  fail-fast: false
30
33
  matrix:
31
- rails: ['7.0', '7.1', '7.2', '8.0']
34
+ rails: ['7.0', '7.1', '7.2', '8.0', '8.1']
32
35
  env:
33
36
  BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails }}.gemfile
34
37
  steps:
35
- - uses: actions/checkout@v4
38
+ - uses: actions/checkout@v5
36
39
  - uses: ruby/setup-ruby@v1
37
40
  with:
38
41
  ruby-version: '3.3'
39
42
  bundler-cache: true
40
43
  - name: Run tests
41
44
  run: bundle exec rspec
45
+
46
+ javascript:
47
+ name: JavaScript
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v5
51
+ - uses: actions/setup-node@v5
52
+ with:
53
+ node-version: '22'
54
+ cache: npm
55
+ - run: npm ci
56
+ - run: npm test
data/.gitignore CHANGED
@@ -23,3 +23,4 @@ mkmf.log
23
23
  .idea/
24
24
  vendor/bundle/
25
25
  gemfiles/*.gemfile.lock
26
+ node_modules
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.1
4
+
5
+ ### Fixed
6
+ - Rebuilt state/city fields now keep all of the original element's attributes (`required`, `aria-*`, `disabled`, Stimulus `data-*-target`, ...). Previously only `id`, `name` and `class` survived the first country/state change, which dropped validation and accessibility wiring and broke Stimulus reconnects ("Missing target element").
7
+ - A slow `/find_states` or `/find_cities` response for a previous selection can no longer overwrite the dropdown for the current one.
8
+ - `destroy()` now removes the country `change` listener, and `refresh()` no longer stacks a second set of listeners (which caused duplicate lookups per change).
9
+ - Changing the country no longer sends two `/find_cities` requests for an empty state; the city field is cleared client-side.
10
+ - The lookup endpoints' ETag now includes the gem version and the data source's `cache_key`, so browsers re-fetch after an upgrade or a data change instead of receiving a stale `304`. Custom data sources can override `cache_key`.
11
+
12
+ ### Changed
13
+ - CI: Ruby 4.0 and Rails 8.1 added to the matrix, a JavaScript test job (`npm test`, Node test runner + jsdom), least-privilege workflow `permissions`, and Dependabot for Actions/bundler/npm.
14
+
3
15
  ## 4.0.0
4
16
 
5
17
  Modernization release. See [docs/migration-v4.md](docs/migration-v4.md) for the full upgrade guide — existing 3.x code (Ruby API and the legacy jQuery JS) keeps working unchanged.
data/README.md CHANGED
@@ -1,12 +1,15 @@
1
- ## Country State Select
1
+ # Country State Select
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/country_state_select)](https://rubygems.org/gems/country_state_select)
2
4
  [![CI](https://github.com/arvindvyas/Country-State-Select/actions/workflows/ci.yml/badge.svg)](https://github.com/arvindvyas/Country-State-Select/actions/workflows/ci.yml)
3
- [![Code Climate](https://codeclimate.com/github/arvindvyas/Country-State-Select/badges/gpa.svg)](https://codeclimate.com/github/arvindvyas/Country-State-Select)
5
+ [![Downloads](https://img.shields.io/gem/dt/country_state_select)](https://rubygems.org/gems/country_state_select)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
4
7
 
5
8
  Country State Select gives you cascading Country → State/Province → City dropdowns for Rails forms: pick a country, the state field repopulates; pick a state, the city field repopulates.
6
9
 
7
10
  ![Cascading country, state, and city selects](docs/images/cascading-select-demo.png)
8
11
 
9
- Country/state/state data comes from the [city-state](https://github.com/loureirorg/city-state) gem (pluggable — see [Custom data sources](#custom-data-sources)).
12
+ Country, state and city data comes from the [city-state](https://github.com/loureirorg/city-state) gem (pluggable — see [Custom data sources](#custom-data-sources)).
10
13
 
11
14
  **v4.0 is a modernization release.** Highlights:
12
15
 
@@ -18,7 +21,20 @@ Country/state/state data comes from the [city-state](https://github.com/loureiro
18
21
  - HTTP-cached JSON lookup endpoints
19
22
  - An install generator (`rails g country_state_select:install`)
20
23
 
21
- Upgrading from 3.x? See [docs/migration-v4.md](docs/migration-v4.md) — the legacy jQuery/Chosen JavaScript still works, but Rails < 7.0 is no longer supported (stay on the 3.x gem for older Rails).
24
+ See the [CHANGELOG](CHANGELOG.md) for what changed in each release. Upgrading from 3.x? See [docs/migration-v4.md](docs/migration-v4.md) — the legacy jQuery/Chosen JavaScript still works, but Rails < 7.0 is no longer supported (stay on the 3.x gem for older Rails).
25
+
26
+ ## Table of contents
27
+
28
+ - [Installation](#installation)
29
+ - [Supported versions](#supported-versions)
30
+ - [JavaScript setup](#javascript-setup)
31
+ - [JavaScript API](#javascript-api)
32
+ - [Using it in a form](#using-it-in-a-form)
33
+ - [Configuration](#configuration)
34
+ - [JSON API](#json-api)
35
+ - [Development](#development)
36
+ - [Contributing](#contributing)
37
+ - [License](#license)
22
38
 
23
39
  ## Installation
24
40
 
@@ -31,7 +47,14 @@ bundle install
31
47
  rails g country_state_select:install # optional: initializer + setup instructions
32
48
  ```
33
49
 
34
- Requires Rails >= 7.0 and Ruby >= 3.1.
50
+ ## Supported versions
51
+
52
+ | Gem version | Ruby | Rails |
53
+ |---|---|---|
54
+ | 4.x | 3.1 – 4.0 | 7.0 – 8.1 |
55
+ | 3.x | no minimum declared | 3.0 – 7.x (use 3.x for Rails < 7.0) |
56
+
57
+ CI tests every Ruby and Rails version listed for 4.x. Rails 7.0 and 7.1 and Ruby 3.1 and 3.2 are past end of life upstream; they still pass CI but may be dropped in a future major version.
35
58
 
36
59
  ## JavaScript setup
37
60
 
@@ -85,14 +108,22 @@ Stimulus controller values (all optional, set as `data-country-state-select-<val
85
108
 
86
109
  ```js
87
110
  document.addEventListener('DOMContentLoaded', function () {
88
- CountryStateSelect({ country_id: "country_field_id", state_id: "state_field_id" })
111
+ CountryStateSelect({
112
+ country_id: "country_field_id",
113
+ state_id: "state_field_id",
114
+ city_id: "city_field_id" // optional
115
+ })
89
116
  })
90
117
  ```
91
118
 
119
+ All options are listed in [JavaScript API](#javascript-api). If you use Turbo without Stimulus, see [Turbo without Stimulus](#turbo-without-stimulus).
120
+
92
121
  ### npm / esbuild / webpack
93
122
 
123
+ The package is not on the npm registry yet. Install it from GitHub:
124
+
94
125
  ```
95
- npm install country_state_select @hotwired/stimulus
126
+ npm install github:arvindvyas/Country-State-Select @hotwired/stimulus
96
127
  ```
97
128
 
98
129
  ```js
@@ -123,6 +154,69 @@ $(document).on('turbo:load', function () {
123
154
  })
124
155
  ```
125
156
 
157
+ ## JavaScript API
158
+
159
+ The Stimulus controller is a thin wrapper around one function. Call it directly when you aren't using Stimulus:
160
+
161
+ ```js
162
+ const csc = CountryStateSelect({ country_id: "country", state_id: "state", city_id: "city" })
163
+ ```
164
+
165
+ | Option | Default | Purpose |
166
+ |---|---|---|
167
+ | `country_id` | required | `id` of the country `<select>` |
168
+ | `state_id` | required | `id` of the state field |
169
+ | `city_id` | none | `id` of the city field; leave it out for a country → state form |
170
+ | `states_url` / `cities_url` | `/find_states` / `/find_cities` | Lookup endpoints, if you mounted them elsewhere |
171
+ | `state_placeholder` / `city_placeholder` | `""` | Text of the blank first option |
172
+ | `selected_state` / `selected_city` | none | Value to pre-select when the field is filled on page load. You can also set `data-selected-value` on the state field or `data-selected-city` on the state or city field |
173
+ | `announce` | `true` | Announce updates through an `aria-live` region |
174
+ | `enhance` | none | `function (selectEl, options)` called on every rebuilt `<select>`, e.g. to attach Tom Select. Return an object with `destroy()` so it can be torn down before the next rebuild |
175
+ | `enhance_options` | `{}` | Passed as the second argument to `enhance` |
176
+ | `offline_data` | none | Serve lookups from memory instead of the endpoints (see below) |
177
+
178
+ It returns `{ refresh, destroy }`:
179
+
180
+ - `refresh()` re-reads the fields and re-attaches listeners, e.g. after you replace the form's HTML. It can be called repeatedly; listeners are not duplicated.
181
+ - `destroy()` removes all listeners and tears down any `enhance` instances.
182
+
183
+ **How fields are updated.** When the country changes, the state field is rebuilt with the new options and the city field is cleared. If there is nothing to list, the field becomes a text input so the user can type a value. The rebuilt field keeps every attribute of the original (`id`, `name`, `class`, `required`, `aria-*`, `data-*`, and so on), so validation, accessibility labels and Stimulus targets survive. If the user changes the country or state again before a lookup returns, the older response is ignored.
184
+
185
+ **Tom Select without Stimulus:**
186
+
187
+ ```js
188
+ CountryStateSelect({
189
+ country_id: "country",
190
+ state_id: "state",
191
+ enhance: (el, opts) => new TomSelect(el, opts),
192
+ enhance_options: { create: false }
193
+ })
194
+ ```
195
+
196
+ **Offline data.** Skip the network by passing the lookup data up front. States are keyed by country code, cities by `"COUNTRY:STATE"`. A missing `cities` key falls back to the endpoint.
197
+
198
+ ```js
199
+ CountryStateSelect({
200
+ country_id: "country", state_id: "state", city_id: "city",
201
+ offline_data: {
202
+ states: { US: [["CA", "California"], ["NY", "New York"]] },
203
+ cities: { "US:CA": ["Los Angeles", "San Francisco"] }
204
+ }
205
+ })
206
+ ```
207
+
208
+ ### Turbo without Stimulus
209
+
210
+ The Stimulus controller cleans up after itself. With the plain function, destroy the instance before Turbo caches the page, or each visit leaves another listener on `document`:
211
+
212
+ ```js
213
+ let csc
214
+ document.addEventListener("turbo:load", () => {
215
+ csc = CountryStateSelect({ country_id: "country", state_id: "state", city_id: "city" })
216
+ })
217
+ document.addEventListener("turbo:before-cache", () => csc?.destroy())
218
+ ```
219
+
126
220
  ## Using it in a form
127
221
 
128
222
  ### `form_with` (no simple_form required)
@@ -174,9 +268,11 @@ end
174
268
 
175
269
  Every option here can also be passed per-call: `CountryStateSelect.countries_collection(priority: %w[US])`, `f.country_select :country, only: %w[US CA]`.
176
270
 
271
+ The `priority_countries`, `only_countries` and `except_countries` settings shape the **country** list. They are not applied by the `/find_states` and `/find_cities` endpoints.
272
+
177
273
  ### Custom data sources
178
274
 
179
- Swap out `city-state` (e.g. for a database table, or the `countries` gem) by implementing the adapter interface:
275
+ Swap out `city-state` (e.g. for a database table, or the `countries` gem) by implementing the adapter interface. The lookup endpoints, form helpers and `collect_*` methods all go through it, so no view or JavaScript changes are needed:
180
276
 
181
277
  ```ruby
182
278
  class MyDataSource < CountryStateSelect::DataSources::Base
@@ -191,11 +287,19 @@ class MyDataSource < CountryStateSelect::DataSources::Base
191
287
  def cities(state_code, country_code)
192
288
  ['Los Angeles', 'San Francisco'] # plain names
193
289
  end
290
+
291
+ # Optional: part of the lookup endpoints' ETag. Return something that
292
+ # changes with your data so browsers don't keep serving a stale 304.
293
+ def cache_key
294
+ "my-data-#{Location.maximum(:updated_at).to_i}"
295
+ end
194
296
  end
195
297
 
196
298
  CountryStateSelect.configure { |c| c.data_source = MyDataSource.new }
197
299
  ```
198
300
 
301
+ `cache_key` defaults to the class name. The lookup responses are HTTP-cached (1 day by default), and their `ETag` is built from the gem version, this key and the request params. If your data can change without a deploy, return a key that changes with it; otherwise browsers that already hold a response keep getting `304 Not Modified` until it expires.
302
+
199
303
  ### Custom route path
200
304
 
201
305
  By default the engine draws `/find_states` and `/find_cities` at the top level. To mount it elsewhere:
@@ -221,20 +325,50 @@ The lookup endpoints are documented in [docs/json-api.md](docs/json-api.md) —
221
325
 
222
326
  ## Development
223
327
 
328
+ Ruby specs:
329
+
224
330
  ```
225
331
  bundle install
226
332
  bundle exec rspec
333
+
334
+ # against a specific Rails version
335
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
336
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rspec
227
337
  ```
228
338
 
229
- The dummy Rails app used for integration tests lives in `spec/dummy` and doubles as a runnable demo:
339
+ JavaScript tests (Node's built-in test runner with jsdom, in `test/javascript/`):
340
+
341
+ ```
342
+ npm ci
343
+ npm test
344
+ ```
345
+
346
+ The JavaScript source of truth is `app/javascript/country_state_select/country_state_select.js`. After editing it, regenerate the Sprockets build and commit both files:
347
+
348
+ ```
349
+ bundle exec rake js:build
350
+ ```
351
+
352
+ The dummy Rails app used for the request specs lives in `spec/dummy` and doubles as a runnable demo:
230
353
 
231
354
  ```
232
355
  cd spec/dummy
233
356
  bin/rails server
234
357
  ```
235
358
 
236
- CI runs the suite across Ruby 3.1–3.4 (latest Rails) and Rails 7.0/7.1/7.2/8.0 (see `gemfiles/`).
359
+ CI runs the Ruby suite on Ruby 3.1–4.0 (latest Rails) and on Rails 7.0, 7.1, 7.2, 8.0 and 8.1 (see `gemfiles/`), plus the JavaScript tests.
237
360
 
238
361
  ## Contributing
239
362
 
240
- Fork, fix, then send a pull request.
363
+ 1. Fork the repo and create a branch.
364
+ 2. Make your change with a test: an RSpec example for Ruby, a `test/javascript/` test for the JS.
365
+ 3. If you changed the JS source, run `bundle exec rake js:build`.
366
+ 4. Run `bundle exec rspec` and `npm test`.
367
+ 5. Add a line under the next version in `CHANGELOG.md`.
368
+ 6. Open a pull request.
369
+
370
+ Bug reports and questions are welcome in [GitHub issues](https://github.com/arvindvyas/Country-State-Select/issues).
371
+
372
+ ## License
373
+
374
+ Released under the [MIT License](LICENSE.txt).
@@ -30,12 +30,16 @@ module CountryStateSelect
30
30
  # both at the HTTP layer (Cache-Control) and via conditional GET
31
31
  # (ETag/304), avoiding a render entirely on repeat lookups. Disabled by
32
32
  # setting `CountryStateSelect.configuration.cache_duration = nil`.
33
+ # The ETag includes the gem version and the data source's cache_key so
34
+ # an upgrade or a data change invalidates responses browsers already hold.
33
35
  def render_cached(data, cache_key)
34
- duration = CountryStateSelect.configuration.cache_duration
36
+ configuration = CountryStateSelect.configuration
37
+ duration = configuration.cache_duration
35
38
 
36
39
  if duration
37
40
  expires_in duration, public: true
38
- return unless stale?(etag: cache_key)
41
+ etag = [CountryStateSelect::VERSION, configuration.data_source_instance.cache_key, cache_key]
42
+ return unless stale?(etag: etag)
39
43
  end
40
44
 
41
45
  respond_to do |format|
@@ -24,10 +24,14 @@ export default function CountryStateSelect(options) {
24
24
  var liveRegion = announce ? ensureLiveRegion() : null;
25
25
 
26
26
  var enhancedInstances = {};
27
+ var boundCountryEl = null;
28
+ var statesRequestId = 0;
29
+ var citiesRequestId = 0;
30
+ var NON_TRANSFERABLE_ATTRIBUTES = ['type', 'value'];
27
31
 
28
32
  init();
29
33
 
30
- return { refresh: init, destroy: destroy };
34
+ return { refresh: refresh, destroy: destroy };
31
35
 
32
36
  // ====== ***** INITIALIZATION ***** ===================================== //
33
37
 
@@ -37,9 +41,8 @@ export default function CountryStateSelect(options) {
37
41
  var countryEl = document.getElementById(countryId);
38
42
  if (!countryEl) return;
39
43
 
40
- countryEl.addEventListener('change', function () {
41
- findStates(countryEl.value, { userInitiated: true });
42
- });
44
+ countryEl.addEventListener('change', countryChange);
45
+ boundCountryEl = countryEl;
43
46
 
44
47
  if (hasCityField) {
45
48
  document.addEventListener('change', delegatedStateChange);
@@ -79,10 +82,26 @@ export default function CountryStateSelect(options) {
79
82
  return !!el && el.tagName === 'SELECT' && el.options.length > 1;
80
83
  }
81
84
 
85
+ function refresh() {
86
+ removeListeners();
87
+ init();
88
+ }
89
+
82
90
  function destroy() {
91
+ removeListeners();
92
+ Object.keys(enhancedInstances).forEach(destroyEnhancement);
93
+ }
94
+
95
+ function removeListeners() {
96
+ if (boundCountryEl) boundCountryEl.removeEventListener('change', countryChange);
97
+ boundCountryEl = null;
83
98
  document.removeEventListener('change', delegatedStateChange);
84
99
  }
85
100
 
101
+ function countryChange(event) {
102
+ findStates(event.target.value, { userInitiated: true });
103
+ }
104
+
86
105
  function delegatedStateChange(event) {
87
106
  if (event.target && event.target.id === stateId) {
88
107
  findCities(event.target.value, document.getElementById(countryId).value, { userInitiated: true });
@@ -91,8 +110,13 @@ export default function CountryStateSelect(options) {
91
110
 
92
111
  // ====== ***** DATA LOOKUP ***** ========================================= //
93
112
 
113
+ // Each lookup takes a fresh request id; a response is only applied if no
114
+ // newer lookup for the same field started while it was in flight, so a
115
+ // slow reply for a previously selected country/state can't overwrite the
116
+ // current one.
94
117
  function findStates(countryCode, ctx) {
95
118
  ctx = ctx || {};
119
+ var requestId = ++statesRequestId;
96
120
  if (ctx.userInitiated) findCities('', '', { silent: true });
97
121
 
98
122
  if (offlineData && offlineData.states) {
@@ -101,6 +125,7 @@ export default function CountryStateSelect(options) {
101
125
  }
102
126
 
103
127
  fetchJson(statesUrl, { country_id: countryCode }).then(function (data) {
128
+ if (requestId !== statesRequestId) return;
104
129
  buildStatesDropdown(data || [], ctx);
105
130
  });
106
131
  }
@@ -108,6 +133,12 @@ export default function CountryStateSelect(options) {
108
133
  function findCities(stateCode, countryCode, ctx) {
109
134
  ctx = ctx || {};
110
135
  if (!hasCityField) return;
136
+ var requestId = ++citiesRequestId;
137
+
138
+ if (!stateCode) {
139
+ buildCitiesDropdown([], ctx);
140
+ return;
141
+ }
111
142
 
112
143
  if (offlineData && offlineData.cities) {
113
144
  var key = countryCode + ':' + stateCode;
@@ -116,6 +147,7 @@ export default function CountryStateSelect(options) {
116
147
  }
117
148
 
118
149
  fetchJson(citiesUrl, { country_id: countryCode, state_id: stateCode }).then(function (data) {
150
+ if (requestId !== citiesRequestId) return;
119
151
  buildCitiesDropdown(data || [], ctx);
120
152
  });
121
153
  }
@@ -142,18 +174,15 @@ export default function CountryStateSelect(options) {
142
174
  rebuildField(stateId, data, ctx.preselect, options.state_placeholder);
143
175
  if (!ctx.silent) announceUpdate('State options updated');
144
176
 
145
- var countryHasChanged = ctx.userInitiated;
146
- if (hasCityField) {
147
- if (countryHasChanged) {
148
- findCities('', '', { silent: true });
149
- } else if (ctx.preselect !== undefined) {
150
- var stateEl = document.getElementById(stateId);
151
- var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
152
- findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
153
- preselect: preselectedCity,
154
- silent: true
155
- });
156
- }
177
+ // A user-initiated country change already cleared the city field in
178
+ // findStates, so only the initial (pre-selected) load cascades here.
179
+ if (hasCityField && !ctx.userInitiated && ctx.preselect !== undefined) {
180
+ var stateEl = document.getElementById(stateId);
181
+ var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
182
+ findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
183
+ preselect: preselectedCity,
184
+ silent: true
185
+ });
157
186
  }
158
187
  }
159
188
 
@@ -163,9 +192,12 @@ export default function CountryStateSelect(options) {
163
192
  }
164
193
 
165
194
  // Rebuilds `fieldId` as a <select> (data present) or a plain text <input>
166
- // (no data for the parent selection) while preserving its id/name/class —
167
- // mutating the existing node in place rather than replaceWith() keeps
168
- // external references (Tom Select instances, other listeners) valid.
195
+ // (no data for the parent selection). The new element replaces the old
196
+ // one, so every attribute is carried over (id, name, class, required,
197
+ // aria-*, Stimulus target data attributes, ...) — otherwise validation,
198
+ // accessibility wiring and Stimulus targets are lost after the first
199
+ // rebuild. Tom Select instances are torn down first and re-created on
200
+ // the new node.
169
201
  function rebuildField(fieldId, pairs, preselectValue, placeholder) {
170
202
  var el = document.getElementById(fieldId);
171
203
  if (!el) return;
@@ -174,18 +206,14 @@ export default function CountryStateSelect(options) {
174
206
 
175
207
  if (!pairs || pairs.length === 0) {
176
208
  var input = document.createElement('input');
209
+ copyAttributes(el, input);
177
210
  input.type = 'text';
178
- input.id = fieldId;
179
- input.name = el.name;
180
- input.className = el.className;
181
211
  el.replaceWith(input);
182
212
  return;
183
213
  }
184
214
 
185
215
  var select = document.createElement('select');
186
- select.id = fieldId;
187
- select.name = el.name;
188
- select.className = el.className;
216
+ copyAttributes(el, select);
189
217
 
190
218
  var blank = document.createElement('option');
191
219
  blank.textContent = placeholder || '';
@@ -206,6 +234,12 @@ export default function CountryStateSelect(options) {
206
234
  enhance(fieldId);
207
235
  }
208
236
 
237
+ function copyAttributes(from, to) {
238
+ Array.prototype.forEach.call(from.attributes, function (attr) {
239
+ if (NON_TRANSFERABLE_ATTRIBUTES.indexOf(attr.name) === -1) to.setAttribute(attr.name, attr.value);
240
+ });
241
+ }
242
+
209
243
  function enhance(fieldId) {
210
244
  if (!fieldId || typeof enhancer !== 'function') return;
211
245
  var el = document.getElementById(fieldId);
data/docs/json-api.md CHANGED
@@ -16,7 +16,7 @@ GET /find_states?country_id=US
16
16
  [["AK", "Alaska"], ["AL", "Alabama"], ["AR", "Arkansas"], ...]
17
17
  ```
18
18
 
19
- Honors whatever `only_countries`/`except_countries`/`priority_countries` and custom `data_source` are configured — it does not bypass your configuration the way calling the underlying data gem directly would.
19
+ Resolves through the configured `data_source`, so a custom adapter is used here too. The country settings (`only_countries`, `except_countries`, `priority_countries`) are not applied: they shape the country list, and this endpoint returns the states of whatever country code it is given.
20
20
 
21
21
  ## `GET /find_cities`
22
22
 
@@ -34,7 +34,9 @@ GET /find_cities?state_id=CA&country_id=US
34
34
 
35
35
  ## Caching
36
36
 
37
- Both endpoints send `Cache-Control` and `ETag` headers based on `CountryStateSelect.configuration.cache_duration` (default: 1 day). A conditional `GET` with a matching `If-None-Match` gets a `304 Not Modified` with no body. Set `cache_duration = nil` to disable both.
37
+ Both endpoints send `Cache-Control: public` and an `ETag` when `CountryStateSelect.configuration.cache_duration` is set (default: 1 day). Set `cache_duration = nil` to send neither.
38
+
39
+ The `ETag` is built from the gem version, the data source's `cache_key` and the request params. A conditional `GET` whose `If-None-Match` matches gets `304 Not Modified` with no body. Upgrading the gem (or the `city-state` gem, for the default data source) changes the `ETag`, so clients re-fetch. A custom data source whose data changes at runtime should return a changing `cache_key`; see [Custom data sources](../README.md#custom-data-sources).
38
40
 
39
41
  ## Routes
40
42
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 8.1.0'
6
+
7
+ gemspec path: '..'
@@ -22,6 +22,14 @@ module CountryStateSelect
22
22
  def cities(_state_code, _country_code)
23
23
  raise NotImplementedError
24
24
  end
25
+
26
+ # Part of the ETag on the JSON lookup endpoints. Change it whenever the
27
+ # underlying data changes (e.g. a table's max updated_at) so browsers
28
+ # holding a cached response re-fetch instead of getting a 304.
29
+ # @return [String]
30
+ def cache_key
31
+ self.class.name
32
+ end
25
33
  end
26
34
  end
27
35
  end
@@ -23,6 +23,10 @@ module CountryStateSelect
23
23
 
24
24
  CS.cities(state_code.to_sym, country_code.to_s.empty? ? nil : country_code.to_sym) || []
25
25
  end
26
+
27
+ def cache_key
28
+ "#{super}/#{Gem.loaded_specs['city-state']&.version}"
29
+ end
26
30
  end
27
31
  end
28
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CountryStateSelect
4
- VERSION = '4.0.0'
4
+ VERSION = '4.0.1'
5
5
  end