Skip to content

Commit 776950e

Browse files
authored
Merge pull request #2370 from Financial-Times/BOLT-211-add-dropdown-options-for-subnav
Bolt 211 add dropdown options for subnav
2 parents 2dfa22a + 3cf16d1 commit 776950e

File tree

6 files changed

+864
-0
lines changed

6 files changed

+864
-0
lines changed

components/o-header/src/js/subnav.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import viewport from '@financial-times/o-viewport';
22
import * as oUtils from '@financial-times/o-utils';
3+
import { initSubnavDropdowns } from './subnavDropdown.js';
34

45
function init(headerEl) {
56
const subnav = headerEl.querySelector('[data-o-header-subnav]');
@@ -88,6 +89,8 @@ function init(headerEl) {
8889
button.onclick = scroll;
8990
});
9091

92+
initSubnavDropdowns(headerEl);
93+
9194
checkCurrentPosition();
9295
}
9396

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* Subnav Dropdown
3+
*
4+
* This script controls the behaviour of subnavigation dropdown options where present.
5+
* Desktop:
6+
* - Shown when hovering over the appropriate subnav item
7+
* - Hidden when the cursor is moved away for a set duration
8+
* - Positioned relative to the designated item
9+
*
10+
* Mobile:
11+
* - Shown when the subnav item is tapped
12+
* - Positioned centrally with a close icon available
13+
* - Hidden when the close icon is tapped
14+
*/
15+
16+
const INTENT_ENTER = 300;
17+
const INTENT_LEAVE = 400;
18+
const DEFAULT_DROPDOWN_WIDTH = 285;
19+
const POSITIONING_OFFSET = 8;
20+
21+
const expandedDropdowns = new Set();
22+
const dropdownEventListeners = new WeakMap();
23+
24+
function handleScroll() {
25+
expandedDropdowns.forEach(dropdown => {
26+
const parent = dropdown.parentNode;
27+
positionDropdown(dropdown, parent);
28+
});
29+
}
30+
31+
function closeAllDropdowns() {
32+
const dropdownsToClose = new Set(expandedDropdowns);
33+
dropdownsToClose.forEach(hideDropdown);
34+
}
35+
36+
function isDropdownOpen(dropdown) {
37+
return expandedDropdowns.has(dropdown);
38+
}
39+
40+
function addDropdownControlEvents(dropdown, isDesktop) {
41+
const currentDropdownEventListeners = dropdownEventListeners.get(dropdown);
42+
if (currentDropdownEventListeners) return;
43+
44+
const listeners = new Set();
45+
const registerListener = (target, type, callback) => {
46+
target.addEventListener(type, callback);
47+
listeners.add({ target, type, callback });
48+
};
49+
50+
if (isDesktop) {
51+
// Dropdowns scroll with the user on desktop
52+
registerListener(window, 'scroll', handleScroll);
53+
54+
const keydownHandler = (event) => {
55+
const key = event.key;
56+
57+
if (key === 'Escape' && isDropdownOpen(dropdown)) {
58+
hideDropdown(dropdown);
59+
}
60+
}
61+
registerListener(document, 'keydown', keydownHandler);
62+
} else {
63+
// The close button is only visible on mobile
64+
const closeButton = dropdown.querySelector('[data-o-header-subnav-dropdown-close]');
65+
if (closeButton) {
66+
const closeButtonClickHandler = (event) => {
67+
event.preventDefault();
68+
event.stopPropagation();
69+
hideDropdown(dropdown);
70+
};
71+
registerListener(closeButton, 'click', closeButtonClickHandler);
72+
}
73+
}
74+
75+
dropdownEventListeners.set(dropdown, listeners);
76+
}
77+
78+
function removeDropdownControlEvents (dropdown) {
79+
const currentDropdownEventListeners = dropdownEventListeners.get(dropdown);
80+
currentDropdownEventListeners.forEach((listener) => {
81+
const { target, type, callback } = listener;
82+
target.removeEventListener(type, callback)
83+
})
84+
dropdownEventListeners.delete(dropdown);
85+
}
86+
87+
function positionDropdown(dropdown, hoverTarget) {
88+
if (!hoverTarget) {
89+
return;
90+
}
91+
92+
const targetRect = hoverTarget.getBoundingClientRect();
93+
const viewportWidth = window.innerWidth;
94+
95+
const dropdownWidth = DEFAULT_DROPDOWN_WIDTH;
96+
97+
let left = targetRect.left;
98+
99+
if (left + dropdownWidth > viewportWidth) {
100+
left = Math.max(POSITIONING_OFFSET, viewportWidth - dropdownWidth - POSITIONING_OFFSET);
101+
}
102+
103+
Object.assign(dropdown.style, {
104+
position: 'fixed',
105+
top: `${targetRect.bottom + POSITIONING_OFFSET}px`,
106+
left: `${left}px`,
107+
zIndex: '10000',
108+
transform: 'none',
109+
right: 'auto',
110+
bottom: 'auto',
111+
margin: '0'
112+
});
113+
}
114+
115+
function showDropdown(dropdown, isDesktop) {
116+
dropdown.setAttribute('aria-hidden', 'false');
117+
dropdown.setAttribute('aria-expanded', 'true');
118+
dropdown.style.display = 'block';
119+
120+
expandedDropdowns.add(dropdown);
121+
122+
addDropdownControlEvents(dropdown, isDesktop)
123+
}
124+
125+
function hideDropdown(dropdown) {
126+
dropdown.setAttribute('aria-hidden', 'true');
127+
dropdown.setAttribute('aria-expanded', 'false');
128+
dropdown.style.display = 'none';
129+
130+
expandedDropdowns.delete(dropdown);
131+
132+
removeDropdownControlEvents(dropdown)
133+
}
134+
135+
function addDropdownShowHideEvents(parent, dropdown) {
136+
let timeout;
137+
const isDesktop = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
138+
if (isDesktop) {
139+
parent.addEventListener('mouseenter', () => {
140+
clearTimeout(timeout);
141+
142+
if (isDropdownOpen(dropdown)) {
143+
return;
144+
}
145+
146+
positionDropdown(dropdown, parent);
147+
148+
timeout = setTimeout(() => {
149+
if (expandedDropdowns.size > 0) {
150+
closeAllDropdowns();
151+
}
152+
showDropdown(dropdown, true);
153+
}, INTENT_ENTER);
154+
});
155+
156+
parent.addEventListener('mouseleave', () => {
157+
clearTimeout(timeout);
158+
timeout = setTimeout(() => {
159+
if (isDropdownOpen(dropdown)) {
160+
hideDropdown(dropdown);
161+
}
162+
}, INTENT_LEAVE);
163+
});
164+
} else {
165+
// For Mobile: click/tap events
166+
parent.addEventListener('click', () => {
167+
if (!isDropdownOpen(dropdown)) {
168+
if (expandedDropdowns.size > 0) {
169+
closeAllDropdowns();
170+
}
171+
showDropdown(dropdown, false);
172+
}
173+
});
174+
}
175+
}
176+
177+
function initSubnavDropdowns(headerEl) {
178+
const dropdowns = Array.from(headerEl.querySelectorAll('[data-o-header-subnav-dropdown]'));
179+
const parents = dropdowns.map(dropdown => dropdown.parentNode);
180+
181+
parents.forEach((parent, i) => addDropdownShowHideEvents(parent, dropdowns[i]));
182+
}
183+
184+
export { initSubnavDropdowns };
185+
export default { initSubnavDropdowns };
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export interface DropdownItem {
2+
label: string;
3+
url: string;
4+
}
5+
6+
export interface SubnavItemWithDropdown {
7+
label: string;
8+
url: string;
9+
selected?: boolean;
10+
dropdown?: DropdownItem[];
11+
}
12+
13+
const items: SubnavItemWithDropdown[] = [
14+
{
15+
label: 'MONETARY POLICY RADAR',
16+
url: '/',
17+
selected: true,
18+
},
19+
{
20+
label: 'Federal Reserve',
21+
url: '',
22+
dropdown: [
23+
{label: 'Coverage', url: '/banks/federal-reserve/coverage'},
24+
{label: 'Policy rate scenarios', url: '/banks/federal-reserve/policy-rate-scenarios'},
25+
{label: 'Doves and hawks', url: '/banks/federal-reserve/doves-and-hawks'},
26+
{label: 'Central bankers views', url: '/banks/federal-reserve/views'},
27+
],
28+
},
29+
{
30+
label: 'European Central Bank',
31+
url: '',
32+
dropdown: [
33+
{label: 'Coverage', url: '/banks/european-central-bank/coverage'},
34+
{label: 'Policy rate scenarios', url: '/banks/european-central-bank/policy-rate-scenarios'},
35+
{label: 'Doves and hawks', url: '/banks/european-central-bank/doves-and-hawks'},
36+
{label: 'Central bankers views', url: '/banks/european-central-bank/views'},
37+
],
38+
},
39+
{
40+
label: 'Bank of England',
41+
url: '',
42+
selected: false,
43+
dropdown: [
44+
{label: 'Coverage', url: '/banks/bank-of-england/coverage'},
45+
{label: 'Policy rate scenarios', url: '/banks/bank-of-england/policy-rate-scenarios'},
46+
{label: 'Doves and hawks', url: '/banks/bank-of-england/doves-and-hawks'},
47+
{label: 'Central bankers views', url: '/banks/bank-of-england/views'},
48+
],
49+
},
50+
{
51+
label: 'Global',
52+
url: '/global',
53+
},
54+
];
55+
56+
export default items;

0 commit comments

Comments
 (0)