country-select-pro 6.1.3 → 6.2.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.
@@ -1,471 +1,930 @@
1
+ /*! CountrySelect Pro v6.2.0 | MIT | https://github.com/nikmauro/country-select-pro */
1
2
  /**
2
- * CountrySelect Pro v6.1.3
3
+ * CountrySelect Pro v6.2.0
3
4
  * ---------------------------------------------------------------------------
4
- * Features:
5
- * - SVG Arrow Down icon
6
- * - HTMX Auto-initialization
7
- * - Improved Validation UI
8
- * - Zero Dependencies
9
- * - 4x3, 1x1 Flags
5
+ * Vanilla JS country selector — zero dependencies
6
+ * - Bootstrap 5 ή χωρίς framework: θέμα με CSS μεταβλητές --cs-* (fallback στις --bs-*, άρα και dark mode του Bootstrap)
7
+ * - Αυτόματο init σε <input> / <select> με class .country-select (στατικό HTML, htmx, MutationObserver)
8
+ * - Λίστα: ενσωματωμένη (data.json) ή δική σου — array / function, JSON inline, URL (ajax), <option> ενός <select>
9
+ * - Πληκτρολόγιο + ARIA combobox / listbox · αναζήτηση χωρίς τόνους, με κωδικό χώρας ή κλήσης
10
+ * - Ονόματα χωρών σε κάθε γλώσσα (Intl.DisplayNames — data-lang="el")
10
11
  * ---------------------------------------------------------------------------
11
12
  */
13
+ (function (global) {
14
+ 'use strict';
12
15
 
16
+ if (typeof document === 'undefined') return;
17
+ if (global.CountrySelect && global.CountrySelect.version) return; // ήδη φορτωμένο στη σελίδα — ένα αντίγραφο αρκεί
13
18
 
14
- class CountrySelect {
19
+ const VERSION = '6.2.0';
20
+ const DEFAULT_BASE_URL = 'https://cdn.jsdelivr.net/gh/nikmauro/country-select-pro@6.1.3/';
15
21
 
16
-
17
- constructor(element, options = {}) {
18
- this.input = element;
19
- if (!this.input) return;
22
+ /* ---------------------------------------------------------------- βοηθητικά */
20
23
 
21
- // 1. ΕΛΕΓΧΟΣ HTMX: Αν έχει ήδη γίνει init, σταμάτα αμέσως
22
- if (this.input.dataset.csInitialized === '1') return;
23
-
24
- // 2. ΚΛΕΙΔΩΜΑ HTMX: Σημειώνουμε ότι έγινε init ΠΡΙΝ τρέξει το async _init()
25
- this.input.dataset.csInitialized = '1';
24
+ const I18N = {
25
+ en: {placeholder: 'Select', searchPlaceholder: 'Search...', noResults: 'Not found', loading: 'Loading...', loadError: 'Could not load the list', results: '{n} results', listLabel: 'Countries'},
26
+ el: {placeholder: 'Επιλέξτε', searchPlaceholder: 'Αναζήτηση...', noResults: 'Δεν βρέθηκε', loading: 'Φόρτωση...', loadError: 'Η λίστα δεν φορτώθηκε', results: '{n} αποτελέσματα', listLabel: 'Χώρες'}
27
+ };
26
28
 
27
- // Configuration
28
- this.schema = this.input.dataset.schema || "{img} {name}";
29
- this.schemaReturn = this.input.dataset.schemaReturn || this.schema;
30
- this.valueType = this.input.dataset.valueType || "code";
31
- this.rowLimit = parseInt(this.input.dataset.countryRowValues) || 5;
32
- this.hasSearch = this.input.dataset.countrySearch !== "false";
33
- this.dropdownWidth = this.input.dataset.dropdownWidth || "auto";
29
+ // Κωδικός κλήσης κοινός σε πολλές χώρες → ποια εννοεί μια τιμή που είναι μόνο ο κωδικός (+1 = ΗΠΑ, όχι American Samoa)
30
+ const PHONE_PRIMARY = {'+1': 'US', '+7': 'RU', '+44': 'GB', '+47': 'NO', '+61': 'AU', '+64': 'NZ', '+212': 'MA', '+262': 'RE', '+358': 'FI', '+500': 'FK', '+590': 'GP', '+599': 'CW', '+672': 'NF'};
34
31
 
35
- // Preferred Logic
36
- const preferredAttr = this.input.dataset.preferred || "";
37
- this.preferredCodes = preferredAttr ? preferredAttr.split(',').map(c => c.trim().toUpperCase()) : [];
32
+ // Συνώνυμα για την αναζήτηση
33
+ const ALIASES = {
34
+ GB: 'uk britain england', US: 'usa america', GR: 'hellas ellada ελλαδα', CY: 'kypros κυπρος', AE: 'uae emirates', NL: 'holland',
35
+ CZ: 'czech', KR: 'south korea', KP: 'north korea', DE: 'deutschland', ES: 'espana', CH: 'schweiz suisse', VA: 'vatican',
36
+ CI: "cote d'ivoire", MK: 'macedonia', TR: 'turkey turkiye'
37
+ };
38
38
 
39
- this.config = {
40
- placeholder: this.input.dataset.placeholder || "Select",
41
- ...options
42
- };
39
+ const ARROW = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3E%3C/svg%3E\")";
40
+
41
+ const str = (v) => (v == null ? '' : String(v));
42
+ const norm = (s) => str(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); // χωρίς τόνους / διακριτικά
43
+ const esc = (s) => str(s).replace(/[&<>"']/g, (ch) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[ch]));
44
+ const codeList = (v) => (Array.isArray(v) ? v : str(v).split(',')).map((c) => str(c).trim().toUpperCase()).filter(Boolean);
45
+ const yes = (v, def) => (v == null || v === '' ? def : !(v === false || v === 'false' || v === '0' || v === 'no'));
46
+ const phoneOf = (v) => { const d = str(v).replace(/\D/g, '').replace(/^00/, ''); return d ? '+' + d : ''; };
47
+ const isFlagCode = (c) => /^[a-z]{2}(-[a-z]{2,4})?$/i.test(c);
48
+ const slash = (u) => (u && !/\/$/.test(u) ? u + '/' : u);
49
+
50
+ /* ---------------------------------------------------------------- δεδομένα */
51
+
52
+ const jsonCache = {}; // url → Promise (ένα αίτημα ανά σελίδα, όσα instances κι αν υπάρχουν)
53
+
54
+ function fetchJSON(url, init) {
55
+ if (!jsonCache[url]) {
56
+ jsonCache[url] = fetch(url, init)
57
+ .then((res) => {
58
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url);
59
+ return res.json();
60
+ })
61
+ .catch((err) => {
62
+ delete jsonCache[url]; // να ξαναδοκιμάσει την επόμενη φορά
63
+ throw err;
64
+ });
65
+ }
66
+ return jsonCache[url];
67
+ }
43
68
 
44
- this.countries = [];
45
- this.filteredCountries = [];
46
- this.isOpen = false;
47
- this.activeIndex = -1;
48
- this.rowHeight = 44;
49
-
50
- // Base URL για το JSON και τα SVG flags
51
- this.baseUrl = 'https://cdn.jsdelivr.net/gh/nikmauro/country-select-pro@6.1.3/';
52
-
53
- this._init();
69
+ // Οποιαδήποτε μορφή λίστας → πίνακας: [...] · data.json της βιβλιοθήκης · {countries|items|data|results: [...]} · {"GR": "Greece"}
70
+ function toArray(data) {
71
+ if (!data) return [];
72
+ if (Array.isArray(data)) return data;
73
+ if (data.data && Array.isArray(data.data.objects)) return data.data.objects;
74
+ for (const k of ['countries', 'items', 'data', 'results']) {
75
+ if (Array.isArray(data[k])) return data[k];
76
+ }
77
+ if (typeof data === 'object') {
78
+ return Object.keys(data).map((code) => (data[code] && typeof data[code] === 'object' ? Object.assign({code}, data[code]) : {code, name: data[code]}));
79
+ }
80
+ return [];
54
81
  }
55
82
 
56
- async _init() {
57
- this._applyStyles();
58
- this._setupDOM();
59
- this._bindEvents();
60
- await this._loadData();
83
+ // Ένα στοιχείο σε οποιαδήποτε μορφή ("GR" · {code, name, phone, flag, value} · μορφή data.json · {iso2, dial_code} κ.λπ.)
84
+ function toItem(x) {
85
+ if (x == null || x === '') return null;
86
+ if (typeof x !== 'object') x = {code: x};
87
+ const cc = x.calling_codes || x.callingCodes;
88
+ const name = str(x.name != null ? x.name : x.label != null ? x.label : x.text != null ? x.text : x.names && x.names.common).trim();
89
+ const item = {
90
+ code: str(x.code || x.iso2 || x.iso || x.alpha2 || (x.codes && x.codes.alpha_2)).trim().toUpperCase(),
91
+ nameEn: name,
92
+ fixedName: !!name && !(x.names && x.names.common), // όνομα από τον χρήστη → δεν μεταφράζεται
93
+ phone: phoneOf(x.phone || x.dial_code || x.dialCode || (Array.isArray(cc) ? cc[0] : cc))
94
+ };
95
+ if (x.value != null && x.value !== '') item.value = str(x.value);
96
+ if (typeof x.flag === 'string' || x.flag === false) item.flag = x.flag; // στο data.json το flag είναι {emoji} → αγνοείται
97
+ const kw = x.keywords || x.aliases;
98
+ if (kw) item.keywords = Array.isArray(kw) ? kw.join(' ') : str(kw);
99
+ return item.code || item.value ? item : null;
61
100
  }
62
101
 
63
- _applyStyles() {
64
- const styles = `
65
- .cs-no-results {
66
- padding: 10px;
67
- text-align: center;
68
- color: #999;
69
- cursor: default;
70
- font-style: italic;
102
+ /* ---------------------------------------------------------------- CSS */
103
+
104
+ const CSS = `
105
+ .cs-wrapper { position: relative; display: block; width: 100%; font-family: inherit; text-align: start; outline: none; color: var(--cs-color, var(--bs-body-color, #212529)); }
106
+ .cs-wrapper, .cs-wrapper *, .cs-wrapper *::before, .cs-wrapper *::after { box-sizing: border-box; }
107
+ .cs-trigger {
108
+ position: relative; display: flex; align-items: center; gap: 8px; cursor: pointer;
109
+ min-height: calc(1.5em + .75rem + 2px); padding-block: .375rem; padding-inline: .75rem 2.25rem;
110
+ font-size: var(--cs-font-size, 1rem); font-weight: 400; line-height: 1.5;
111
+ color: var(--cs-color, var(--bs-body-color, #212529)); background-color: var(--cs-bg, var(--bs-body-bg, #fff)); background-clip: padding-box;
112
+ border: 1px solid var(--cs-border-color, var(--bs-border-color, #ced4da)); border-radius: var(--cs-radius, var(--bs-border-radius, .375rem));
113
+ transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
71
114
  }
115
+ .cs-trigger::after {
116
+ content: ""; position: absolute; inset-inline-end: .75rem; top: 50%; width: 16px; height: 12px; margin-top: -6px; pointer-events: none;
117
+ background-color: currentColor; -webkit-mask: ${ARROW} no-repeat center / 16px 12px; mask: ${ARROW} no-repeat center / 16px 12px;
118
+ transition: transform .15s ease-in-out;
119
+ }
120
+ .cs-wrapper.open .cs-trigger::after { transform: rotate(180deg); }
121
+ .cs-wrapper:focus-within .cs-trigger {
122
+ border-color: var(--cs-focus-border, #86b7fe); outline: 0;
123
+ box-shadow: 0 0 0 .25rem var(--cs-focus-ring, var(--bs-focus-ring-color, rgba(13, 110, 253, .25)));
124
+ }
125
+ .cs-selected-content { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; white-space: nowrap; line-height: 1; }
126
+ .cs-selected-content.cs-placeholder { color: var(--cs-muted, var(--bs-secondary-color, #6c757d)); }
127
+
128
+ .input-group > .cs-wrapper { flex: 1 1 auto; width: 1%; min-width: 0; }
129
+ .input-group > .cs-wrapper:not(:last-child) .cs-trigger { border-start-end-radius: 0; border-end-end-radius: 0; }
130
+ .input-group > * + .country-select + .cs-wrapper .cs-trigger { border-start-start-radius: 0; border-end-start-radius: 0; }
72
131
 
73
- .cs-flag-img {
74
- width: 22px !important;
75
- height: 16px !important;
76
- object-fit: cover; /* Ή contain, ανάλογα πώς φαίνεται καλύτερα */
77
- vertical-align: middle;
78
- margin-right: 8px;
79
- border-radius: 2px;
80
- display: inline-block;
132
+ .cs-wrapper.is-invalid .cs-trigger, .is-invalid + .cs-wrapper .cs-trigger, .was-validated :invalid + .cs-wrapper .cs-trigger {
133
+ border-color: var(--cs-invalid, var(--bs-form-invalid-border-color, #dc3545)) !important;
81
134
  }
82
-
83
- .cs-wrapper { position: relative; font-family: inherit; width: 100%; outline: none; }
84
- .cs-trigger {
85
- /* Bootstrap 5 Exact Specs */
86
- padding: 0.375rem 2.25rem 0.375rem 0.75rem;
87
- font-size: 1rem;
88
- font-weight: 400;
89
- line-height: 1.5;
90
- color: #212529;
91
- background-color: #fff;
92
- background-clip: padding-box;
93
- border: 1px solid #ced4da;
94
- border-radius: 0.375rem;
95
- transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
96
- display: flex; align-items: center; gap: 8px; cursor: pointer;
97
- min-height: calc(1.5em + 0.75rem + 2px);
98
- box-sizing: border-box; position: relative;
99
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3E%3C/svg%3E");
100
- background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 16px 12px;
101
- }
102
-
103
- .input-group > .cs-wrapper { flex: 1 1 auto; width: 1%; }
104
- .cs-wrapper.is-invalid .cs-trigger { border-color: #dc3545 !important; }
105
- .cs-error-msg { display: none; color: #dc3545; font-size: 0.825em; margin-top: 4px; position: absolute; left: 0; top: 100%; width: 100%; z-index: 10; }
106
- .cs-wrapper.is-invalid .cs-error-msg { display: block; }
107
-
108
- /* Focus state Bootstrap 5 */
109
- .cs-wrapper:focus-within .cs-trigger {
110
- border-color: #86b7fe;
111
- outline: 0;
112
- box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
135
+ .cs-error-msg { display: none; position: absolute; left: 0; top: 100%; width: 100%; z-index: 10; margin-top: 4px; font-size: .825em; color: var(--cs-invalid, var(--bs-form-invalid-color, #dc3545)); }
136
+ .cs-wrapper.is-invalid .cs-error-msg:not(:empty) { display: block; }
137
+ .cs-wrapper.cs-disabled .cs-trigger, :disabled + .cs-wrapper .cs-trigger { cursor: default; opacity: 1; background-color: var(--cs-disabled-bg, var(--bs-secondary-bg, #e9ecef)); }
138
+
139
+ .cs-dropdown {
140
+ position: absolute; left: 0; right: 0; z-index: var(--cs-z-index, 2050); display: none; max-width: 95vw; margin-top: .125rem; overflow: hidden;
141
+ color: var(--cs-color, var(--bs-body-color, #212529)); background: var(--cs-dropdown-bg, var(--cs-bg, var(--bs-body-bg, #fff)));
142
+ border: 1px solid var(--cs-dropdown-border-color, var(--bs-border-color-translucent, rgba(0, 0, 0, .15)));
143
+ border-radius: var(--cs-radius, var(--bs-border-radius, .375rem)); box-shadow: var(--cs-shadow, 0 .5rem 1rem rgba(0, 0, 0, .175));
144
+ }
145
+ .cs-wrapper.drop-up .cs-dropdown { bottom: calc(100% + .25rem); top: auto; box-shadow: var(--cs-shadow-up, 0 -.5rem 1rem rgba(0, 0, 0, .175)); }
146
+ .cs-wrapper.open .cs-dropdown { display: block; }
147
+
148
+ .cs-search-box { padding: 8px; background: var(--cs-search-bg, var(--bs-tertiary-bg, #f8f9fa)); border-bottom: 1px solid var(--cs-divider-color, var(--bs-border-color, #dee2e6)); }
149
+ .cs-search-input { font-size: .875rem; }
150
+ .cs-wrapper input.cs-search-input { /* πιο ισχυρό από γενικούς κανόνες του θέματος (input[type=text] { ... }) */
151
+ display: block; width: 100%; height: auto; margin: 0; padding: .375rem .75rem; font-family: inherit; line-height: 1.5; outline: none;
152
+ color: inherit; background: var(--cs-bg, var(--bs-body-bg, #fff)); box-shadow: none;
153
+ border: 1px solid var(--cs-border-color, var(--bs-border-color, #ced4da)); border-radius: .25rem;
154
+ }
155
+ .cs-wrapper input.cs-search-input:focus { border-color: var(--cs-focus-border, #86b7fe); box-shadow: 0 0 0 .2rem var(--cs-focus-ring, var(--bs-focus-ring-color, rgba(13, 110, 253, .25))); }
156
+ @media (pointer: coarse) { .cs-search-input { font-size: 16px; } }
157
+
158
+ .cs-list { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scroll-behavior: auto; min-height: 50px; padding: .375rem 0; }
159
+ .cs-option { display: flex; align-items: center; gap: 10px; padding: .5rem .75rem; font-size: .9rem; cursor: pointer; transition: background-color .1s; }
160
+ .cs-option:hover { background-color: var(--cs-hover-bg, var(--bs-tertiary-bg, #f8f9fa)); }
161
+ .cs-option[aria-selected="true"] { font-weight: 600; }
162
+ .cs-option.active { background-color: var(--cs-active-bg, var(--bs-primary, #0d6efd)); color: var(--cs-active-color, #fff); }
163
+ .cs-divider { height: 1px; margin: .375rem 0; pointer-events: none; background: var(--cs-divider-color, var(--bs-border-color, #dee2e6)); }
164
+ .cs-no-results, .cs-loading, .cs-load-error { padding: 10px; text-align: center; font-style: italic; cursor: default; color: var(--cs-muted, var(--bs-secondary-color, #6c757d)); }
165
+ .cs-load-error { color: var(--cs-invalid, var(--bs-form-invalid-color, #dc3545)); }
166
+
167
+ .cs-flag-img { display: inline-block; width: 22px !important; height: 16px !important; object-fit: cover; vertical-align: middle; margin-inline-end: 8px; border-radius: 2px; }
168
+ .cs-wrapper img { width: 20px !important; height: 14px !important; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
169
+ .cs-hidden { display: none !important; }
170
+ .cs-sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
171
+
172
+ @media (prefers-reduced-motion: reduce) { .cs-trigger, .cs-trigger::after, .cs-option { transition: none; } }
173
+ @media (forced-colors: active) {
174
+ .cs-trigger::after { forced-color-adjust: none; background-color: CanvasText; }
175
+ .cs-option.active { forced-color-adjust: none; background-color: Highlight; color: HighlightText; }
176
+ }
177
+ `;
178
+
179
+ function injectStyles() {
180
+ if (CountrySelect.defaults.css === false || document.getElementById('cs-styles')) return;
181
+ const style = document.createElement('style');
182
+ style.id = 'cs-styles';
183
+ style.textContent = CSS;
184
+ document.head.appendChild(style);
185
+ }
186
+
187
+ /* ---------------------------------------------------------------- instances */
188
+
189
+ const instances = new Set();
190
+ let uid = 0;
191
+ let globalBound = false;
192
+
193
+ function bindGlobal() {
194
+ if (globalBound) return;
195
+ globalBound = true;
196
+ document.addEventListener('click', (e) => {
197
+ instances.forEach((inst) => {
198
+ if (!inst.input.isConnected) return inst.destroy(); // βγήκε από τη σελίδα (htmx swap κ.λπ.) → καθάρισμα
199
+ if (inst.isOpen && !inst.wrapper.contains(e.target)) inst.close();
200
+ });
201
+ });
202
+ }
203
+
204
+ class CountrySelect {
205
+
206
+ /**
207
+ * @param {HTMLInputElement|HTMLSelectElement|string} element input / select ή CSS selector
208
+ * @param {Object} [options] υπερισχύουν των data-* (βλ. README)
209
+ */
210
+ constructor(element, options) {
211
+ const o = options || {};
212
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
213
+ this.input = el;
214
+ if (!el || !el.parentNode) {
215
+ if (typeof element === 'string' && !el) console.warn('CountrySelect: element not found:', element);
216
+ return;
113
217
  }
114
218
 
115
- .flag-emoji {
116
- font-size: 1.2em; /* Για να φαίνεται πιο καθαρό το emoji */
117
- margin-right: 8px;
219
+ // Ήδη ενεργό στο ίδιο στοιχείο → το ίδιο instance (με νέες options: ξανά από την αρχή με αυτές)
220
+ if (CountrySelect._isLive(el)) {
221
+ if (!Object.keys(o).length) return el._countrySelect;
222
+ el._countrySelect.destroy();
118
223
  }
224
+ // Παλιό instance που έχασε το wrapper του (π.χ. morph του htmx) ή αντίγραφο HTML (cloneNode, ιστορικό htmx) → καθάρισμα και ξανά
225
+ if (el._countrySelect) el._countrySelect.destroy();
226
+ const stale = el.nextElementSibling;
227
+ if (stale && stale.classList.contains('cs-wrapper')) stale.remove();
228
+
229
+ el._countrySelect = this;
230
+ el.dataset.csInitialized = '1';
231
+ instances.add(this);
232
+
233
+ const d = el.dataset;
234
+ const defs = CountrySelect.defaults;
235
+ const pick = (key, dataKey) => (o[key] !== undefined ? o[key] : d[dataKey || key] !== undefined ? d[dataKey || key] : defs[key]);
236
+
237
+ this.options = o;
238
+ this._id = 'cs' + (++uid);
239
+ this._off = [];
240
+ this.isSelect = el.tagName === 'SELECT';
241
+
242
+ // Ρυθμίσεις: options > data-* > CountrySelect.defaults (window.CountrySelectConfig) > προεπιλογές
243
+ this.schema = pick('schema') || '{img} {name}';
244
+ this.schemaReturn = pick('schemaReturn') || this.schema;
245
+ this.valueType = pick('valueType') || 'code';
246
+ this.rowLimit = parseInt(pick('rows', 'countryRowValues'), 10) || 5;
247
+ this.hasSearch = yes(pick('search', 'countrySearch'), true);
248
+ this.dropdownWidth = pick('dropdownWidth') || 'auto';
249
+ this.preferredCodes = codeList(pick('preferredCountries', 'preferred'));
250
+ this.onlyCodes = codeList(pick('onlyCountries'));
251
+ this.excludeCodes = codeList(pick('excludeCountries'));
252
+ this.defaultCountry = str(pick('defaultCountry')).trim();
253
+ this.sort = yes(pick('sort'), true);
254
+ this.showFlags = yes(pick('flags'), true);
255
+ let lang = str(pick('lang'));
256
+ if (lang === 'auto') lang = document.documentElement.lang || navigator.language || '';
257
+ this.lang = lang;
258
+ this.baseUrl = slash(pick('baseUrl') || CountrySelect.baseUrl);
259
+ this.flagPath = pick('flagPath') || this.baseUrl + 'dist/flags/4x3/{code}.svg';
260
+ this.source = pick('countries') != null ? pick('countries') : pick('dataUrl', 'src');
261
+
262
+ const t = I18N[lang.slice(0, 2).toLowerCase()] || I18N.en;
263
+ const emptyOption = this.isSelect ? Array.from(el.options).find((op) => op.value === '') : null;
264
+ this.config = Object.assign({}, t, {
265
+ placeholder: d.placeholder || defs.placeholder || (emptyOption && emptyOption.text.trim()) || t.placeholder,
266
+ searchPlaceholder: d.searchPlaceholder || defs.searchPlaceholder || t.searchPlaceholder,
267
+ noResults: d.noResults || defs.noResults || t.noResults
268
+ }, o);
269
+
270
+ this.countries = [];
271
+ this.filteredCountries = [];
272
+ this.ordered = []; // όπως φαίνεται στη λίστα: προτιμώμενες πρώτα, μετά οι υπόλοιπες
273
+ this._els = []; // τα .cs-option, ίδια σειρά με το ordered
274
+ this.selected = null;
275
+ this.isOpen = false;
276
+ this.activeIndex = -1;
277
+ this.rowHeight = 44;
278
+
279
+ /** Promise — ολοκληρώνεται όταν φορτώσει η λίστα */
280
+ this.ready = this._init();
281
+ }
282
+
283
+ static _isLive(el) {
284
+ const inst = el && el._countrySelect;
285
+ return !!(inst && !inst._destroyed && el.dataset.csInitialized === '1' && inst.wrapper && inst.wrapper.isConnected);
286
+ }
287
+
288
+ async _init() {
289
+ injectStyles();
290
+ this._setupDOM();
291
+ this._bindEvents();
292
+ await this._loadData();
293
+ return this;
294
+ }
295
+
296
+ /* ------------------------------------------------------------ DOM */
297
+
298
+ _setupDOM() {
299
+ const el = this.input, id = this._id, c = this.config;
300
+ this._orig = {style: el.getAttribute('style'), tabindex: el.getAttribute('tabindex')};
301
+
302
+ Object.assign(el.style, {position: 'absolute', opacity: '0', width: '1px', height: '1px', pointerEvents: 'none'});
303
+ el.tabIndex = -1; // στο Tab πηγαίνει μόνο στο ορατό control
304
+
305
+ const w = this.wrapper = document.createElement('div');
306
+ w.className = 'cs-wrapper';
307
+ w.tabIndex = 0;
308
+ w.setAttribute('role', 'combobox');
309
+ w.setAttribute('aria-haspopup', 'listbox');
310
+ w.setAttribute('aria-expanded', 'false');
311
+ w.setAttribute('aria-controls', id + '-list');
312
+ const labelledBy = el.getAttribute('aria-labelledby');
313
+ const label = el.getAttribute('aria-label') || (el.labels && el.labels[0] ? el.labels[0].textContent.trim() : '');
314
+ if (labelledBy) w.setAttribute('aria-labelledby', labelledBy);
315
+ else if (label) w.setAttribute('aria-label', label);
316
+ if (el.getAttribute('aria-describedby')) w.setAttribute('aria-describedby', el.getAttribute('aria-describedby'));
317
+ if (el.required) w.setAttribute('aria-required', 'true');
318
+
319
+ w.innerHTML =
320
+ '<div class="cs-trigger"><span class="cs-selected-content"></span></div>' +
321
+ '<div class="cs-error-msg"></div>' +
322
+ '<div class="cs-dropdown">' +
323
+ '<div class="cs-search-box' + (this.hasSearch ? '' : ' cs-hidden') + '">' +
324
+ '<input type="text" class="cs-search-input" autocomplete="off" autocapitalize="off" spellcheck="false" tabindex="-1" aria-autocomplete="list">' +
325
+ '</div>' +
326
+ '<div class="cs-list" role="listbox" id="' + id + '-list"><div class="cs-loading">' + esc(c.loading) + '</div></div>' +
327
+ '</div>' +
328
+ '<div class="cs-sr-only" aria-live="polite"></div>';
329
+
330
+ this.contentEl = w.querySelector('.cs-selected-content');
331
+ this.dropdownEl = w.querySelector('.cs-dropdown');
332
+ this.searchEl = w.querySelector('.cs-search-input');
333
+ this.listEl = w.querySelector('.cs-list');
334
+ this.liveEl = w.querySelector('.cs-sr-only');
335
+
336
+ this.searchEl.placeholder = c.searchPlaceholder;
337
+ this.searchEl.setAttribute('aria-label', c.searchPlaceholder);
338
+ this.searchEl.setAttribute('aria-controls', id + '-list');
339
+ this.listEl.setAttribute('aria-label', label || c.listLabel);
340
+ this.dropdownEl.style.width = this.dropdownWidth === 'auto' ? '100%' : this.dropdownWidth;
341
+ if (!el.value) this._showPlaceholder();
342
+
343
+ el.parentNode.insertBefore(w, el.nextSibling);
344
+ if (document.activeElement === el) w.focus({preventScroll: true}); // autofocus
345
+ this._syncDisabled();
346
+ }
347
+
348
+ _bindEvents() {
349
+ const w = this.wrapper, el = this.input;
350
+ const on = (target, type, fn) => {
351
+ target.addEventListener(type, fn);
352
+ this._off.push(() => target.removeEventListener(type, fn));
353
+ };
354
+
355
+ on(w.querySelector('.cs-trigger'), 'click', (e) => {
356
+ e.stopPropagation();
357
+ this.toggle();
358
+ });
359
+ on(w, 'keydown', (e) => this._onKey(e));
360
+ on(this.searchEl, 'input', () => this._onSearch());
361
+ on(this.listEl, 'click', (e) => {
362
+ const opt = e.target.closest('.cs-option');
363
+ if (!opt) return;
364
+ e.stopPropagation();
365
+ const country = this.ordered[+opt.dataset.index];
366
+ if (country) this._choose(country);
367
+ });
119
368
 
120
- .cs-dropdown {
121
- position: absolute; left: 0; right: 0; background: #fff;
122
- border: 1px solid rgba(0,0,0,.15); border-radius: 0.375rem;
123
- z-index: 2050; display: none; margin-top: 0.125rem;
124
- box-shadow: 0 0.5rem 1rem rgba(0,0,0,.175);
125
- overflow: hidden; max-width: 95vw;
369
+ // Αλλαγή της τιμής απ' έξω (autofill, άλλο script που στέλνει change) → ενημέρωση του control
370
+ const external = () => {
371
+ if (!this._emitting) this._syncFromInput();
372
+ };
373
+ on(el, 'change', external);
374
+ on(el, 'input', external);
375
+ on(el, 'invalid', () => {
376
+ w.classList.add('is-invalid');
377
+ w.setAttribute('aria-invalid', 'true');
378
+ });
379
+ if (el.form) on(el.form, 'reset', () => setTimeout(() => {
380
+ w.classList.remove('is-invalid');
381
+ w.removeAttribute('aria-invalid');
382
+ this._syncFromInput();
383
+ }, 0));
384
+ // <label for> → εστίαση στο ορατό control (όχι στο κρυφό input)
385
+ Array.from(el.labels || []).forEach((lab) => on(lab, 'click', (e) => {
386
+ if (w.contains(e.target)) return;
387
+ e.preventDefault();
388
+ if (!this._isDisabled()) w.focus();
389
+ }));
390
+
391
+ this._mo = new MutationObserver((muts) => {
392
+ this._syncDisabled();
393
+ if (this.isSelect && muts.some((m) => m.type === 'childList')) this._loadData(); // άλλαξαν τα <option>
394
+ });
395
+ this._mo.observe(el, {attributes: true, attributeFilter: ['disabled', 'readonly'], childList: this.isSelect});
396
+
397
+ bindGlobal();
398
+ }
399
+
400
+ /* ------------------------------------------------------------ δεδομένα */
401
+
402
+ async _loadData() {
403
+ const token = (this._loadToken = (this._loadToken || 0) + 1);
404
+ try {
405
+ const items = await this._getItems();
406
+ if (token !== this._loadToken || this._destroyed) return;
407
+ this.close();
408
+ this._build(items);
409
+ if (!this.input.value && this.defaultCountry) {
410
+ const dc = this.defaultCountry;
411
+ const c = this.countries.find((x) => x.code === dc.toUpperCase()) || this._find(dc);
412
+ if (c) this.input.value = this._valueOf(c);
413
+ }
414
+ this._renderOptions();
415
+ this._syncFromInput();
416
+ } catch (err) {
417
+ console.error('CountrySelect Load Fail:', err);
418
+ if (!this._destroyed) this.listEl.innerHTML = '<div class="cs-load-error">' + esc(this.config.loadError) + '</div>';
126
419
  }
127
-
128
- .cs-wrapper.drop-up .cs-dropdown {
129
- bottom: calc(100% + 0.25rem); top: auto;
130
- box-shadow: 0 -0.5rem 1rem rgba(0,0,0,.175);
420
+ }
421
+
422
+ _base() {
423
+ return fetchJSON(this.baseUrl + 'dist/data.json').then((json) => toArray(json).map(toItem).filter((it) => it && it.code));
424
+ }
425
+
426
+ async _getItems() {
427
+ let custom = null;
428
+ let src = this.source;
429
+ if (this.isSelect) {
430
+ custom = Array.from(this.input.options).filter((op) => op.value !== '').map((op) => ({
431
+ value: op.value,
432
+ code: op.dataset.code || (this.valueType === 'code' ? op.value : ''),
433
+ phone: op.dataset.phone || (this.valueType === 'phone' ? op.value : ''),
434
+ name: op.text.trim(),
435
+ flag: op.dataset.flag
436
+ }));
437
+ } else if (src != null && src !== '') {
438
+ if (typeof src === 'function') src = await src();
439
+ if (typeof src === 'string') {
440
+ const s = src.trim();
441
+ src = s[0] === '[' || s[0] === '{' ? JSON.parse(s) : await fetchJSON(s, this.options.fetchOptions);
442
+ }
443
+ custom = toArray(await src);
131
444
  }
132
- .cs-wrapper.open .cs-dropdown { display: block; }
133
-
134
- .cs-search-box { padding: 8px; border-bottom: 1px solid #dee2e6; background: #f8f9fa; }
135
- .cs-search-input {
136
- width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da;
137
- border-radius: 0.25rem; outline: none; font-size: 0.875rem;
445
+
446
+ if (!custom) return (await this._base()).map((it) => Object.assign({}, it));
447
+
448
+ const items = custom.map(toItem).filter(Boolean);
449
+ // Ό,τι λείπει (όνομα / κωδικός κλήσης) από την ενσωματωμένη λίστα — μόνο όταν χρειάζεται
450
+ const needPhone = this.valueType === 'phone' || /\{phone\}/.test(this.schema + this.schemaReturn);
451
+ if (yes(this.options.merge, true) && items.some((it) => it.code && (!it.nameEn || (needPhone && !it.phone)))) {
452
+ const base = await this._base().catch(() => []);
453
+ const map = {};
454
+ base.forEach((b) => { map[b.code] = b; });
455
+ items.forEach((it) => {
456
+ const b = map[it.code];
457
+ if (!b) return;
458
+ if (!it.nameEn) it.nameEn = b.nameEn;
459
+ if (!it.phone) it.phone = b.phone;
460
+ });
138
461
  }
139
- .cs-list { overflow-y: auto; scrollbar-width: thin; scroll-behavior: auto; min-height: 50px; padding: 0.375rem 0; }
140
- .cs-option {
141
- padding: 0.5rem 0.75rem; display: flex; align-items: center; gap: 10px;
142
- cursor: pointer; font-size: 0.9rem; transition: background 0.1s;
462
+ items.forEach((it) => {
463
+ if (!it.nameEn) it.nameEn = it.code || it.value;
464
+ });
465
+ return items;
466
+ }
467
+
468
+ _build(items) {
469
+ let names = null, collator = null;
470
+ if (this.lang) {
471
+ try { names = new Intl.DisplayNames([this.lang], {type: 'region', fallback: 'none'}); } catch (e) { /* χωρίς μετάφραση */ }
472
+ try { collator = new Intl.Collator(this.lang); } catch (e) { /* default */ }
143
473
  }
144
- .cs-option:hover { background-color: #f8f9fa; }
145
- .cs-option.active { background-color: #0d6efd; color: #fff; }
146
-
147
- .cs-wrapper img { width: 20px !important; height: 14px !important; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
148
- .cs-selected-content { display: flex; align-items: center; gap: 8px; overflow: hidden; white-space: nowrap; line-height: 1; }
149
- .cs-divider { height: 1px; background: #dee2e6; margin: 0.375rem 0; pointer-events: none; }
150
- .cs-hidden { display: none !important; }
151
- `;
152
- const styleId = 'cs-v57-styles';
153
- if (!document.getElementById(styleId)) {
154
- const style = document.createElement("style");
155
- style.id = styleId;
156
- style.textContent = styles;
157
- document.head.appendChild(style);
474
+ const only = this.onlyCodes, excl = this.excludeCodes, pref = this.preferredCodes;
475
+ let list = items
476
+ .filter((it) => (!only.length || only.includes(it.code)) && !excl.includes(it.code))
477
+ .map((it) => {
478
+ let name = it.nameEn;
479
+ if (names && it.code && !it.fixedName) {
480
+ try { name = names.of(it.code) || name; } catch (e) { /* άγνωστος κωδικός */ }
481
+ }
482
+ let flag = '';
483
+ if (this.showFlags && it.flag !== false) {
484
+ flag = typeof it.flag === 'string' ? it.flag
485
+ : isFlagCode(it.code) ? this.flagPath.split('{code}').join(it.code.toLowerCase()).split('{CODE}').join(it.code) : '';
486
+ }
487
+ return {
488
+ code: it.code, name, nameEn: it.nameEn, phone: it.phone || '', flag, value: it.value,
489
+ isPreferred: !!it.code && pref.includes(it.code),
490
+ _search: norm([name, it.nameEn, it.code, ALIASES[it.code], it.keywords].join(' '))
491
+ };
492
+ });
493
+ if (this.sort) list.sort((a, b) => (collator ? collator.compare(a.name, b.name) : a.name.localeCompare(b.name)));
494
+ this.countries = list;
495
+ this.filteredCountries = list.slice();
496
+ this._filtered = false;
158
497
  }
159
- }
160
498
 
161
- _setupDOM() {
162
- Object.assign(this.input.style, {
163
- position: 'absolute',
164
- opacity: '0',
165
- width: '1px',
166
- height: '1px',
167
- pointerEvents: 'none'
168
- });
169
- this.wrapper = document.createElement('div');
170
- this.wrapper.className = 'cs-wrapper';
171
- this.wrapper.tabIndex = 0;
172
- const initialText = this.input.value ? "" : this.config.placeholder;
173
- this.wrapper.innerHTML = `
174
- <div class="cs-trigger"><span class="cs-selected-content">${initialText}</span></div>
175
- <div class="cs-error-msg"></div>
176
- <div class="cs-dropdown">
177
- <div class="cs-search-box ${!this.hasSearch ? 'cs-hidden' : ''}">
178
- <input type="text" class="cs-search-input" placeholder="Search..." autocomplete="off" tabindex="-1">
179
- </div>
180
- <div class="cs-list"><div class="cs-loading" style="padding:15px; text-align:center;">Loading...</div></div>
181
- </div>
182
- `;
183
- const dropdown = this.wrapper.querySelector('.cs-dropdown');
184
- dropdown.style.width = (this.dropdownWidth === 'auto') ? '100%' : this.dropdownWidth;
185
- this.input.parentNode.insertBefore(this.wrapper, this.input.nextSibling);
186
- }
499
+ /** Τιμή του input για μια χώρα */
500
+ _valueOf(c) {
501
+ if (c.value != null) return c.value;
502
+ return this.valueType === 'phone' ? c.phone : this.valueType === 'name' ? c.nameEn : c.code;
503
+ }
187
504
 
188
- _updateActiveElement() {
189
- const options = this.wrapper.querySelectorAll('.cs-option');
190
- options.forEach((el, index) => {
191
- if (index === this.activeIndex) {
192
- el.classList.add('active');
193
- // Bonus UX: Κάνει scroll αν το element είναι εκτός οθόνης
194
- el.scrollIntoView({ block: 'nearest' });
195
- } else {
196
- el.classList.remove('active');
197
- }
198
- });
199
- }
505
+ /** Χώρα από τιμή (κωδικός ISO χωρίς διάκριση πεζών / κεφαλαίων, κωδικός κλήσης, όνομα) */
506
+ _find(val) {
507
+ const v = str(val).trim();
508
+ if (!v) return null;
509
+ const list = this.countries, sel = this.selected;
510
+ if (sel && this._valueOf(sel) === v) return sel; // π.χ. +1: μένει ο Καναδάς αν επιλέχθηκε ο Καναδάς
511
+ const exact = list.find((c) => c.value != null && c.value === v);
512
+ if (exact) return exact;
513
+
514
+ if (this.valueType === 'phone') {
515
+ const p = phoneOf(v);
516
+ const hits = list.filter((c) => c.phone === p);
517
+ if (hits.length < 2) return hits[0] || null;
518
+ for (const code of this.preferredCodes) {
519
+ const h = hits.find((c) => c.code === code);
520
+ if (h) return h;
521
+ }
522
+ return hits.find((c) => c.code === PHONE_PRIMARY[p]) || hits[0];
523
+ }
524
+ if (this.valueType === 'name') {
525
+ const n = norm(v);
526
+ return list.find((c) => norm(c.nameEn) === n || norm(c.name) === n) || null;
527
+ }
528
+ const up = v.toUpperCase();
529
+ return list.find((c) => c.code === up) || list.find((c) => c.value != null && c.value.toUpperCase() === up) || null;
530
+ }
200
531
 
201
- _updatePosition() {
202
- const rect = this.wrapper.getBoundingClientRect();
203
- const dropdownHeight = (this.rowLimit * this.rowHeight) + (this.hasSearch ? 60 : 0);
204
- const spaceBelow = window.innerHeight - rect.bottom;
205
- const spaceAbove = rect.top;
532
+ /* ------------------------------------------------------------ εμφάνιση */
533
+
534
+ _parseTemplate(template, c) {
535
+ const img = c.flag
536
+ ? '<img src="' + esc(c.flag) + '" class="cs-flag-img" width="20" height="14" loading="lazy" decoding="async" alt="' + (template.indexOf('{name}') > -1 ? '' : esc(c.name)) + '">'
537
+ : '';
538
+ return template
539
+ .split('{img}').join(img)
540
+ .split('{name}').join('<span>' + esc(c.name) + '</span>')
541
+ .split('{code}').join('<span>' + esc(c.code) + '</span>')
542
+ .split('{phone}').join('<span>' + esc(c.phone) + '</span>');
543
+ }
206
544
 
207
- if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
208
- this.wrapper.classList.add('drop-up');
209
- } else {
210
- this.wrapper.classList.remove('drop-up');
545
+ _showPlaceholder() {
546
+ this.contentEl.classList.add('cs-placeholder');
547
+ this.contentEl.innerHTML = this.config.placeholder;
211
548
  }
212
- }
213
549
 
214
- async _loadData() {
215
- try {
216
- const jsonUrl = `${this.baseUrl}dist/data.json`; // Χρήση του baseUrl
217
-
218
- const res = await fetch(jsonUrl);
219
- const json = await res.json();
220
- const data = json?.data?.objects || [];
221
-
222
- this.countries = data.map(c => {
223
- const code = c.codes?.alpha_2 ? c.codes.alpha_2.toUpperCase() : "";
224
- return {
225
- name: c.names?.common || "",
226
- code: code,
227
- // Δημιουργία path για το SVG με βάση το code
228
- flag: code ? `${this.baseUrl}dist/flags/4x3/${code.toLowerCase()}.svg` : "",
229
- phone: (c.calling_codes && c.calling_codes.length > 0) ? `+${c.calling_codes[0]}` : "",
230
- isPreferred: code ? this.preferredCodes.includes(code) : false
231
- };
232
- }).filter(c => c.code !== "")
233
- .sort((a, b) => a.name.localeCompare(b.name));
234
-
235
- this.filteredCountries = [...this.countries];
236
- this._renderOptions();
237
- this._syncInitialValue();
238
- } catch (e) {
239
- console.error("CountrySelect Load Fail:", e);
240
- }
241
- }
550
+ _setSelected(c) {
551
+ this.selected = c || null;
552
+ if (c) {
553
+ this.contentEl.classList.remove('cs-placeholder');
554
+ this.contentEl.innerHTML = this._parseTemplate(this.schemaReturn, c);
555
+ } else {
556
+ this._showPlaceholder();
557
+ }
558
+ this._els.forEach((node, i) => node.setAttribute('aria-selected', this.ordered[i] === this.selected ? 'true' : 'false'));
559
+ }
242
560
 
243
-
244
- _syncInitialValue() {
245
- const val = this.input.value;
246
- if (!val) {
247
- this.wrapper.querySelector('.cs-selected-content').innerHTML = this.config.placeholder;
248
- return;
561
+ _syncFromInput() {
562
+ if (this.countries.length) this._setSelected(this._find(this.input.value));
249
563
  }
250
- const current = this.countries.find(c => this.valueType === "phone" ? c.phone === val : this.valueType === "name" ? c.name === val : c.code === val);
251
- if (current) this._updateUI(current);
252
- }
253
564
 
565
+ _renderOptions() {
566
+ const list = this.listEl;
567
+ const pref = [], rest = [];
568
+ this.filteredCountries.forEach((c) => (c.isPreferred ? pref : rest).push(c));
569
+ this.ordered = pref.concat(rest);
570
+ this._els = [];
571
+ list.textContent = '';
572
+
573
+ if (!this.ordered.length) {
574
+ list.innerHTML = '<div class="cs-no-results"><span class="cs-no-results-text">' + esc(this.config.noResults) + '</span></div>';
575
+ return;
576
+ }
254
577
 
255
- _parseTemplate(template, country) {
256
- return template
257
- .replace('{img}', `<img src="${country.flag}" class="cs-flag-img" loading="lazy" alt="${country.name}">`)
258
- .replace('{name}', `<span>${country.name}</span>`)
259
- .replace('{code}', `<span>${country.code}</span>`)
260
- .replace('{phone}', `<span>${country.phone}</span>`);
261
- }
578
+ const frag = document.createDocumentFragment();
579
+ const searching = !!this.searchEl.value.trim();
580
+ this.ordered.forEach((c, i) => {
581
+ if (i === pref.length && i > 0 && !searching) {
582
+ const hr = document.createElement('div');
583
+ hr.className = 'cs-divider';
584
+ hr.setAttribute('aria-hidden', 'true');
585
+ frag.appendChild(hr);
586
+ }
587
+ const div = document.createElement('div');
588
+ div.className = 'cs-option' + (i === this.activeIndex ? ' active' : '');
589
+ div.id = this._id + '-o' + i;
590
+ div.setAttribute('role', 'option');
591
+ div.setAttribute('aria-selected', c === this.selected ? 'true' : 'false');
592
+ div.dataset.index = i;
593
+ div.innerHTML = this._parseTemplate(this.schema, c);
594
+ frag.appendChild(div);
595
+ this._els.push(div);
596
+ });
597
+ list.appendChild(frag);
598
+ }
262
599
 
263
- _renderOptions() {
264
- const listContainer = this.wrapper.querySelector('.cs-list');
265
- if (!listContainer) return;
266
- listContainer.innerHTML = '';
267
-
268
- // Έλεγχος αν η λίστα είναι άδεια
269
- if (this.filteredCountries.length === 0) {
270
- const noResults = document.createElement('div');
271
- noResults.className = 'cs-no-results';
272
- noResults.innerHTML = '<span class="cs-no-results-text">Not found</span>';
273
- listContainer.appendChild(noResults);
274
- return; // Σταματάμε εδώ
275
- }
600
+ _updateActive() {
601
+ this._els.forEach((node, i) => node.classList.toggle('active', i === this.activeIndex));
602
+ const node = this.isOpen ? this._els[this.activeIndex] : null;
603
+ [this.wrapper, this.searchEl].forEach((t) => (node ? t.setAttribute('aria-activedescendant', node.id) : t.removeAttribute('aria-activedescendant')));
604
+ }
276
605
 
277
- const searchVal = this.wrapper.querySelector('.cs-search-input')?.value || "";
278
- const preferred = this.filteredCountries.filter(c => c.isPreferred);
279
- const others = this.filteredCountries.filter(c => !c.isPreferred);
280
-
281
- const createOption = (country, index) => {
282
- const div = document.createElement('div');
283
- div.className = `cs-option ${index === this.activeIndex ? 'active' : ''}`;
284
- div.innerHTML = this._parseTemplate(this.schema, country);
285
- div.onclick = (e) => {
286
- e.stopPropagation();
287
- this._select(country);
288
- };
289
- return div;
290
- };
606
+ _updatePosition() {
607
+ const rect = this.wrapper.getBoundingClientRect();
608
+ const dropdownHeight = (this.rowLimit * this.rowHeight) + (this.hasSearch ? 60 : 0);
609
+ const spaceBelow = window.innerHeight - rect.bottom;
610
+ this.wrapper.classList.toggle('drop-up', spaceBelow < dropdownHeight && rect.top > dropdownHeight);
611
+ }
291
612
 
292
- preferred.forEach((c, i) => listContainer.appendChild(createOption(c, i)));
613
+ _itemTop(item) {
614
+ return item.getBoundingClientRect().top - this.listEl.getBoundingClientRect().top + this.listEl.scrollTop;
615
+ }
293
616
 
294
- if (preferred.length > 0 && others.length > 0 && !searchVal) {
295
- const hr = document.createElement('div');
296
- hr.className = 'cs-divider';
297
- listContainer.appendChild(hr);
298
- }
617
+ /**
618
+ * Άνοιγμα: αν η επιλεγμένη φαίνεται ήδη από την αρχή της λίστας, η λίστα ξεκινά από την αρχή (6.1.4)·
619
+ * αλλιώς η επιλεγμένη στη μέση.
620
+ */
621
+ _scrollToActive() {
622
+ const list = this.listEl, item = this._els[this.activeIndex];
623
+ if (!item) {
624
+ list.scrollTop = 0;
625
+ return;
626
+ }
627
+ const top = this._itemTop(item);
628
+ list.scrollTop = top + item.offsetHeight <= list.clientHeight ? 0 : top - (list.clientHeight / 2) + (item.offsetHeight / 2);
629
+ }
299
630
 
300
- others.forEach((c, i) => listContainer.appendChild(createOption(c, preferred.length + i)));
301
- }
631
+ /** Πληκτρολόγιο: όσο scroll χρειάζεται για να φαίνεται η ενεργή */
632
+ _scrollNearest() {
633
+ const list = this.listEl, item = this._els[this.activeIndex];
634
+ if (!item) return;
635
+ if (this.activeIndex === 0) return void (list.scrollTop = 0);
636
+ if (this.activeIndex === this._els.length - 1) return void (list.scrollTop = list.scrollHeight);
637
+ const top = this._itemTop(item), bottom = top + item.offsetHeight;
638
+ if (top < list.scrollTop) list.scrollTop = top;
639
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
640
+ }
302
641
 
303
- _select(country) {
304
- this._updateUI(country);
642
+ _announce(msg) {
643
+ clearTimeout(this._liveTimer);
644
+ this._liveTimer = setTimeout(() => {
645
+ this.liveEl.textContent = msg;
646
+ }, 400);
647
+ }
305
648
 
306
- // Ενημέρωση τιμής
307
- this.input.value = (this.valueType === "phone") ? country.phone : (this.valueType === "name" ? country.name : country.code);
649
+ /* ------------------------------------------------------------ αλληλεπίδραση */
308
650
 
309
- // Reset validation (αν χρησιμοποιείς Bootstrap classes)
310
- this.input.setCustomValidity("");
311
- this.wrapper.classList.remove('is-invalid');
651
+ _isDisabled() {
652
+ return this.input.disabled || this.input.matches(':disabled');
653
+ }
312
654
 
313
- // 1. Τυπικό change & input (για γενική χρήση)
314
- this.input.dispatchEvent(new Event('change', {bubbles: true}));
315
- this.input.dispatchEvent(new Event('input', {bubbles: true}));
655
+ _syncDisabled() {
656
+ const dis = this._isDisabled(), w = this.wrapper;
657
+ if (!w) return;
658
+ w.classList.toggle('cs-disabled', dis);
659
+ w.tabIndex = dis ? -1 : 0;
660
+ if (dis) w.setAttribute('aria-disabled', 'true'); else w.removeAttribute('aria-disabled');
661
+ if (this.input.readOnly) w.setAttribute('aria-readonly', 'true'); else w.removeAttribute('aria-readonly');
662
+ if (dis) this.close();
663
+ }
316
664
 
317
- // 2. ΤΟ ΚΛΕΙΔΙ: Μοναδικό event για το δικό μας HTMX Trigger
318
- this.input.dispatchEvent(new Event('country-change', {bubbles: true}));
665
+ _onSearch() {
666
+ const raw = this.searchEl.value.trim();
667
+ const term = norm(raw);
668
+ let res = this.countries;
669
+ if (term) {
670
+ const isPhone = /^\+?[\d\s().-]*$/.test(raw);
671
+ const digits = raw.replace(/\D/g, '');
672
+ const primary = PHONE_PRIMARY['+' + digits];
673
+ const rank = isPhone
674
+ ? (c) => (!c.phone || c.phone.indexOf(digits) < 0 ? -1 : c.phone === '+' + digits ? (c.code === primary ? 0 : 1) : c.phone.indexOf('+' + digits) === 0 ? 2 : 3)
675
+ : (c) => {
676
+ const s = ' ' + c._search + ' ';
677
+ return s.indexOf(term) < 0 ? -1 : s.indexOf(' ' + term + ' ') > -1 ? 0 : s.indexOf(' ' + term) > -1 ? 1 : 2;
678
+ };
679
+ // ακριβής λέξη / κωδικός → ξεκινά από τον όρο → τον περιέχει (με την αλφαβητική σειρά μέσα σε κάθε ομάδα)
680
+ res = res.map((c, i) => [rank(c), i, c]).filter((x) => x[0] > -1).sort((a, b) => a[0] - b[0] || a[1] - b[1]).map((x) => x[2]);
681
+ }
682
+ this.filteredCountries = res === this.countries ? res.slice() : res;
683
+ this._filtered = !!term;
684
+ this.activeIndex = res.length ? 0 : -1;
685
+ this._renderOptions();
686
+ this.listEl.scrollTop = 0;
687
+ this._updateActive();
688
+ this._announce(term ? this.config.results.replace('{n}', res.length) : '');
689
+ }
319
690
 
320
- this._toggle(false);
321
- this.wrapper.focus();
322
- }
691
+ _move(i) {
692
+ if (i < 0 || i >= this.ordered.length) return;
693
+ this.activeIndex = i;
694
+ this._updateActive();
695
+ this._scrollNearest();
696
+ }
323
697
 
324
- _updateUI(country) {
325
- const content = this.wrapper.querySelector('.cs-selected-content');
326
- if (content) content.innerHTML = this._parseTemplate(this.schemaReturn, country);
327
- }
698
+ _onKey(e) {
699
+ if (e.isComposing || e.keyCode === 229) return; // IME (π.χ. σύνθεση χαρακτήρων)
700
+ if (this._isDisabled() || this.input.readOnly) return;
701
+ const k = e.key, inSearch = e.target === this.searchEl;
702
+ const printable = k.length === 1 && k !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey;
703
+
704
+ if (!this.isOpen) {
705
+ if (e.target !== this.wrapper) return;
706
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Home', 'End'].indexOf(k) > -1) {
707
+ e.preventDefault();
708
+ this.open();
709
+ if (k === 'Home') this._move(0);
710
+ else if (k === 'End') this._move(this.ordered.length - 1);
711
+ } else if (printable && this.hasSearch) {
712
+ this.open(); // η εστίαση πάει στην αναζήτηση → ο χαρακτήρας γράφεται εκεί
713
+ }
714
+ return;
715
+ }
328
716
 
329
- _toggle(force) {
330
- if (this.countries.length === 0) return;
331
- this.isOpen = force !== undefined ? force : !this.isOpen;
332
-
333
- if (this.isOpen) {
334
- this._updatePosition();
335
- document.dispatchEvent(new CustomEvent('cs-close-others', {detail: {opener: this.wrapper}}));
336
-
337
- // ΑΛΛΑΓΗ: Έλεγχος αν η λίστα έχει ήδη στοιχεία
338
- const list = this.wrapper.querySelector('.cs-list');
339
- if (list.children.length === 0) {
340
- this._renderOptions();
717
+ const last = this.ordered.length - 1;
718
+ switch (k) {
719
+ case 'ArrowDown':
720
+ e.preventDefault();
721
+ this._move(Math.min(this.activeIndex + 1, last));
722
+ break;
723
+ case 'ArrowUp':
724
+ e.preventDefault();
725
+ this._move(Math.max(this.activeIndex - 1, 0));
726
+ break;
727
+ case 'PageDown':
728
+ e.preventDefault();
729
+ this._move(Math.min(this.activeIndex + this.rowLimit, last));
730
+ break;
731
+ case 'PageUp':
732
+ e.preventDefault();
733
+ this._move(Math.max(this.activeIndex - this.rowLimit, 0));
734
+ break;
735
+ case 'Home':
736
+ case 'End':
737
+ if (inSearch) break; // μέσα στην αναζήτηση: κέρσορας
738
+ e.preventDefault();
739
+ this._move(k === 'Home' ? 0 : last);
740
+ break;
741
+ case ' ':
742
+ if (inSearch) break;
743
+ // falls through
744
+ case 'Enter':
745
+ e.preventDefault();
746
+ if (this.ordered[this.activeIndex]) this._choose(this.ordered[this.activeIndex]);
747
+ break;
748
+ case 'Escape':
749
+ e.preventDefault();
750
+ e.stopPropagation(); // να μην κλείσει και το modal / offcanvas γύρω
751
+ this.close(true);
752
+ break;
753
+ case 'Tab':
754
+ this.close();
755
+ break;
756
+ default:
757
+ if (printable && this.hasSearch && !inSearch) this.searchEl.focus();
758
+ }
341
759
  }
342
760
 
343
- list.style.maxHeight = `${this.rowLimit * this.rowHeight}px`;
761
+ _apply(c, silent) {
762
+ this.input.value = c ? this._valueOf(c) : '';
763
+ this._setSelected(c);
764
+ this.input.setCustomValidity('');
765
+ this.wrapper.classList.remove('is-invalid');
766
+ this.wrapper.removeAttribute('aria-invalid');
767
+ if (!silent) this._emit();
768
+ }
344
769
 
345
- const pref = this.filteredCountries.filter(c => c.isPreferred);
346
- const oth = this.filteredCountries.filter(c => !c.isPreferred);
347
- const fullList = [...pref, ...oth];
348
- this.activeIndex = fullList.findIndex(c => this.input.value.includes(c.code) || this.input.value.includes(c.phone));
770
+ _choose(c) {
771
+ this._apply(c);
772
+ this.close(true);
773
+ }
349
774
 
350
- // ΑΛΛΑΓΗ: Ενημέρωσε το active element αντί να κάνεις re-render όλη τη λίστα
351
- this._updateActiveElement();
775
+ _emit() {
776
+ const el = this.input;
777
+ this._emitting = true;
778
+ try {
779
+ el.dispatchEvent(new Event('change', {bubbles: true}));
780
+ el.dispatchEvent(new Event('input', {bubbles: true}));
781
+ // Μοναδικό event για htmx (hx-trigger="country-change") με τα στοιχεία της χώρας
782
+ el.dispatchEvent(new CustomEvent('country-change', {bubbles: true, detail: {value: el.value, country: this.getCountry()}}));
783
+ } finally {
784
+ this._emitting = false;
785
+ }
786
+ }
352
787
 
353
- if (this.activeIndex > -1) setTimeout(() => this._scrollToActive(), 0);
354
- if (this.hasSearch) setTimeout(() => this.wrapper.querySelector('.cs-search-input').focus(), 50);
355
- }
356
- this.wrapper.classList.toggle('open', this.isOpen);
357
- }
788
+ /* ------------------------------------------------------------ δημόσιο API */
358
789
 
359
- _bindEvents() {
360
- this.wrapper.querySelector('.cs-trigger').onclick = (e) => {
361
- e.stopPropagation();
362
- this._toggle();
363
- };
790
+ open() {
791
+ if (this.isOpen || this._destroyed || !this.countries.length || this._isDisabled() || this.input.readOnly) return;
792
+ instances.forEach((inst) => {
793
+ if (inst !== this && inst.isOpen) inst.close();
794
+ });
795
+ document.dispatchEvent(new CustomEvent('cs-close-others', {detail: {opener: this.wrapper}}));
364
796
 
365
- this.wrapper.addEventListener('keydown', (e) => {
366
- if (!this.isOpen && (e.key === 'ArrowDown')) {
367
- e.preventDefault();
368
- this._toggle(true);
369
- return;
370
- }
371
-
372
- if (this.isOpen) {
373
- if (e.key === 'ArrowDown') {
374
- e.preventDefault();
375
- // ΕΛΕΓΧΟΣ: Μπορούμε να πάμε παρακάτω;
376
- if (this.activeIndex < this.filteredCountries.length - 1) {
377
- this.activeIndex++;
378
- this._updateActiveElement();
379
- this._scrollToActive();
380
- }
381
- } else if (e.key === 'ArrowUp') {
382
- e.preventDefault();
383
- // ΕΛΕΓΧΟΣ: Μπορούμε να πάμε παραπάνω;
384
- if (this.activeIndex > 0) {
385
- this.activeIndex--;
386
- this._updateActiveElement();
387
- this._scrollToActive();
388
- }
389
- } else if (e.key === 'Escape') {
390
- this._toggle(false);
391
- } else if (e.key === 'Enter') {
392
- e.preventDefault();
393
- // Εδώ πρέπει να είμαστε σίγουροι ότι το activeIndex είναι έγκυρο
394
- if (this.activeIndex >= 0 && this.activeIndex < this.filteredCountries.length) {
395
- this._select(this.filteredCountries[this.activeIndex]);
797
+ if (this._filtered || this.searchEl.value) { // νέο άνοιγμα → χωρίς την προηγούμενη αναζήτηση
798
+ this.searchEl.value = '';
799
+ this.filteredCountries = this.countries.slice();
800
+ this._filtered = false;
801
+ this._renderOptions();
396
802
  }
803
+ this._updatePosition();
804
+ this.listEl.style.maxHeight = (this.rowLimit * this.rowHeight) + 'px';
805
+ this.isOpen = true;
806
+ this.wrapper.classList.add('open');
807
+ this.wrapper.setAttribute('aria-expanded', 'true');
808
+ this.activeIndex = this.selected ? this.ordered.indexOf(this.selected) : -1;
809
+ this._updateActive();
810
+ this._scrollToActive();
811
+ if (this.hasSearch) this.searchEl.focus({preventScroll: true});
397
812
  }
398
- }
399
- });
400
-
401
- if (this.hasSearch) {
402
- this.wrapper.querySelector('.cs-search-input').oninput = (e) => {
403
- const term = e.target.value.toLowerCase();
404
- this.filteredCountries = this.countries.filter(c => c.name.toLowerCase().includes(term) || c.phone.includes(term));
405
- this.activeIndex = 0;
406
- this._renderOptions();
407
- };
813
+
814
+ close(refocus) {
815
+ if (!this.isOpen) return;
816
+ this.isOpen = false;
817
+ this.wrapper.classList.remove('open');
818
+ this.wrapper.setAttribute('aria-expanded', 'false');
819
+ this._updateActive();
820
+ if (refocus) this.wrapper.focus({preventScroll: true});
408
821
  }
409
- document.addEventListener('click', (e) => {
410
- if (!this.wrapper.contains(e.target)) this._toggle(false);
411
- });
412
- document.addEventListener('cs-close-others', (e) => {
413
- if (e.detail.opener !== this.wrapper) {
414
- this.isOpen = false;
415
- this.wrapper.classList.remove('open');
822
+
823
+ toggle() {
824
+ if (this.isOpen) this.close(true); else this.open();
825
+ }
826
+
827
+ /** Συμβατότητα με ≤ 6.1 */
828
+ _toggle(force) {
829
+ if (force === undefined) this.toggle(); else if (force) this.open(); else this.close();
830
+ }
831
+
832
+ /**
833
+ * Ορισμός τιμής (κωδικός / κωδικός κλήσης / όνομα, ανάλογα με το valueType).
834
+ * Στέλνει change / input / country-change, εκτός αν {silent: true}.
835
+ */
836
+ setValue(value, opts) {
837
+ if (!this.countries.length) { // δεν έχει φορτώσει ακόμα → εφαρμόζεται με το φόρτωμα
838
+ this.input.value = str(value);
839
+ return this;
416
840
  }
417
- });
418
- }
841
+ this._apply(this._find(value), !!(opts && opts.silent));
842
+ return this;
843
+ }
419
844
 
420
- _scrollToActive() {
421
- const list = this.wrapper.querySelector('.cs-list');
422
- const options = list.querySelectorAll('.cs-option');
423
- const activeItem = options[this.activeIndex];
424
- if (activeItem) {
425
- const scrollPos = activeItem.offsetTop - (list.offsetHeight / 2) + (this.rowHeight / 2);
426
- list.scrollTop = scrollPos;
845
+ getValue() {
846
+ return this.input.value;
427
847
  }
428
- }
429
- }
430
848
 
431
- /** AUTO-INIT (STATIC & HTMX) **/
432
- const initCS = (target) => {
433
- const root = (target && target.querySelectorAll) ? target : document;
849
+ /** {code, name, localName, phone, value} ή null */
850
+ getCountry() {
851
+ const c = this.selected;
852
+ return c ? {code: c.code, name: c.nameEn, localName: c.name, phone: c.phone, value: this._valueOf(c)} : null;
853
+ }
434
854
 
435
- if (root.matches?.('.country-select')) {
436
- if (root.dataset.csInitialized !== '1') {
437
- new CountrySelect(root);
855
+ /** Νέα λίστα (array, function, JSON, URL) */
856
+ setCountries(source) {
857
+ this.source = source;
858
+ return this._loadData();
438
859
  }
439
- }
440
860
 
441
- root.querySelectorAll?.('.country-select').forEach(el => {
442
- if (el.dataset.csInitialized === '1') return;
443
- new CountrySelect(el);
444
- });
445
- };
861
+ /** Ξανά από την τιμή του input (μετά από input.value = ... χωρίς event) */
862
+ refresh() {
863
+ this._syncFromInput();
864
+ return this;
865
+ }
446
866
 
447
- const setupListeners = () => {
448
- initCS(document);
867
+ destroy() {
868
+ if (this._destroyed) return;
869
+ this.close();
870
+ this._destroyed = true;
871
+ this._off.forEach((off) => off());
872
+ this._off = [];
873
+ if (this._mo) this._mo.disconnect();
874
+ clearTimeout(this._liveTimer);
875
+ instances.delete(this);
876
+ if (this.wrapper && this.wrapper.parentNode) this.wrapper.remove();
877
+ const el = this.input;
878
+ if (el._countrySelect === this) delete el._countrySelect;
879
+ delete el.dataset.csInitialized;
880
+ if (this._orig) {
881
+ if (this._orig.style == null) el.removeAttribute('style'); else el.setAttribute('style', this._orig.style);
882
+ if (this._orig.tabindex == null) el.removeAttribute('tabindex'); else el.setAttribute('tabindex', this._orig.tabindex);
883
+ }
884
+ }
885
+ }
449
886
 
450
- document.body.addEventListener('htmx:afterSettle', (e) => {
451
- initCS(e.detail.target || e.target);
452
- });
887
+ CountrySelect.version = VERSION;
888
+ CountrySelect.baseUrl = DEFAULT_BASE_URL;
889
+ /** Καθολικές ρυθμίσεις: window.CountrySelectConfig = {...} ΠΡΙΝ φορτωθεί το script (π.χ. {css: false, lang: 'el', baseUrl, flagPath}) */
890
+ CountrySelect.defaults = Object.assign({}, global.CountrySelectConfig);
891
+ if (CountrySelect.defaults.baseUrl) CountrySelect.baseUrl = CountrySelect.defaults.baseUrl;
892
+
893
+ /* ---------------------------------------------------------------- AUTO-INIT (στατικό HTML, htmx, δυναμικό DOM) */
894
+
895
+ const initCS = (target) => {
896
+ const root = target && target.querySelectorAll ? target : document;
897
+ const els = [];
898
+ if (root.matches && root.matches('.country-select')) els.push(root);
899
+ root.querySelectorAll('.country-select').forEach((el) => els.push(el));
900
+ els.forEach((el) => {
901
+ if ((el.tagName === 'INPUT' || el.tagName === 'SELECT') && !CountrySelect._isLive(el)) new CountrySelect(el);
902
+ });
903
+ };
453
904
 
454
- new MutationObserver((mutations) => {
455
- mutations.forEach(mu => {
456
- mu.addedNodes.forEach(node => {
457
- if (node.nodeType !== 1) return;
905
+ CountrySelect.init = initCS;
906
+ CountrySelect.getInstance = (el) => {
907
+ const node = typeof el === 'string' ? document.querySelector(el) : el;
908
+ return node && CountrySelect._isLive(node) ? node._countrySelect : null;
909
+ };
910
+
911
+ function setup() {
912
+ initCS(document);
913
+ document.addEventListener('htmx:afterSettle', (e) => initCS((e.detail && e.detail.target) || e.target));
914
+ new MutationObserver((mutations) => {
915
+ mutations.forEach((mu) => mu.addedNodes.forEach((node) => {
916
+ if (node.nodeType !== 1 || node.closest('.cs-wrapper')) return; // τα δικά μας (επιλογές της λίστας κ.λπ.)
458
917
  initCS(node);
459
- });
460
- });
461
- }).observe(document.body, {
462
- childList: true,
463
- subtree: true
464
- });
465
- };
466
-
467
- if (document.readyState === 'loading') {
468
- document.addEventListener('DOMContentLoaded', setupListeners);
469
- } else {
470
- setupListeners();
471
- }
918
+ }));
919
+ }).observe(document.documentElement, {childList: true, subtree: true});
920
+ }
921
+
922
+ global.CountrySelect = CountrySelect;
923
+ if (!global.initCS) global.initCS = initCS; // ≤ 6.1: global συνάρτηση
924
+ if (typeof module === 'object' && module.exports) module.exports = CountrySelect;
925
+
926
+ if (CountrySelect.defaults.autoInit !== false) {
927
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup);
928
+ else setup();
929
+ }
930
+ })(typeof window !== 'undefined' ? window : this);