PageSourceSearch

https://www.governmentjobs.com/Scripts/header-v2.js

js governmentjobs.com collected 2026-10-09 22:42:03 UTC 16,593 bytes, 434 lines download raw bytes

1// Header V2 - User menu dropdown functionality
2// ONLINE-41719: Keyboard accessibility (Tab, Escape, Arrow keys)
3
4(function() {
5    'use strict';
6
7    const HEADER_GUTTER = 8;
8
9    const header = document.getElementById('header');
10
11    if (!header) {
12        return;
13    }
14
15    function applyStickyHeaderOffset() {
16        const styles = globalThis.getComputedStyle(header);
17        const overlaysContent = styles.position === 'fixed' || styles.position === 'sticky';
18
19        const offset = (overlaysContent && styles.display !== 'none' && styles.visibility !== 'hidden')
20            ? Math.ceil(header.getBoundingClientRect().height) + HEADER_GUTTER
21            : 0;
22
23        document.documentElement.style.setProperty('scroll-padding-top', offset + 'px');
24    }
25
26    applyStickyHeaderOffset();
27
28    if (globalThis.ResizeObserver) {
29        new globalThis.ResizeObserver(applyStickyHeaderOffset).observe(header);
30    } else {
31        globalThis.addEventListener('resize', applyStickyHeaderOffset);
32        globalThis.addEventListener('orientationchange', applyStickyHeaderOffset);
33    }
34})();
35
36(function() {
37    'use strict';
38
39    const userMenuTrigger = document.getElementById('user-menu-trigger');
40    const userMenuDropdown = document.getElementById('user-menu-dropdown');
41
42    if (!userMenuTrigger || !userMenuDropdown) {
43        return;
44    }
45
46    // Prevent double initialization if script loads twice
47    if (userMenuTrigger.dataset.headerV2Initialized) {
48        return;
49    }
50    userMenuTrigger.dataset.headerV2Initialized = 'true';
51
52    const VIEWPORT_GUTTER = 16;
53    const MIN_MENU_HEIGHT = 120;
54    const DEFER_TO_NEXT_TICK = 0;  // Allows native focus move to complete before collapsing
55
56    // ONLINE-41717: the trigger's accessible name must keep the visible user name (WCAG 2.5.3
57    // Label in Name) and track the expanded state. The rendered label is the collapsed wording,
58    // so the name is derived from it rather than rebuilt from the display name.
59    const collapsedMenuLabel = userMenuTrigger.dataset.collapsedLabel || '';
60    const expandedMenuLabel = collapsedMenuLabel.replace(' to open ', ' to close ');
61
62    function applyTriggerLabel(expanded) {
63        if (collapsedMenuLabel) {
64            userMenuTrigger.setAttribute('aria-label', expanded ? expandedMenuLabel : collapsedMenuLabel);
65        }
66    }
67
68    // ONLINE-41956: Dynamic max-height for small viewports. Static CSS calc() doesn't account
69    // for expanded mobile panels, so trailing items paint off-screen. This measures actual space.
70    function applyAvailableHeight() {
71        if (!isOpen()) {
72            return;
73        }
74
75        // Measure from the untouched position so repeated calls do not compound.
76        userMenuDropdown.style.maxHeight = '';
77        userMenuDropdown.style.marginTop = '';
78
79        if (globalThis.getComputedStyle(userMenuDropdown).position === 'static') {
80            return;
81        }
82
83        const dropdownTop = userMenuDropdown.getBoundingClientRect().top;
84        let available = globalThis.innerHeight - dropdownTop - VIEWPORT_GUTTER;
85
86        // Extremely short viewports (landscape phone, on-screen keyboard open) leave less room
87        // below the trigger than MIN_MENU_HEIGHT. Pull the menu up over the trigger so it still
88        // fits, capped so its top never leaves the viewport. margin-top is used instead of
89        // transform because the `dropdown-appear` animation owns the transform property.
90        if (available < MIN_MENU_HEIGHT) {
91            const shift = Math.min(MIN_MENU_HEIGHT - available, Math.max(dropdownTop - VIEWPORT_GUTTER, 0));
92
93            if (shift > 0) {
94                const baseMarginTop = Number.parseFloat(globalThis.getComputedStyle(userMenuDropdown).marginTop) || 0;
95
96                userMenuDropdown.style.marginTop = (baseMarginTop - shift) + 'px';
97                available += shift;
98            }
99        }
100
101        userMenuDropdown.style.maxHeight = Math.max(available, MIN_MENU_HEIGHT) + 'px';
102    }
103
104    function isOpen() {
105        return userMenuTrigger.getAttribute('aria-expanded') === 'true';
106    }
107
108    // ONLINE-41719: re-query items on each keypress (Vetted and SavedJobs are conditionally rendered).
109    function getItems() {
110        return Array.prototype.slice.call(userMenuDropdown.querySelectorAll('.dropdown-item'))
111            .filter(isFocusable);
112    }
113
114    function isFocusable(item) {
115        return globalThis.getComputedStyle(item).display !== 'none';
116    }
117
118    function focusItem(items, index) {
119        if (items.length) {
120            items[(index + items.length) % items.length].focus();
121        }
122    }
123
124    function preventAndStop(e) {
125        e.preventDefault();
126        e.stopPropagation();
127    }
128
129    function handleArrowDown(items, currentIndex) {
130        if (!isOpen()) {
131            openDropdown();
132        }
133        focusItem(items, currentIndex + 1);
134    }
135
136    function handleArrowUp(items, currentIndex) {
137        if (!isOpen()) {
138            openDropdown();
139            focusItem(items, -1);
140        } else {
141            focusItem(items, currentIndex - 1);
142        }
143    }
144
145    function handleHome(items) {
146        if (isOpen()) {
147            focusItem(items, 0);
148        }
149    }
150
151    function handleEnd(items) {
152        if (isOpen()) {
153            focusItem(items, -1);
154        }
155    }
156
157    function handleTab(items, currentIndex, shiftKey) {
158        if (isOpen() && (shiftKey ? currentIndex === 0 : currentIndex === items.length - 1)) {
159            setTimeout(closeDropdown, DEFER_TO_NEXT_TICK);
160        }
161    }
162
163    function openDropdown() {
164        userMenuTrigger.setAttribute('aria-expanded', 'true');
165        applyTriggerLabel(true);
166        userMenuDropdown.setAttribute('aria-hidden', 'false');
167        userMenuDropdown.style.display = 'block';
168        // iOS Safari: reading offsetHeight forces a synchronous reflow so the browser
169        // promotes the dropdown to its own compositing layer before the next paint.
170        // Without this, the dropdown can be invisible on physical iPhones when the
171        // sticky header's GPU layer doesn't yet include the absolutely-positioned overflow.
172        userMenuDropdown.offsetHeight; // eslint-disable-line no-unused-expressions
173        applyAvailableHeight();
174    }
175
176    function closeDropdown() {
177        userMenuTrigger.setAttribute('aria-expanded', 'false');
178        applyTriggerLabel(false);
179        userMenuDropdown.setAttribute('aria-hidden', 'true');
180        userMenuDropdown.style.display = 'none';
181        userMenuDropdown.style.maxHeight = '';
182        userMenuDropdown.style.marginTop = '';
183    }
184
185    // iOS Safari: touchstart can fire a document-level click before the trigger's own click.
186    // Track when the trigger was last touched so the outside-dismiss handler can ignore
187    // events that arrive within the same gesture (16 ms covers a single event-loop tick).
188    var lastTriggerInteractionTime = 0;
189    var touchHandled = false;
190
191    userMenuTrigger.addEventListener('touchstart', function() {
192        lastTriggerInteractionTime = Date.now();
193        touchHandled = false;
194    }, { passive: true });
195
196    // ONLINE-42934: iPhone menu not opening — touchend event handler added to ensure
197    // touch gestures properly toggle the dropdown on iOS devices. The click event
198    // alone was insufficient on some iOS versions (Safari/Chrome on iPhone 15).
199    userMenuTrigger.addEventListener('touchend', function(e) {
200        if (touchHandled) {
201            return;
202        }
203        touchHandled = true;
204        e.preventDefault();
205        e.stopPropagation();
206        lastTriggerInteractionTime = Date.now();
207
208        if (isOpen()) {
209            closeDropdown();
210        } else {
211            openDropdown();
212        }
213    });
214
215    userMenuTrigger.addEventListener('click', function(e) {
216        // Prevent click from firing after touchend has already handled it
217        if (touchHandled) {
218            touchHandled = false;
219            return;
220        }
221
222        e.stopPropagation();
223        lastTriggerInteractionTime = Date.now();
224
225        if (isOpen()) {
226            closeDropdown();
227        } else {
228            openDropdown();
229        }
230    });
231
232    // Keyboard navigation: bring the focused item into view. `block: 'nearest'` scrolls the
233    // menu (or the page, when the menu is not the scrolling box) by the minimum amount and
234    // does nothing when the item is already visible, so it never fights the sticky header.
235    userMenuDropdown.addEventListener('focusin', function(e) {
236        const item = e.target.closest ? e.target.closest('.dropdown-item') : null;
237
238        if (item) {
239            item.scrollIntoView({ block: 'nearest' });
240        }
241    });
242
243    // ONLINE-41719 #2: Escape on trigger or any item closes menu and returns focus.
244    // Listener on container (not dropdown) because trigger sits outside #user-menu-dropdown.
245    const userMenuContainer = userMenuTrigger.parentNode;
246
247    userMenuContainer.addEventListener('keydown', function(e) {
248        if (!isOpen()) {
249            return;
250        }
251
252        if (e.key === 'Escape' || e.key === 'Esc') {
253            preventAndStop(e);
254            userMenuTrigger.focus();
255            closeDropdown();
256        }
257    });
258
259    // ONLINE-41719 #3: arrow-key navigation. Registered with capture=true so it runs
260    // BEFORE bootstrap-dropdown.js, which calls stopPropagation() on arrow keys but
261    // then fails to find items (it expects `[role=menu] li a`, we have direct <a> children).
262    userMenuContainer.addEventListener('keydown', function(e) {
263        const items = getItems();
264        if (!items.length) {
265            return;
266        }
267
268        const currentIndex = items.indexOf(document.activeElement);
269
270        switch (e.key) {
271            case 'ArrowDown':
272            case 'Down':
273                preventAndStop(e);
274                handleArrowDown(items, currentIndex);
275                break;
276
277            case 'ArrowUp':
278            case 'Up':
279                preventAndStop(e);
280                handleArrowUp(items, currentIndex);
281                break;
282
283            case 'Home':
284                if (isOpen()) {
285                    preventAndStop(e);
286                    handleHome(items);
287                }
288                break;
289
290            case 'End':
291                if (isOpen()) {
292                    preventAndStop(e);
293                    handleEnd(items);
294                }
295                break;
296
297            case 'Tab':
298                handleTab(items, currentIndex, e.shiftKey);
299                break;
300        }
301    }, true);
302
303    // The available space changes with rotation, browser zoom and on-screen keyboards.
304    globalThis.addEventListener('resize', applyAvailableHeight);
305    globalThis.addEventListener('orientationchange', applyAvailableHeight);
306
307    if (globalThis.visualViewport) {
308        globalThis.visualViewport.addEventListener('resize', applyAvailableHeight);
309    }
310
311    // ONLINE-42934: iOS dropdown link navigation fix.
312    // On iOS, touch events can interfere with link clicks. We need to explicitly
313    // handle touchend on dropdown items and trigger navigation programmatically.
314    var dropdownTouchStartTarget = null;
315    var dropdownTouchStartTime = 0;
316
317    userMenuDropdown.addEventListener('touchstart', function(e) {
318        // Track which item was touched
319        dropdownTouchStartTarget = e.target.closest ? e.target.closest('.dropdown-item') : null;
320        dropdownTouchStartTime = Date.now();
321        console.log('ONLINE-42934 touchstart:', dropdownTouchStartTarget ? dropdownTouchStartTarget.getAttribute('href') : 'null');
322    }, { passive: true });
323
324    userMenuDropdown.addEventListener('touchend', function(e) {
325        var touchEndTarget = e.target.closest ? e.target.closest('.dropdown-item') : null;
326        var touchDuration = Date.now() - dropdownTouchStartTime;
327
328        console.log('ONLINE-42934 touchend:', {
329            touchEndTarget: touchEndTarget ? touchEndTarget.getAttribute('href') : 'null',
330            touchStartTarget: dropdownTouchStartTarget ? dropdownTouchStartTarget.getAttribute('href') : 'null',
331            sameTarget: touchEndTarget === dropdownTouchStartTarget,
332            duration: touchDuration
333        });
334
335        // Only handle if touch started and ended on the same dropdown item
336        // and the duration was short (not a scroll or long press)
337        if (touchEndTarget && touchEndTarget === dropdownTouchStartTarget && touchDuration < 500) {
338            // Get the href from the link
339            var href = touchEndTarget.getAttribute('href');
340            var target = touchEndTarget.getAttribute('target');
341
342            console.log('ONLINE-42934 navigating to:', href, 'target:', target);
343
344            // ONLINE-42934: Handle touch directly and prevent the delayed synthetic click
345            // (which is suppressed on Profile/Account Settings pages anyway)
346            e.preventDefault();
347            e.stopPropagation();
348
349            // For javascript:void(0) / # links the action lives in onclick, not href.
350            // HTML onclick attributes are always exposed as element.onclick by the browser,
351            // so call it directly — no eval/new Function needed.
352            if (href === 'javascript:void(0)' || href === '#') {
353                console.log('ONLINE-42934 invoking onclick handler directly');
354                if (typeof touchEndTarget.onclick === 'function') {
355                    touchEndTarget.onclick.call(touchEndTarget, e);
356                }
357                return;
358            }
359
360            if (href) {
361                // Navigate programmatically
362                if (target === '_blank') {
363                    // Open in new tab
364                    console.log('ONLINE-42934 opening new tab');
365                    window.open(href, '_blank', 'noopener,noreferrer');
366                    // Close menu after opening new tab
367                    setTimeout(function() {
368                        if (isOpen()) {
369                            closeDropdown();
370                        }
371                    }, 100);
372                } else if (href.startsWith('javascript:')) {
373                    // Handle javascript: links with actual code (not void(0))
374                    console.log('ONLINE-42934 executing javascript');
375                    try {
376                        eval(href.substring(11)); // Execute the javascript code
377                    } catch (ex) {
378                        console.error('Error executing javascript link:', ex);
379                    }
380                } else {
381                    // Normal navigation - redirect the page
382                    console.log('ONLINE-42934 redirecting to:', href);
383                    window.location.href = href;
384                }
385            } else {
386                console.log('ONLINE-42934 no href found!');
387            }
388        } else {
389            console.log('ONLINE-42934 conditions not met - NOT navigating');
390        }
391
392        dropdownTouchStartTarget = null;
393    });
394
395    // Fallback click handler for desktop/non-touch devices
396    userMenuDropdown.addEventListener('click', function(e) {
397        var clickedItem = e.target.closest ? e.target.closest('.dropdown-item') : null;
398
399        if (clickedItem) {
400            // For target="_blank" links, close menu after click
401            var isNewTabLink = clickedItem.getAttribute('target') === '_blank';
402            if (isNewTabLink) {
403                setTimeout(function() {
404                    if (isOpen()) {
405                        closeDropdown();
406                    }
407                }, 100);
408            }
409        }
410    });
411
412    // Outside-dismiss handler: close menu when clicking outside both trigger and dropdown
413    document.addEventListener('click', function(e) {
414        if (!isOpen()) return;
415
416        // Check if click is on a dropdown item - if so, let the click handler above deal with it
417        var clickedDropdownItem = e.target.closest ? e.target.closest('.dropdown-item') : null;
418        if (clickedDropdownItem && userMenuDropdown.contains(clickedDropdownItem)) {
419            // Click is on a dropdown link - don't interfere
420            return;
421        }
422
423        // ONLINE-42934: Don't close if recent touch on trigger (iOS 
423Safari synthetic click delay)
424        var timeSinceTriggerInteraction = Date.now() - lastTriggerInteractionTime;
425        if (timeSinceTriggerInteraction < 300) {
426            return;
427        }
428
429        // Close only if click is outside both trigger and dropdown
430        if (!userMenuTrigger.contains(e.target) && !userMenuDropdown.contains(e.target)) {
431            closeDropdown();
432        }
433    });
434})();

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.