country_state_select 3.2.0 → 4.0.0

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +41 -0
  3. data/.gitignore +2 -0
  4. data/.rspec +1 -0
  5. data/CHANGELOG.md +56 -0
  6. data/README.md +196 -111
  7. data/Rakefile +26 -0
  8. data/app/controllers/country_state_select/cscs_controller.rb +31 -8
  9. data/app/javascript/country_state_select/controller.js +66 -0
  10. data/app/javascript/country_state_select/country_state_select.js +245 -0
  11. data/config/importmap.rb +4 -0
  12. data/config/routes.rb +14 -4
  13. data/country_state_select.gemspec +14 -4
  14. data/docs/cities.md +35 -0
  15. data/docs/images/cascading-select-demo.png +0 -0
  16. data/docs/json-api.md +41 -0
  17. data/docs/migration-v4.md +32 -0
  18. data/gemfiles/rails_7.0.gemfile +10 -0
  19. data/gemfiles/rails_7.1.gemfile +7 -0
  20. data/gemfiles/rails_7.2.gemfile +7 -0
  21. data/gemfiles/rails_8.0.gemfile +7 -0
  22. data/lib/country_state_select/configuration.rb +89 -0
  23. data/lib/country_state_select/country_metadata.rb +29 -0
  24. data/lib/country_state_select/data/dial_codes.yml +250 -0
  25. data/lib/country_state_select/data_sources/base.rb +27 -0
  26. data/lib/country_state_select/data_sources/city_state.rb +28 -0
  27. data/lib/country_state_select/engine.rb +31 -0
  28. data/lib/country_state_select/form_builder.rb +48 -0
  29. data/lib/country_state_select/localization.rb +41 -0
  30. data/lib/country_state_select/version.rb +1 -1
  31. data/lib/country_state_select.rb +83 -23
  32. data/lib/generators/country_state_select/install/install_generator.rb +22 -0
  33. data/lib/generators/country_state_select/install/templates/README +34 -0
  34. data/lib/generators/country_state_select/install/templates/initializer.rb +29 -0
  35. data/package.json +40 -0
  36. data/spec/country_state_select_spec.rb +2 -2
  37. data/spec/lib/country_state_select/configuration_spec.rb +63 -0
  38. data/spec/lib/country_state_select/country_metadata_spec.rb +42 -0
  39. data/spec/lib/country_state_select/filtering_spec.rb +68 -0
  40. data/spec/lib/country_state_select/localization_spec.rb +30 -0
  41. data/spec/requests/business_form_spec.rb +38 -0
  42. data/spec/requests/lookups_spec.rb +72 -0
  43. data/spec/spec_helper.rb +12 -4
  44. data/vendor/assets/javascript/country_state_select.js.erb +35 -21
  45. data/vendor/assets/javascript/country_state_select_vanilla.js +252 -0
  46. metadata +147 -17
  47. data/.circleci/config.yml +0 -15
  48. data/.travis.yml +0 -14
  49. data/lib/country_state_select/engine3.rb +0 -10
  50. data/lib/country_state_select/railtie.rb +0 -10
@@ -0,0 +1,245 @@
1
+ // Dependency-free core: no jQuery required. Works as an ES module (npm,
2
+ // importmap-rails, esbuild/webpack) and is also compiled by `rake js:build`
3
+ // into a plain-script build for Sprockets users — see
4
+ // vendor/assets/javascript/country_state_select_vanilla.js. Keep this file
5
+ // as the single source of truth; edit the compiled build only by re-running
6
+ // `rake js:build`.
7
+ //
8
+ // The legacy jQuery + Chosen implementation
9
+ // (vendor/assets/javascript/country_state_select.js.erb) is kept for
10
+ // backward compatibility but is deprecated — new apps should use this file,
11
+ // optionally through the bundled Stimulus controller (controller.js).
12
+
13
+ export default function CountryStateSelect(options) {
14
+ var countryId = options.country_id;
15
+ var stateId = options.state_id;
16
+ var cityId = options.city_id;
17
+ var hasCityField = !!cityId;
18
+ var announce = options.announce !== false;
19
+ var offlineData = options.offline_data || null;
20
+ var statesUrl = options.states_url || '/find_states';
21
+ var citiesUrl = options.cities_url || '/find_cities';
22
+ var enhancer = options.enhance; // optional fn(selectEl, tomSelectOptions) e.g. Tom Select hookup
23
+ var enhancerOptions = options.enhance_options || {};
24
+ var liveRegion = announce ? ensureLiveRegion() : null;
25
+
26
+ var enhancedInstances = {};
27
+
28
+ init();
29
+
30
+ return { refresh: init, destroy: destroy };
31
+
32
+ // ====== ***** INITIALIZATION ***** ===================================== //
33
+
34
+ function init() {
35
+ enhance(countryId);
36
+
37
+ var countryEl = document.getElementById(countryId);
38
+ if (!countryEl) return;
39
+
40
+ countryEl.addEventListener('change', function () {
41
+ findStates(countryEl.value, { userInitiated: true });
42
+ });
43
+
44
+ if (hasCityField) {
45
+ document.addEventListener('change', delegatedStateChange);
46
+ }
47
+
48
+ // Edit-form support: a server-rendered form (e.g. via the FormBuilder
49
+ // helpers, which read the model's current country/state to pick the
50
+ // right <select> options) already shows the correct state/city on
51
+ // load — trust it and only enhance it, rather than discarding it for
52
+ // a fresh, unselected fetch. Only fetch here for fields that truly
53
+ // need it: a plain/manual integration where the target is still a
54
+ // blank shell (a text input, or a <select> with no real options),
55
+ // even though the country already has a value. Pre-selection for that
56
+ // fetch comes from an explicit option or a `data-selected-value`/
57
+ // `data-selected-city` attribute.
58
+ var countryValue = countryEl.value;
59
+ var stateEl = document.getElementById(stateId);
60
+ var cityEl = hasCityField ? document.getElementById(cityId) : null;
61
+
62
+ if (isPopulated(stateEl)) {
63
+ enhance(stateId);
64
+ } else if (countryValue) {
65
+ var preselectedState = options.selected_state || (stateEl && stateEl.getAttribute('data-selected-value'));
66
+ findStates(countryValue, { preselect: preselectedState, silent: true });
67
+ return; // findStates cascades into the city fetch itself once it resolves
68
+ }
69
+
70
+ if (isPopulated(cityEl)) {
71
+ enhance(cityId);
72
+ } else if (hasCityField && stateEl && stateEl.value) {
73
+ var preselectedCity = options.selected_city || (cityEl && cityEl.getAttribute('data-selected-city'));
74
+ findCities(stateEl.value, countryValue, { preselect: preselectedCity, silent: true });
75
+ }
76
+ }
77
+
78
+ function isPopulated(el) {
79
+ return !!el && el.tagName === 'SELECT' && el.options.length > 1;
80
+ }
81
+
82
+ function destroy() {
83
+ document.removeEventListener('change', delegatedStateChange);
84
+ }
85
+
86
+ function delegatedStateChange(event) {
87
+ if (event.target && event.target.id === stateId) {
88
+ findCities(event.target.value, document.getElementById(countryId).value, { userInitiated: true });
89
+ }
90
+ }
91
+
92
+ // ====== ***** DATA LOOKUP ***** ========================================= //
93
+
94
+ function findStates(countryCode, ctx) {
95
+ ctx = ctx || {};
96
+ if (ctx.userInitiated) findCities('', '', { silent: true });
97
+
98
+ if (offlineData && offlineData.states) {
99
+ buildStatesDropdown(offlineData.states[countryCode] || [], ctx);
100
+ return;
101
+ }
102
+
103
+ fetchJson(statesUrl, { country_id: countryCode }).then(function (data) {
104
+ buildStatesDropdown(data || [], ctx);
105
+ });
106
+ }
107
+
108
+ function findCities(stateCode, countryCode, ctx) {
109
+ ctx = ctx || {};
110
+ if (!hasCityField) return;
111
+
112
+ if (offlineData && offlineData.cities) {
113
+ var key = countryCode + ':' + stateCode;
114
+ buildCitiesDropdown(offlineData.cities[key] || [], ctx);
115
+ return;
116
+ }
117
+
118
+ fetchJson(citiesUrl, { country_id: countryCode, state_id: stateCode }).then(function (data) {
119
+ buildCitiesDropdown(data || [], ctx);
120
+ });
121
+ }
122
+
123
+ function fetchJson(url, params) {
124
+ var query = Object.keys(params)
125
+ .map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]); })
126
+ .join('&');
127
+
128
+ return fetch(url + '?' + query, {
129
+ headers: { Accept: 'application/json' },
130
+ credentials: 'same-origin'
131
+ }).then(function (response) {
132
+ if (!response.ok) return [];
133
+ return response.json();
134
+ }).catch(function () {
135
+ return [];
136
+ });
137
+ }
138
+
139
+ // ====== ***** DOM BUILDING ***** ======================================== //
140
+
141
+ function buildStatesDropdown(data, ctx) {
142
+ rebuildField(stateId, data, ctx.preselect, options.state_placeholder);
143
+ if (!ctx.silent) announceUpdate('State options updated');
144
+
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
+ }
157
+ }
158
+ }
159
+
160
+ function buildCitiesDropdown(data, ctx) {
161
+ rebuildField(cityId, data.map(function (name) { return [name, name]; }), ctx.preselect, options.city_placeholder);
162
+ if (!ctx.silent) announceUpdate('City options updated');
163
+ }
164
+
165
+ // 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.
169
+ function rebuildField(fieldId, pairs, preselectValue, placeholder) {
170
+ var el = document.getElementById(fieldId);
171
+ if (!el) return;
172
+
173
+ destroyEnhancement(fieldId);
174
+
175
+ if (!pairs || pairs.length === 0) {
176
+ var input = document.createElement('input');
177
+ input.type = 'text';
178
+ input.id = fieldId;
179
+ input.name = el.name;
180
+ input.className = el.className;
181
+ el.replaceWith(input);
182
+ return;
183
+ }
184
+
185
+ var select = document.createElement('select');
186
+ select.id = fieldId;
187
+ select.name = el.name;
188
+ select.className = el.className;
189
+
190
+ var blank = document.createElement('option');
191
+ blank.textContent = placeholder || '';
192
+ blank.value = '';
193
+ select.appendChild(blank);
194
+
195
+ pairs.forEach(function (pair) {
196
+ var opt = document.createElement('option');
197
+ opt.value = pair[0];
198
+ opt.textContent = pair[1];
199
+ if (preselectValue != null && String(pair[0]) === String(preselectValue)) {
200
+ opt.selected = true;
201
+ }
202
+ select.appendChild(opt);
203
+ });
204
+
205
+ el.replaceWith(select);
206
+ enhance(fieldId);
207
+ }
208
+
209
+ function enhance(fieldId) {
210
+ if (!fieldId || typeof enhancer !== 'function') return;
211
+ var el = document.getElementById(fieldId);
212
+ if (!el || el.tagName !== 'SELECT') return;
213
+
214
+ enhancedInstances[fieldId] = enhancer(el, enhancerOptions);
215
+ }
216
+
217
+ function destroyEnhancement(fieldId) {
218
+ var instance = enhancedInstances[fieldId];
219
+ if (instance && typeof instance.destroy === 'function') instance.destroy();
220
+ delete enhancedInstances[fieldId];
221
+ }
222
+
223
+ // ====== ***** ACCESSIBILITY ***** ======================================= //
224
+
225
+ function ensureLiveRegion() {
226
+ var existing = document.getElementById('country-state-select-live-region');
227
+ if (existing) return existing;
228
+
229
+ var region = document.createElement('div');
230
+ region.id = 'country-state-select-live-region';
231
+ region.setAttribute('aria-live', 'polite');
232
+ region.setAttribute('role', 'status');
233
+ region.style.position = 'absolute';
234
+ region.style.width = '1px';
235
+ region.style.height = '1px';
236
+ region.style.overflow = 'hidden';
237
+ region.style.clip = 'rect(0 0 0 0)';
238
+ document.body.appendChild(region);
239
+ return region;
240
+ }
241
+
242
+ function announceUpdate(message) {
243
+ if (liveRegion) liveRegion.textContent = message;
244
+ }
245
+ }
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin 'country_state_select', to: 'country_state_select/country_state_select.js'
4
+ pin 'country_state_select/controller', to: 'country_state_select/controller.js'
data/config/routes.rb CHANGED
@@ -1,8 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Rails.application.routes.draw do
4
- scope module: 'country_state_select' do
5
- post 'find_states' => 'cscs#find_states'
6
- post 'find_cities' => 'cscs#find_cities'
3
+ # Rails loads (evals) every engine's config/routes.rb as a plain file each
4
+ # time routes are drawn, expecting it to call `.draw` itself — that's why
5
+ # this isn't wrapped in the usual `Engine.routes.draw` block. Guarding on
6
+ # `draw_routes` here (rather than only documenting an opt-out) is what lets
7
+ # a host app disable the top-level /find_states, /find_cities routes and
8
+ # mount the engine at a custom path instead — see README "Custom route path".
9
+ if CountryStateSelect.configuration.draw_routes
10
+ Rails.application.routes.draw do
11
+ scope module: 'country_state_select' do
12
+ # GET is the correct verb for these read-only lookups (and sidesteps CSRF).
13
+ # POST is kept so existing host apps that wired the old routes keep working.
14
+ match 'find_states' => 'cscs#find_states', via: %i[get post]
15
+ match 'find_cities' => 'cscs#find_cities', via: %i[get post]
16
+ end
7
17
  end
8
18
  end
@@ -13,16 +13,26 @@ Gem::Specification.new do |spec|
13
13
  spec.description = 'Country State Select is a Ruby Gem that aims to make Country and State/Province selection a cinch in Ruby on Rails environments.'
14
14
  spec.homepage = 'https://github.com/arvindvyas/Country-State-Select'
15
15
  spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.1'
16
17
 
17
- spec.files = `git ls-files -z`.split("\x0")
18
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
19
+
20
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.start_with?('spec/dummy/') }
18
21
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
22
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
23
  spec.require_paths = ['lib']
21
24
 
22
25
  spec.add_development_dependency 'bundler'
23
- spec.add_development_dependency 'rake', '~> 12.3.3'
24
- spec.add_development_dependency 'rspec'
26
+ spec.add_development_dependency 'bundler-audit'
27
+ spec.add_development_dependency 'capybara'
28
+ spec.add_development_dependency 'importmap-rails'
29
+ spec.add_development_dependency 'propshaft'
30
+ spec.add_development_dependency 'puma'
31
+ spec.add_development_dependency 'rake', '~> 13.0'
32
+ spec.add_development_dependency 'rspec-rails'
33
+ spec.add_development_dependency 'sqlite3'
34
+ spec.add_development_dependency 'stimulus-rails'
25
35
 
26
36
  spec.add_runtime_dependency 'city-state'
27
- spec.add_runtime_dependency 'rails'
37
+ spec.add_runtime_dependency 'rails', '>= 7.0'
28
38
  end
data/docs/cities.md ADDED
@@ -0,0 +1,35 @@
1
+ # City support
2
+
3
+ City data comes from the same [city-state](https://github.com/loureirorg/city-state) gem as countries and states. Coverage varies a lot by country — it's strong for the US and reasonably populated for many others, but it is not exhaustive. If a state has no city data, the field falls back to a plain text input, the same way a country with no states falls back for the state field.
4
+
5
+ Coverage gaps are a known limitation of the underlying data, not something this gem can fix directly — but you're not stuck with it: see [Custom data sources](../README.md#custom-data-sources) in the README to swap in your own city data (a database table, a paid geodata API, etc.) without changing any of your views or JavaScript.
6
+
7
+ ## form_with
8
+
9
+ ```erb
10
+ <div data-controller="country-state-select">
11
+ <%= f.country_select :country %>
12
+ <%= f.state_select :state, :country %>
13
+ <%= f.city_select :city, :state, :country %>
14
+ </div>
15
+ ```
16
+
17
+ `city_select` takes `(method, state_method, country_method, options = {}, html_options = {})` — it reads the model's current state and country to decide whether to render a `<select>` (data available) or a text `<input>` (no data for that state/country pair).
18
+
19
+ ## simple_form
20
+
21
+ ```erb
22
+ <% options = { form: f, field_names: { state: :state_field, country: :country_field } } %>
23
+ <%= f.input :city_field, CountryStateSelect.city_options(options) %>
24
+ ```
25
+
26
+ ## Direct API
27
+
28
+ ```ruby
29
+ CountryStateSelect.collect_cities('CA', 'US')
30
+ # => ["Los Angeles", "San Francisco", ...]
31
+ ```
32
+
33
+ ## JavaScript
34
+
35
+ Any of the JS integrations (Stimulus, vanilla/Sprockets, or the legacy jQuery build) pick up a city field automatically when you pass `city_id` (or, for the Stimulus controller, add a `city` target) alongside `country_id`/`state_id`. See the README's [JavaScript setup](../README.md#javascript-setup) section.
data/docs/json-api.md ADDED
@@ -0,0 +1,41 @@
1
+ # JSON lookup API
2
+
3
+ The engine exposes two read-only, cacheable JSON endpoints. They back the bundled JavaScript, but are documented here so a SPA, mobile app, or hand-rolled integration can call them directly.
4
+
5
+ ## `GET /find_states`
6
+
7
+ **Params:** `country_id` — an ISO 3166-1 alpha-2 country code (e.g. `US`).
8
+
9
+ **Response:** an array of `[code, name]` pairs, or `[]` if the country has no states.
10
+
11
+ ```
12
+ GET /find_states?country_id=US
13
+ ```
14
+
15
+ ```json
16
+ [["AK", "Alaska"], ["AL", "Alabama"], ["AR", "Arkansas"], ...]
17
+ ```
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.
20
+
21
+ ## `GET /find_cities`
22
+
23
+ **Params:** `state_id`, `country_id`.
24
+
25
+ **Response:** an array of plain city name strings, or `[]` if `state_id` is blank or the pair has no data.
26
+
27
+ ```
28
+ GET /find_cities?state_id=CA&country_id=US
29
+ ```
30
+
31
+ ```json
32
+ ["Los Angeles", "San Francisco", "San Diego", ...]
33
+ ```
34
+
35
+ ## Caching
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.
38
+
39
+ ## Routes
40
+
41
+ These are mounted at `/find_states` and `/find_cities` by default. See the README's [Custom route path](../README.md#custom-route-path) section to change that.
@@ -0,0 +1,32 @@
1
+ # Migrating from 3.x to 4.0
2
+
3
+ ## Requirements changed
4
+
5
+ - **Rails >= 7.0** is now required (Propshaft/importmap-rails, the default modern asset pipeline this release targets, itself requires Rails 7+). Rails 3–6 support (`engine3.rb`, `railtie.rb`) has been removed. If you're on an older Rails, stay on the `country_state_select` 3.x series.
6
+ - **Ruby >= 3.1** is now required.
7
+
8
+ ## Nothing breaks if you do nothing
9
+
10
+ The Ruby API (`countries_collection`, `collect_states`, `state_options`, `city_options`, etc.) and the legacy jQuery/Chosen JavaScript (`vendor/assets/javascript/country_state_select.js.erb`) are unchanged and still work exactly as in 3.x, aside from the fixes below. You can upgrade the gem, bump your Rails version if needed, and keep everything else as-is.
11
+
12
+ ## Behavior fixes worth knowing about
13
+
14
+ - **`cities_collection` (the simple_form helper) now actually filters by country.** In 3.x it silently ignored the `:country` key in `field_names` and only ever looked up cities by state, which meant it never respected the country half of a state/country pair. Duplicate state codes across countries would have picked up the wrong country's cities. If you were relying on that behavior, it's now consistent with `collect_cities(state, country)`, i.e. it needs `field_names` to include both `:state` and `:country`.
15
+ - **`/find_states` and `/find_cities` are HTTP-cached by default** (`Cache-Control` + `ETag`, 1 day). This is new — 3.x sent no caching headers at all. Set `CountryStateSelect.configure { |c| c.cache_duration = nil }` to opt out.
16
+ - **`/find_states` and `/find_cities` now go through your configured data source and `only`/`except`/`priority` filters**, not the `city-state` gem directly. This only changes behavior if you set those new options.
17
+
18
+ ## New, opt-in features
19
+
20
+ Everything in the README's Configuration section — `priority_countries`, `only`/`except` filtering, `flags`, `dial_codes`, `localize_names`, a pluggable `data_source`, `draw_routes` — is new in 4.0 and defaults to off/unchanged, so adopting the gem doesn't require adopting any of it.
21
+
22
+ ## If you want to modernize your JavaScript
23
+
24
+ You don't have to — the legacy jQuery/Chosen build keeps working — but if you want to drop the jQuery dependency:
25
+
26
+ 1. Remove `//= require country_state_select` and `//= require chosen-jquery` (and the corresponding CSS `require`s) from your asset manifest.
27
+ 2. Follow the README's [JavaScript setup](../README.md#javascript-setup) for whichever pipeline you use (importmap+Stimulus is the default for Rails 7/8 apps).
28
+ 3. If you were using `chosen_ui: true`, the modern equivalent is [Tom Select](https://tom-select.js.org/) — Chosen is unmaintained. The Stimulus controller has a `tom-select` value for this; the vanilla/npm build takes an `enhance` callback for the same purpose.
29
+
30
+ ## If you want to switch from simple_form to `form_with`
31
+
32
+ Also optional. `f.country_select`, `f.state_select`, `f.city_select` are new `form_with`/`form_for` builder methods that don't require simple_form — see the README's [Using it in a form](../README.md#using-it-in-a-form) section. Your existing simple_form-based views keep working unchanged.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 7.0.0'
6
+ # Rails 7.0/7.1's sqlite3 adapter only supports the 1.4.x gem; the gemspec's
7
+ # unpinned `sqlite3` dependency otherwise resolves to the latest 2.x series.
8
+ gem 'sqlite3', '~> 1.4'
9
+
10
+ gemspec path: '..'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 7.1.0'
6
+
7
+ gemspec path: '..'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 7.2.0'
6
+
7
+ gemspec path: '..'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 8.0.0'
6
+
7
+ gemspec path: '..'
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CountryStateSelect
4
+ # Global, per-app configuration. Set it from an initializer:
5
+ #
6
+ # CountryStateSelect.configure do |config|
7
+ # config.priority_countries = %w[US IN GB]
8
+ # config.flags = true
9
+ # config.cache_duration = 1.week
10
+ # end
11
+ #
12
+ # Every option here is a *default* — the equivalent per-call keyword
13
+ # (e.g. `countries_collection(priority: ...)`) always wins.
14
+ class Configuration
15
+ # Data source answering countries/states/cities lookups. Accepts an
16
+ # object implementing DataSources::Base or a symbol (:city_state).
17
+ attr_accessor :data_source
18
+
19
+ # ISO codes listed first in the country dropdown (issue #66).
20
+ attr_accessor :priority_countries
21
+
22
+ # Whitelist / blacklist of ISO codes applied to every country lookup.
23
+ attr_accessor :only_countries, :except_countries
24
+
25
+ # Prepend the emoji flag to country labels ("🇮🇳 India").
26
+ attr_accessor :flags
27
+
28
+ # Append the international dial code to country labels ("India (+91)").
29
+ attr_accessor :dial_codes
30
+
31
+ # Translate country names through I18n / the `countries` gem when
32
+ # translations are available. Labels fall back to the English name.
33
+ attr_accessor :localize_names
34
+
35
+ # How long the JSON lookup endpoints are HTTP-cached (they serve
36
+ # static data). Set to nil/false to disable cache headers.
37
+ attr_accessor :cache_duration
38
+
39
+ # Draw the legacy top-level `/find_states` + `/find_cities` routes in
40
+ # the host app. Disable if you mount the engine at a custom path:
41
+ #
42
+ # config.draw_routes = false
43
+ # # config/routes.rb: mount CountryStateSelect::Rails::Engine => '/csc'
44
+ attr_accessor :draw_routes
45
+
46
+ def initialize
47
+ @data_source = :city_state
48
+ @priority_countries = []
49
+ @only_countries = nil
50
+ @except_countries = nil
51
+ @flags = false
52
+ @dial_codes = false
53
+ @localize_names = false
54
+ @cache_duration = 86_400 # 1 day, in seconds
55
+ @draw_routes = true
56
+ end
57
+
58
+ def data_source_instance
59
+ @data_source_instance ||=
60
+ case data_source
61
+ when :city_state, nil then DataSources::CityState.new
62
+ when Symbol then raise ArgumentError, "Unknown data source: #{data_source.inspect}"
63
+ else data_source
64
+ end
65
+ end
66
+
67
+ # Invalidate the memoized adapter when the source changes at runtime
68
+ # (mostly useful in tests).
69
+ def data_source=(source)
70
+ @data_source_instance = nil
71
+ @data_source = source
72
+ end
73
+ end
74
+
75
+ class << self
76
+ def configuration
77
+ @configuration ||= Configuration.new
78
+ end
79
+
80
+ def configure
81
+ yield(configuration)
82
+ configuration
83
+ end
84
+
85
+ def reset_configuration!
86
+ @configuration = Configuration.new
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CountryStateSelect
4
+ # Optional label decoration: flag emoji and/or international dial code.
5
+ # Both read from a small bundled table so the gem stays dependency-free;
6
+ # a country missing from the table (rare, mostly disputed territories)
7
+ # simply renders without the decoration instead of raising.
8
+ module CountryMetadata
9
+ DIAL_CODES = YAML.safe_load_file(
10
+ File.expand_path('data/dial_codes.yml', __dir__)
11
+ ).freeze
12
+
13
+ def self.decorate(code, name)
14
+ label = name
15
+ label = "#{flag(code)} #{label}" if CountryStateSelect.configuration.flags && flag(code)
16
+ label = "#{label} (+#{DIAL_CODES[code.to_s]})" if CountryStateSelect.configuration.dial_codes && DIAL_CODES[code.to_s]
17
+ label
18
+ end
19
+
20
+ # Converts a two-letter ISO code to its flag emoji via the Unicode
21
+ # "Regional Indicator Symbol" trick (each letter A-Z maps to U+1F1E6..).
22
+ def self.flag(code)
23
+ chars = code.to_s.upcase.chars
24
+ return nil unless chars.length == 2 && chars.all? { |c| c.match?(/[A-Z]/) }
25
+
26
+ chars.map { |c| [0x1F1E6 + (c.ord - 'A'.ord)].pack('U') }.join
27
+ end
28
+ end
29
+ end