country-select-pro 6.1.4 → 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,479 +1,930 @@
1
+ /*! CountrySelect Pro v6.2.0 | MIT | https://github.com/nikmauro/country-select-pro */
1
2
  /**
2
- * CountrySelect Pro v6.1.4
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';
15
+
16
+ if (typeof document === 'undefined') return;
17
+ if (global.CountrySelect && global.CountrySelect.version) return; // ήδη φορτωμένο στη σελίδα — ένα αντίγραφο αρκεί
18
+
19
+ const VERSION = '6.2.0';
20
+ const DEFAULT_BASE_URL = 'https://cdn.jsdelivr.net/gh/nikmauro/country-select-pro@6.1.3/';
21
+
22
+ /* ---------------------------------------------------------------- βοηθητικά */
23
+
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
+ };
28
+
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'};
31
+
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
+
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
+ }
12
68
 
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 [];
81
+ }
13
82
 
14
- class CountrySelect {
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;
100
+ }
15
101
 
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;
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)); }
16
127
 
17
- constructor(element, options = {}) {
18
- this.input = element;
19
- if (!this.input) return;
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; }
20
131
 
21
- // 1. ΕΛΕΓΧΟΣ HTMX: Αν έχει ήδη γίνει init, σταμάτα αμέσως
22
- if (this.input.dataset.csInitialized === '1') return;
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;
134
+ }
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
+ }
23
186
 
24
- // 2. ΚΛΕΙΔΩΜΑ HTMX: Σημειώνουμε ότι έγινε init ΠΡΙΝ τρέξει το async _init()
25
- this.input.dataset.csInitialized = '1';
187
+ /* ---------------------------------------------------------------- instances */
26
188
 
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";
189
+ const instances = new Set();
190
+ let uid = 0;
191
+ let globalBound = false;
34
192
 
35
- // Preferred Logic
36
- const preferredAttr = this.input.dataset.preferred || "";
37
- this.preferredCodes = preferredAttr ? preferredAttr.split(',').map(c => c.trim().toUpperCase()) : [];
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
+ }
38
203
 
39
- this.config = {
40
- placeholder: this.input.dataset.placeholder || "Select",
41
- ...options
42
- };
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;
217
+ }
43
218
 
44
- this.countries = [];
45
- this.filteredCountries = [];
46
- this.isOpen = false;
47
- this.activeIndex = -1;
48
- this.rowHeight = 44;
219
+ // Ήδη ενεργό στο ίδιο στοιχείο → το ίδιο instance (με νέες options: ξανά από την αρχή με αυτές)
220
+ if (CountrySelect._isLive(el)) {
221
+ if (!Object.keys(o).length) return el._countrySelect;
222
+ el._countrySelect.destroy();
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
+ }
49
282
 
50
- // Base URL για το JSON και τα SVG flags
51
- this.baseUrl = 'https://cdn.jsdelivr.net/gh/nikmauro/country-select-pro@6.1.3/';
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
+ }
52
287
 
53
- this._init();
54
- }
288
+ async _init() {
289
+ injectStyles();
290
+ this._setupDOM();
291
+ this._bindEvents();
292
+ await this._loadData();
293
+ return this;
294
+ }
55
295
 
56
- async _init() {
57
- this._applyStyles();
58
- this._setupDOM();
59
- this._bindEvents();
60
- await this._loadData();
61
- }
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
+ }
62
347
 
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;
71
- }
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
+ };
72
354
 
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;
81
- }
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);
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
+ });
368
+
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>';
113
419
  }
420
+ }
421
+
422
+ _base() {
423
+ return fetchJSON(this.baseUrl + 'dist/data.json').then((json) => toArray(json).map(toItem).filter((it) => it && it.code));
424
+ }
114
425
 
115
- .flag-emoji {
116
- font-size: 1.2em; /* Για να φαίνεται πιο καθαρό το emoji */
117
- margin-right: 8px;
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);
118
444
  }
119
445
 
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;
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
+ });
126
461
  }
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);
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 */ }
131
473
  }
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;
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;
497
+ }
498
+
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
+ }
504
+
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];
138
523
  }
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;
524
+ if (this.valueType === 'name') {
525
+ const n = norm(v);
526
+ return list.find((c) => norm(c.nameEn) === n || norm(c.name) === n) || null;
143
527
  }
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);
528
+ const up = v.toUpperCase();
529
+ return list.find((c) => c.code === up) || list.find((c) => c.value != null && c.value.toUpperCase() === up) || null;
158
530
  }
159
- }
160
531
 
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
- }
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
+ }
187
544
 
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'});
545
+ _showPlaceholder() {
546
+ this.contentEl.classList.add('cs-placeholder');
547
+ this.contentEl.innerHTML = this.config.placeholder;
548
+ }
549
+
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);
195
555
  } else {
196
- el.classList.remove('active');
556
+ this._showPlaceholder();
197
557
  }
198
- });
199
- }
558
+ this._els.forEach((node, i) => node.setAttribute('aria-selected', this.ordered[i] === this.selected ? 'true' : 'false'));
559
+ }
200
560
 
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;
561
+ _syncFromInput() {
562
+ if (this.countries.length) this._setSelected(this._find(this.input.value));
563
+ }
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
+ }
206
577
 
207
- if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
208
- this.wrapper.classList.add('drop-up');
209
- } else {
210
- this.wrapper.classList.remove('drop-up');
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);
211
598
  }
212
- }
213
599
 
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);
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')));
240
604
  }
241
- }
242
605
 
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
+ }
243
612
 
244
- _syncInitialValue() {
245
- const val = this.input.value;
246
- if (!val) {
247
- this.wrapper.querySelector('.cs-selected-content').innerHTML = this.config.placeholder;
248
- return;
613
+ _itemTop(item) {
614
+ return item.getBoundingClientRect().top - this.listEl.getBoundingClientRect().top + this.listEl.scrollTop;
249
615
  }
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
616
 
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
+ }
254
630
 
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
- }
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
+ }
262
641
 
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
- }
276
-
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
- };
642
+ _announce(msg) {
643
+ clearTimeout(this._liveTimer);
644
+ this._liveTimer = setTimeout(() => {
645
+ this.liveEl.textContent = msg;
646
+ }, 400);
647
+ }
291
648
 
292
- preferred.forEach((c, i) => listContainer.appendChild(createOption(c, i)));
649
+ /* ------------------------------------------------------------ αλληλεπίδραση */
293
650
 
294
- if (preferred.length > 0 && others.length > 0 && !searchVal) {
295
- const hr = document.createElement('div');
296
- hr.className = 'cs-divider';
297
- listContainer.appendChild(hr);
651
+ _isDisabled() {
652
+ return this.input.disabled || this.input.matches(':disabled');
298
653
  }
299
654
 
300
- others.forEach((c, i) => listContainer.appendChild(createOption(c, preferred.length + i)));
301
- }
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
+ }
302
664
 
303
- _select(country) {
304
- this._updateUI(country);
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
+ }
305
690
 
306
- // Ενημέρωση τιμής
307
- this.input.value = (this.valueType === "phone") ? country.phone : (this.valueType === "name" ? country.name : country.code);
691
+ _move(i) {
692
+ if (i < 0 || i >= this.ordered.length) return;
693
+ this.activeIndex = i;
694
+ this._updateActive();
695
+ this._scrollNearest();
696
+ }
308
697
 
309
- // Reset validation (αν χρησιμοποιείς Bootstrap classes)
310
- this.input.setCustomValidity("");
311
- this.wrapper.classList.remove('is-invalid');
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;
312
703
 
313
- // 1. Τυπικό change & input (για γενική χρήση)
314
- this.input.dispatchEvent(new Event('change', {bubbles: true}));
315
- this.input.dispatchEvent(new Event('input', {bubbles: true}));
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
+ }
316
716
 
317
- // 2. ΤΟ ΚΛΕΙΔΙ: Μοναδικό event για το δικό μας HTMX Trigger
318
- this.input.dispatchEvent(new Event('country-change', {bubbles: true}));
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
+ }
759
+ }
319
760
 
320
- this._toggle(false);
321
- this.wrapper.focus();
322
- }
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
+ }
323
769
 
324
- _updateUI(country) {
325
- const content = this.wrapper.querySelector('.cs-selected-content');
326
- if (content) content.innerHTML = this._parseTemplate(this.schemaReturn, country);
327
- }
770
+ _choose(c) {
771
+ this._apply(c);
772
+ this.close(true);
773
+ }
774
+
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
+ }
328
787
 
329
- _toggle(force) {
330
- if (this.countries.length === 0) return;
331
- this.isOpen = force !== undefined ? force : !this.isOpen;
788
+ /* ------------------------------------------------------------ δημόσιο API */
332
789
 
333
- if (this.isOpen) {
334
- this._updatePosition();
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
+ });
335
795
  document.dispatchEvent(new CustomEvent('cs-close-others', {detail: {opener: this.wrapper}}));
336
796
 
337
- // ΑΛΛΑΓΗ: Έλεγχος αν η λίστα έχει ήδη στοιχεία
338
- const list = this.wrapper.querySelector('.cs-list');
339
- if (list.children.length === 0) {
797
+ if (this._filtered || this.searchEl.value) { // νέο άνοιγμα → χωρίς την προηγούμενη αναζήτηση
798
+ this.searchEl.value = '';
799
+ this.filteredCountries = this.countries.slice();
800
+ this._filtered = false;
340
801
  this._renderOptions();
341
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});
812
+ }
342
813
 
343
- list.style.maxHeight = `${this.rowLimit * this.rowHeight}px`;
344
-
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));
349
-
350
- // ΑΛΛΑΓΗ: Ενημέρωσε το active element αντί να κάνεις re-render όλη τη λίστα
351
- this._updateActiveElement();
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});
821
+ }
352
822
 
353
- if (this.activeIndex > -1) setTimeout(() => this._scrollToActive(true), 0); // 6.1.4: από την αρχή, αν η επιλεγμένη φαίνεται εκεί
354
- if (this.hasSearch) setTimeout(() => this.wrapper.querySelector('.cs-search-input').focus(), 50);
823
+ toggle() {
824
+ if (this.isOpen) this.close(true); else this.open();
355
825
  }
356
- this.wrapper.classList.toggle('open', this.isOpen);
357
- }
358
826
 
359
- _bindEvents() {
360
- this.wrapper.querySelector('.cs-trigger').onclick = (e) => {
361
- e.stopPropagation();
362
- this._toggle();
363
- };
827
+ /** Συμβατότητα με ≤ 6.1 */
828
+ _toggle(force) {
829
+ if (force === undefined) this.toggle(); else if (force) this.open(); else this.close();
830
+ }
364
831
 
365
- this.wrapper.addEventListener('keydown', (e) => {
366
- if (!this.isOpen && (e.key === 'ArrowDown')) {
367
- e.preventDefault();
368
- this._toggle(true);
369
- return;
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;
370
840
  }
841
+ this._apply(this._find(value), !!(opts && opts.silent));
842
+ return this;
843
+ }
371
844
 
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]);
396
- }
397
- }
398
- }
399
- });
845
+ getValue() {
846
+ return this.input.value;
847
+ }
400
848
 
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
- };
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;
408
853
  }
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');
416
- }
417
- });
418
- }
419
854
 
420
- /**
421
- * Scroll της λίστας στην επιλεγμένη χώρα.
422
- * 6.1.4: fromTop (άνοιγμα της λίστας) — αν η επιλεγμένη φαίνεται ήδη από την αρχή της λίστας, η λίστα ξεκινά από την αρχή
423
- * (πριν: κεντράριζε ΠΑΝΤΑ → με επιλεγμένη την 3η χώρα, π.χ. Greece μετά από Bulgaria · Cyprus, κρυβόταν η 1η / οι προτιμώμενες).
424
- * Θέση μέσα στη λίστα από τα getBoundingClientRect (το offsetTop μετρά από το .cs-dropdown — μαζί με το πεδίο αναζήτησης).
425
- */
426
- _scrollToActive(fromTop = false) {
427
- const list = this.wrapper.querySelector('.cs-list');
428
- const activeItem = list.querySelectorAll('.cs-option')[this.activeIndex];
429
- if (!activeItem) return;
430
- const top = activeItem.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop;
431
- if (fromTop && top + activeItem.offsetHeight <= list.clientHeight) {
432
- list.scrollTop = 0;
433
- return;
434
- }
435
- list.scrollTop = top - (list.clientHeight / 2) + (activeItem.offsetHeight / 2);
436
- }
437
- }
855
+ /** Νέα λίστα (array, function, JSON, URL) */
856
+ setCountries(source) {
857
+ this.source = source;
858
+ return this._loadData();
859
+ }
438
860
 
439
- /** AUTO-INIT (STATIC & HTMX) **/
440
- const initCS = (target) => {
441
- const root = (target && target.querySelectorAll) ? target : document;
861
+ /** Ξανά από την τιμή του input (μετά από input.value = ... χωρίς event) */
862
+ refresh() {
863
+ this._syncFromInput();
864
+ return this;
865
+ }
442
866
 
443
- if (root.matches?.('.country-select')) {
444
- if (root.dataset.csInitialized !== '1') {
445
- new CountrySelect(root);
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
+ }
446
884
  }
447
885
  }
448
886
 
449
- root.querySelectorAll?.('.country-select').forEach(el => {
450
- if (el.dataset.csInitialized === '1') return;
451
- new CountrySelect(el);
452
- });
453
- };
454
-
455
- const setupListeners = () => {
456
- initCS(document);
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
+ };
904
+
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; // τα δικά μας (επιλογές της λίστας κ.λπ.)
917
+ initCS(node);
918
+ }));
919
+ }).observe(document.documentElement, {childList: true, subtree: true});
920
+ }
457
921
 
458
- document.body.addEventListener('htmx:afterSettle', (e) => {
459
- initCS(e.detail.target || e.target);
460
- });
922
+ global.CountrySelect = CountrySelect;
923
+ if (!global.initCS) global.initCS = initCS; // ≤ 6.1: global συνάρτηση
924
+ if (typeof module === 'object' && module.exports) module.exports = CountrySelect;
461
925
 
462
- new MutationObserver((mutations) => {
463
- mutations.forEach(mu => {
464
- mu.addedNodes.forEach(node => {
465
- if (node.nodeType !== 1) return;
466
- initCS(node);
467
- });
468
- });
469
- }).observe(document.body, {
470
- childList: true,
471
- subtree: true
472
- });
473
- };
474
-
475
- if (document.readyState === 'loading') {
476
- document.addEventListener('DOMContentLoaded', setupListeners);
477
- } else {
478
- setupListeners();
479
- }
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);