PageSourceSearch

https://www.humhub.com/js/country-picker.fb67698f698b03e8437df466795a25a91c1ae1fe8a364eef041696a903a7c9fa.js

js humhub.com collected 2026-09-24 09:37:55 UTC 7,555 bytes, 203 lines download raw bytes

1// Custom country picker: searchable dropdown with flag thumbnails.
2// Reveals on JS load; falls back to the native <select> when JS is off.
3
4(function () {
5  document.querySelectorAll('[data-country-picker]').forEach(init);
6
7  function init(root) {
8    var trigger   = root.querySelector('.cp-trigger');
9    var dropdown  = root.querySelector('.cp-dropdown');
10    var search    = root.querySelector('.cp-search');
11    var list      = root.querySelector('.cp-list');
12    var empty     = root.querySelector('.cp-empty');
13    var native    = root.querySelector('.cp-native');
14    var options   = Array.from(root.querySelectorAll('.cp-option'));
15    var groups    = Array.from(root.querySelectorAll('.cp-group'));
16    var triggerFlag = trigger.querySelector('.cp-flag');
17    var triggerName = trigger.querySelector('.cp-name');
18
19    // Pre-compute a diacritic-folded haystack per option so "oster" matches
20    // "Österreich", "etats" matches "États-Unis", "turkei" matches "Türkei".
21    function fold(s) {
22      return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
23    }
24    options.forEach(function (o) {
25      o._haystack = fold(o.dataset.name) + ' ' + o.dataset.code;
26    });
27
28    // Activate the custom UI (the native <select> stays in the DOM for form submission).
29    root.classList.add('is-ready');
30    native.setAttribute('tabindex', '-1');
31
32    var open = false;
33    var activeIndex = -1;
34
35    function placeholder() {
36      // First non-empty option in the native select gives us the localized placeholder
37      var ph = native.options[0] ? native.options[0].text : 'Select country';
38      triggerFlag.removeAttribute('src');
39      triggerFlag.style.display = 'none';
40      triggerName.textContent = ph;
41      triggerName.classList.add('is-placeholder');
42    }
43
44    function select(code, opts) {
45      opts = opts || {};
46      var opt = options.find(function (o) { return o.dataset.code === code; });
47      if (!opt) { placeholder(); return; }
48      native.value = code;
49      var flag = opt.dataset.flag;
50      if (flag) {
51        triggerFlag.src = flag;
52        triggerFlag.style.display = '';
53      }
54      triggerName.textContent = opt.dataset.name;
55      triggerName.classList.remove('is-placeholder');
56      options.forEach(function (o) { o.classList.toggle('is-selected', o === opt); });
57      // Notify other listeners (pricing calculator carries country into the URL)
58      if (!opts.silent) native.dispatchEvent(new Event('change', { bubbles: true }));
59    }
60
61    function setOpen(v) {
62      open = v;
63      dropdown.hidden = !v;
64      trigger.setAttribute('aria-expanded', v ? 'true' : 'false');
65      if (v) {
66        // Focus search; reset filter; jump active to selected (or first)
67        search.value = '';
68        filter('');
69        var selectedIdx = options.findIndex(function (o) { return o.classList.contains('is-selected'); });
70        activeIndex = selectedIdx >= 0 ? selectedIdx : 0;
71        updateActive();
72        scrollActiveIntoView();
73        setTimeout(function () { search.focus(); }, 0);
74      }
75    }
76
77    function updateActive() {
78      options.forEach(function (o, i) { o.classList.toggle('is-active', i === activeIndex); });
79    }
80
81    function scrollActiveIntoView() {
82      var el = options[activeIndex];
83      if (!el || el.hidden) return;
84      var box = list.getBoundingClientRect();
85      var elBox = el.getBoundingClientRect();
86      if (elBox.top < box.top) list.scrollTop -= (box.top - elBox.top);
87      else if (elBox.bottom > box.bottom) list.scrollTop += (elBox.bottom - box.bottom);
88    }
89
90    function visible() {
91      return options.filter(function (o) { return !o.hidden; });
92    }
93
94    function filter(q) {
95      q = fold(q.trim());
96      var anyVisible = false;
97      options.forEach(function (o) {
98        var match = !q || o._haystack.indexOf(q) !== -1;
99        o.hidden = !match;
100        if (match) anyVisible = true;
101      });
102      // Hide group headers whose group has no visible items
103      groups.forEach(function (g) {
104        var anyInGroup = false;
105        var n = g.nextElementSibling;
106        while (n && !n.classList.contains('cp-group')) {
107          if (n.classList.contains('cp-option') && !n.hidden) { anyInGroup = true; break; }
108          n = n.nextElementSibling;
109        }
110        g.hidden = !anyInGroup;
111      });
112      empty.hidden = anyVisible;
113      // Move active to first visible
114      if (q) {
115        var first = options.findIndex(function (o) { return !o.hidden; });
116        if (first >= 0) { activeIndex = first; updateActive(); }
117      }
118    }
119
120    function step(dir) {
121      var vis = visible();
122      if (!vis.length) return;
123      var currentInVis = vis.indexOf(options[activeIndex]);
124      var nextInVis = (currentInVis + dir + vis.length) % vis.length;
125      activeIndex = options.indexOf(vis[nextInVis]);
126      updateActive();
127      scrollActiveIntoView();
128    }
129
130    // --- Wiring ---
131    trigger.addEventListener('click', function (e) {
132      e.preventDefault();
133      setOpen(!open);
134    });
135
136    search.addEventListener('input', function () { filter(search.value); });
137
138    search.addEventListener('keydown', function (e) {
139      if (e.key === 'ArrowDown') { e.preventDefault(); step(1); }
140      else if (e.key === 'ArrowUp')   { e.preventDefault(); step(-1); }
141      else if (e.key === 'Enter')     {
142        e.preventDefault();
143        var el = options[activeIndex];
144        if (el && !el.hidden) { select(el.dataset.code); setOpen(false); trigger.focus(); }
145      }
146      else if (e.key === 'Escape')    { e.preventDefault(); setOpen(false); trigger.focus(); }
147    });
148
149    list.addEventListener('click', function (e) {
150      var el = e.target.closest('.cp-option');
151      if (!el) return;
152      select(el.dataset.code);
153      setOpen(false);
154      trigger.focus();
155    });
156
157    document.addEventListener('click', function (e) {
158      if (open && !root.contains(e.target)) setOpen(false);
159    });
160
161    // Allow Enter / ArrowDown on the trigger to open
162    trigger.addEventListener('keydown', function (e) {
163      if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
164        e.preventDefault();
165        setOpen(true);
166      }
167    });
168
169    // --- Preselection ---
170    var initialCode = preselect();
171    if (initialCode) select(initialCode, { silent: true });
172    else placeholder();
173
174    function preselect() {
175      // 1) Explicit ?country= (e.g. carried over from pricing → contact handoff)
176      var url = new URL(window.location.href);
177      var fromUrl = url.searchParams.get('country');
178      if (fromUrl && hasCode(fromUrl)) return fromUrl.toLowerCase();
179
180      var nav = (navigator.language || (navigator.languages && navigator.languages[0]) || '').toLowerCase();
181      var parts = nav.split('-');
182      var siteLang = (document.documentElement.lang || '').toLowerCase();
183
184      // 2) Browser region — only if its language matches the site's language.
185      //    (e.g. de-AT visitor on /de/ → AT; en-US visitor on /de/ → fall through.)
186      if (parts[0] === siteLang && parts[1] && hasCode(parts[1])) return parts[1];
187
188      // 3) Site language default
189      var hint = { de: 'de', en: 'us', fr: 'fr' }[siteLang];
190      if (hint && hasCode(hint)) return hint;
191
192      // 4) Loose fallback to navigator region
193      if (parts[1] && hasCode(parts[1])) return parts[1];
194      if (parts[0] && hasCode(parts[0])) return parts[0];
195
196      return null;
197    }
198
199    function hasCode(code) {
200      return options.some(function (o) { return o.dataset.code === code.toLowerCase(); });
201    }
202  }
203})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.