PageSourceSearch

https://campus.gov.il/wp-content/themes/campusnew/inc/design-system/select.js?var=7

js campus.gov.il collected 2026-10-02 06:35:04 UTC 10,847 bytes, 204 lines download raw bytes

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.