1/* Shared custom <select> replacement â keyboard-accessible listbox behaviour. 2 Wires every [data-custom-select] on the page and exposes window.cilInitSelect(root) 3 so client-rendered selects can be initialised after they are inserted. 4 5 Markup contract is documented in design-system/select.css. 6 Picking an option sets the hidden input, updates the label, and fires a `change` event 7 on that input, so callers react exactly as they would to a native <select>. If the 8 select sits inside a <form>, the form is submitted too â that is what the list-view 9 filters rely on, so keep client-driven selects outside a form. */ 10(function () { 11 'use strict'; 12 13 function $(sel, ctx) { return (ctx || document).querySelector(sel); } 14 function $$(sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); } 15 16 var openSelect = null; 17 18 function initSelect(root) { 19 if (root.__cilSelectReady) return; 20 root.__cilSelectReady = true; 21 22 var btn = $('.cil-select__button', root); 23 var menu = $('.cil-select__menu', root); 24 var hidden = $('[data-select-value]', root); 25 var label = $('[data-select-label]', root); 26 if (!btn || !menu || !hidden || !label) return; 27 28 // Options are read on demand, never cached: consumers may render them after this 29 // select is wired (the Translations locale picker fills its listbox from boot data). 30 function opts() { return $$('li[role="option"]', menu); } 31 var active = -1; 32 33 btn.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); }); 34 btn.addEventListener('keydown', function (e) { 35 if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } 36 }); 37 // Delegated so options inserted later still respond. 38 menu.addEventListener('click', function (e) { 39 var li = e.target.closest('li[role="option"]'); 40 if (li) pick(opts().indexOf(li)); 41 }); 42 menu.addEventListener('mouseover', function (e) { 43 var li = e.target.closest('li[role="option"]'); 44 if (li) setActive(opts().indexOf(li)); 45 }); 46 menu.addEventListener('keydown', function (e) { 47 var list = opts(); 48 if (!list.length) return; 49 if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); } 50 else if (e.key === 'ArrowDown') { e.preventDefault(); setActive((active + 1) % list.length); } 51 else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((active - 1 + list.length) % list.length); } 52 else if (e.key === 'Home') { e.preventDefault(); setActive(0); } 53 else if (e.key === 'End') { e.preventDefault(); setActive(list.length - 1); } 54 else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (active >= 0) pick(active); } 55 else if (e.key.length === 1) { 56 var idx = list.findIndex(function (o) { return o.textContent.trim().toLowerCase().indexOf(e.key.toLowerCase()) === 0; }); 57 if (idx >= 0) setActive(idx); 58 } 59 }); 60 61 /** 62 * Pins the open menu to the viewport. 63 * 64 * An absolutely positioned menu still counts towards the document's scrollable area, so a 65 * menu opened near the foot of a screen stretched the page and made it jump as the 66 * scrollbar appeared. Fixed positioning takes it out of that calculation entirely: it 67 * overlays whatever is beneath it and the page never changes size. The trade is that fixed 68 * coordinates do not follow the field on their own, so they are recomputed while open. 69 */ 70 function placeMenu() { 71 // Neutralise every edge the stylesheet sets before measuring. Clearing these to '' is 72 // not enough: the closed-state rules (top: calc(100% + 6px), inset-inline-start: 0, 73 // min-width: 100%) then apply against the viewport once the menu is fixed, which puts 74 // the menu off-screen and stretches it. 'auto' is what actually releases them. 75 menu.style.maxHeight = 'none'; 76 menu.style.top = 'auto'; 77 menu.style.bottom = 'auto'; 78 menu.style.insetInlineStart = 'auto'; 79 menu.style.insetInlineEnd = 'auto'; 80 81 var anchor = btn.getBoundingClientRect(); 82 // scrollHeight is the content's natural height, so it does not inherit the clamp from a 83 // previous placement â measuring offsetHeight fed the last max-height back in. 84 var wanted = menu.scrollHeight; 85 var gap = 6, margin = 8; 86 var below = Math.max(window.innerHeight - anchor.bottom - gap - margin, 0);
87 var above = Math.max(anchor.top - gap - margin, 0); 88 89 /* Downwards whenever the list fits, which is nearly always: a menu that appears in the 90 same place every time is easier to use than one that guesses. 91 92 It flips only when down is genuinely too small to be worth showing. Clamping to the 93 room below was the previous answer, and near the foot of the screen that left a 94 field opening a list a few pixels tall â every option technically reachable by 95 scrolling inside a sliver, none of them actually readable. Above is chosen only when 96 it is the larger side, so the flip buys real room rather than trading one cramped 97 menu for another. */ 98 var MIN_USEFUL = 180; 99 var flip = wanted > below && above > below && below < MIN_USEFUL; 100 var room = flip ? above : below; 101 102 menu.style.position = 'fixed'; 103 // Clamped to the room on the chosen side, so the menu can never run past either edge of 104 // the screen â a list that still does not fit scrolls inside itself. 105 menu.style.maxHeight = Math.min(wanted, room) + 'px'; 106 menu.style.width = anchor.width + 'px'; 107 menu.style.minWidth = anchor.width + 'px'; 108 menu.style.left = anchor.left + 'px'; 109 if (flip) { 110 // Pinned by its foot, so the menu grows upwards from the field rather than 111 // downwards from a top edge that would move as the list's height changes. 112 menu.style.bottom = (window.innerHeight - anchor.top + gap) + 'px'; 113 menu.style.top = 'auto'; 114 } else { 115 menu.style.top = (anchor.bottom + gap) + 'px'; 116 menu.style.bottom = 'auto'; 117 } 118 root.classList.toggle('is-above', flip); 119 } 120 121 /** 122 * Re-place the menu when the page moves under it. 123 * 124 * Scrolling *inside* the menu must not trigger this. The listener is bound in capture 125 * phase to catch scrolling ancestors, which also catches the menu's own scroll â and 126 * placeMenu resets max-height to measure, which resets scrollTop. A long list therefore 127 * snapped back to the top on every wheel tick and could not be scrolled at all. 128 */ 129 function reposition(event) { 130 if (event && event.target && menu.contains(event.target)) return; 131 if (root.classList.contains('is-open')) placeMenu(); 132 } 133 134 function open() { 135 if (openSelect && openSelect !== root) openSelect.__close(); 136 root.classList.add('is-open'); 137 menu.hidden = false; 138 btn.setAttribute('aria-expanded', 'true'); 139 // Claim the open slot before anything that could throw, so outside-click always closes. 140 openSelect = root; 141 placeMenu(); 142 // Capture phase: a scrolling ancestor does not bubble its scroll event to window. 143 window.addEventListener('scroll', reposition, true); 144 window.addEventListener('resize', reposition); 145 var sel = opts().findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
146 setActive(sel >= 0 ? sel : 0); 147 setTimeout(function () { menu.focus(); }, 10); 148 } 149 function close() { 150 root.classList.remove('is-open'); 151 menu.hidden = true; 152 btn.setAttribute('aria-expanded', 'false'); 153 window.removeEventListener('scroll', reposition, true); 154 window.removeEventListener('resize', reposition); 155 // Drop the inline geometry so the closed menu carries no stale position. 156 menu.style.position = menu.style.top = menu.style.bottom = ''; 157 menu.style.left = menu.style.width = menu.style.maxHeight = ''; 158 menu.style.minWidth = menu.style.insetInlineStart = menu.style.insetInlineEnd = ''; 159 setActive(-1); 160 if (openSelect === root) openSelect = null; 161 } 162 root.__close = close; 163 164 function setActive(i) { 165 var list = opts(); 166 active = i; 167 list.forEach(function (o, idx) { o.classList.toggle('is-active', idx === i); }); 168 if (i >= 0 && list[i]) list[i].scrollIntoView({block: 'nearest'}); 169 } 170 function pick(i) { 171 var list = opts(); 172 if (i < 0 || !list[i]) return; 173 list.forEach(function (o, idx) { o.setAttribute('aria-selected', idx === i ? 'true' : 'false'); }); 174 hidden.value = list[i].dataset.value; 175 label.textContent = list[i].querySelector('span').textContent; 176 close(); btn.focus(); 177 hidden.dispatchEvent(new Event('change', {bubbles: true})); 178 // Auto-submit so filter selects apply immediately, matching native <select onchange>. 179 // Opt out with data-no-submit when the select is one field of a form the visitor is 180 // still filling in â the contact form relies on this. 181 var form = root.closest('form'); 182 if (form && !root.hasAttribute('data-no-submit')) form.submit(); 183 } 184 } 185 186 /** Wire a single select, or every unwired one when called with no argument. */ 187 window.cilInitSelect = function (root) { 188 if (root) initSelect(root); 189 else $$('[data-custom-select]').forEach(initSelect); 190 }; 191 192 document.addEventListener('click', function (e) { 193 if (openSelect && !openSelect.contains(e.target)) openSelect.__close(); 194 }); 195 document.addEventListener('keydown', function (e) { 196 if (e.key === 'Escape' && openSelect) openSelect.__close(); 197 }); 198 199 if (document.readyState === 'loading') { 200 document.addEventListener('DOMContentLoaded', function () { window.cilInitSelect(); }); 201 } else { 202 window.cilInitSelect(); 203 } 204})();
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.