Skip to content

Commit a0b9944

Browse files
committed
webflow-navigation web component
1 parent cc0a106 commit a0b9944

8 files changed

Lines changed: 465 additions & 264 deletions

File tree

dist/navigation-test.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
</style>
1414
</head>
1515
<body>
16-
<div id="webflow-navigation-container"></div>
16+
<webflow-navigation></webflow-navigation>
1717
<script src="/navigation.js"></script>
1818

1919
<h1>Navigation Component Test</h1>

src/js/navigation.js

Lines changed: 40 additions & 263 deletions
Original file line numberDiff line numberDiff line change
@@ -1,272 +1,49 @@
1-
const loadWebflowNavigation = () => {
2-
let env = 'production';
3-
const queryEnv = window.location.search.match(/env=(\w+)/);
4-
if (queryEnv && queryEnv[1] === 'development') {
5-
env = 'development';
6-
}
7-
if (window.location.host.startsWith('localhost') || window.location.host.startsWith('127.0.0.1')) {
8-
env = 'development';
9-
}
10-
11-
console.log('env:', env, window.location.host);
12-
13-
const NAV_CONTAINER_ID = 'webflow-navigation-container';
14-
let navContainer;
15-
let shadowRoot;
16-
17-
window.webflowNavigationLoaded = (data) => {
18-
navContainer = document.querySelector(`#${NAV_CONTAINER_ID}`);
19-
if (!navContainer) {
20-
// console.error('Navigation container not found');
21-
return;
22-
}
23-
24-
if (data.error) {
25-
// console.error('Error loading navigation:', data.error);
26-
return;
27-
}
28-
29-
// Use Shadow DOM for CSS isolation — styles stay flat (no nested selector block)
30-
// which keeps DevTools responsive, and they can't leak to the host page.
31-
// Guard against double initialization (attachShadow throws if called twice).
32-
if (navContainer.shadowRoot) {
33-
navContainer.shadowRoot.innerHTML = '';
34-
shadowRoot = navContainer.shadowRoot;
35-
} else {
36-
shadowRoot = navContainer.attachShadow({ mode: 'open' });
37-
}
38-
39-
if (data.css) {
40-
// Rewrite global selectors to :host — use word-boundary-aware patterns
41-
// to avoid mangling class names like "body-text" or "html-embed".
42-
let css = data.css
43-
.replace(/(^|[},;\s]):root(?=\s*[{,:])/gm, '$1:host')
44-
.replace(/(^|[},;\s])body(?=\s*[{,:])/gm, '$1:host')
45-
.replace(/(^|[},;\s])html(?=\s*[{,:])/gm, '$1:host');
46-
47-
// Extract @font-face rules — they must live in the document head
48-
// to work reliably across browsers.
49-
// Use a balanced-brace approach to handle multi-line src descriptors.
50-
const fontFaces = [];
51-
css = css.replace(/@font-face\s*{([^}]|\n)*}/g, (match) => {
52-
fontFaces.push(match);
53-
return '';
54-
});
55-
if (fontFaces.length > 0) {
56-
// Remove any previously injected font-face style to avoid duplicates on HMR.
57-
document.querySelector('style[data-webflow-navigation-fonts]')?.remove();
58-
const fontStyle = document.createElement('style');
59-
fontStyle.setAttribute('data-webflow-navigation-fonts', '');
60-
fontStyle.textContent = fontFaces.join('\n');
61-
document.head.appendChild(fontStyle);
62-
}
63-
64-
css = `
65-
:host {
66-
all: initial;
67-
display: block;
68-
}
69-
*, *::before, *::after {
70-
-webkit-font-smoothing: antialiased;
71-
-moz-osx-font-smoothing: grayscale;
72-
box-sizing: border-box;
73-
}
74-
${css}
75-
.nav_main-flex-mobile,
76-
.nav_main-flex-img {
77-
z-index: 1;
78-
}
79-
.nav_main-content,
80-
.nav_highlights-wrapper {
81-
z-index: 2;
82-
}
83-
.w-dropdown-toggle[aria-expanded="true"] .nav_links_svg {
84-
transform: rotate(-180deg);
85-
}
86-
`;
87-
88-
// Use adoptedStyleSheets when available — the browser parses the CSS once
89-
// and shares the CSSStyleSheet object, which is faster and uses less memory
90-
// than a <style> element for large stylesheets.
91-
if ('adoptedStyleSheets' in Document.prototype) {
92-
const sheet = new CSSStyleSheet();
93-
sheet.replaceSync(css);
94-
shadowRoot.adoptedStyleSheets = [sheet];
95-
} else {
96-
const style = document.createElement('style');
97-
style.textContent = css;
98-
shadowRoot.appendChild(style);
99-
}
100-
}
101-
102-
// Sanitize HTML: remove <script> tags to prevent unexpected code execution,
103-
// and rewrite inline <style> selectors for :root/body/html → :host.
104-
const safeHTML = data.html
105-
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
106-
.replace(/(^|[},;\s]):root(?=\s*[{,:])/gm, '$1:host')
107-
.replace(/(^|[},;\s])body(?=\s*[{,:])/gm, '$1:host')
108-
.replace(/(^|[},;\s])html(?=\s*[{,:])/gm, '$1:host');
109-
110-
const wrapper = document.createElement('div');
111-
wrapper.innerHTML = safeHTML;
112-
shadowRoot.appendChild(wrapper);
113-
114-
const navMobileToggle = shadowRoot.querySelector('.w-nav-button');
115-
const navMenu = shadowRoot.querySelector('.w-nav-menu');
116-
if (!navMobileToggle || !navMenu) {
117-
// Navigation structure is incomplete — skip interactive setup.
118-
return;
119-
}
120-
let navOverlay = shadowRoot.querySelector('.w-nav-overlay');
121-
if (!navOverlay) {
122-
navOverlay = document.createElement('div');
123-
navOverlay.className = 'w-nav-overlay';
124-
navOverlay.style.display = 'none';
125-
navMobileToggle.insertAdjacentElement('afterend', navOverlay);
126-
}
1+
// ---------------------------------------------------------------------------
2+
// <webflow-navigation> custom element — entry point
3+
//
4+
// Usage: <webflow-navigation></webflow-navigation>
5+
// <webflow-navigation env="development"></webflow-navigation>
6+
//
7+
// All logic lives in src/js/navigation/*.js — this file only re-exports
8+
// the custom element registration and handles webpack HMR.
9+
// ---------------------------------------------------------------------------
10+
11+
// Importing WebflowNavigation registers the <webflow-navigation> custom element
12+
// and sets up the global loader callback. This runs in both dev and production.
13+
import { connectedInstances, cachedData, setCachedData, init } from './navigation/WebflowNavigation';
14+
import { renderNavigation } from './navigation/render';
15+
16+
// Explicit initialization — registers the custom element and global callback.
17+
init();
12718

128-
const openNavDropdown = (dropdown, animationDuration = 500) => {
129-
dropdown.querySelector('.w-dropdown-toggle')?.setAttribute('aria-expanded', 'true');
130-
const backdrop = shadowRoot.querySelector('.nav_dropdown_backdrop');
131-
if (backdrop) backdrop.style.opacity = 1;
132-
const megaWrapper = dropdown.querySelector('.navdropdown_mega-wrapper');
133-
const dropdownList = dropdown.querySelector('nav.w-dropdown-list');
134-
if (!megaWrapper || !dropdownList) return;
135-
megaWrapper.style.transition = `height ${animationDuration}ms ease, opacity ${animationDuration}ms ease`;
136-
megaWrapper.style.opacity = 0;
137-
dropdownList.classList.add('w--open');
138-
requestAnimationFrame(() => {
139-
const dropdownHeight = megaWrapper.clientHeight;
140-
megaWrapper.style.height = 0;
141-
requestAnimationFrame(() => {
142-
megaWrapper.style.height = `${dropdownHeight}px`;
143-
megaWrapper.style.opacity = 1;
144-
window.setTimeout(() => {
145-
megaWrapper.style.height = '';
146-
megaWrapper.style.opacity = '';
147-
}, animationDuration);
148-
});
149-
});
150-
};
151-
152-
const closeNavDropdown = (dropdown) => {
153-
dropdown.querySelector('.w-dropdown-toggle')?.removeAttribute('aria-expanded');
154-
dropdown.querySelector('nav.w-dropdown-list')?.classList.remove('w--open');
155-
const backdrop = shadowRoot.querySelector('.nav_dropdown_backdrop');
156-
if (backdrop) backdrop.style.opacity = 0;
157-
};
158-
159-
const transformMobileToggle = (open) => {
160-
const lineTop = navMobileToggle.querySelector('.nav_btn_line.is-top');
161-
const lineMiddle = navMobileToggle.querySelector('.nav_btn_line.is-middle');
162-
const lineBottom = navMobileToggle.querySelector('.nav_btn_line.is-bottom');
163-
if (!lineTop || !lineMiddle || !lineBottom) return;
164-
lineTop.style.transformStyle = 'preserve-3d';
165-
lineBottom.style.transformStyle = 'preserve-3d';
166-
if (open) {
167-
lineTop.style.transform =
168-
'translate3d(0px, 0.425rem, 0px) scale3d(1, 1, 1) rotateX(0deg) rotateY(0deg) rotateZ(45deg) skew(0deg, 0deg)';
169-
lineMiddle.style.opacity = 0;
170-
lineBottom.style.transform =
171-
'translate3d(0px, -0.425rem, 0px) scale3d(1, 1, 1) rotateX(0deg) rotateY(0deg) rotateZ(-45deg) skew(0deg, 0deg)';
172-
} else {
173-
lineTop.style.transform =
174-
'translate3d(0px, 0rem, 0px) scale3d(1, 1, 1) rotateX(0deg) rotateY(0deg) rotateZ(0deg) skew(0deg, 0deg)';
175-
lineMiddle.style.opacity = 1;
176-
lineBottom.style.transform =
177-
'translate3d(0px, 0rem, 0px) scale3d(1, 1, 1) rotateX(0deg) rotateY(0deg) rotateZ(0deg) skew(0deg, 0deg)';
19+
if (import.meta.webpackHot) {
20+
// Recover data cached by the previous module version.
21+
if (window.webflowNavHMRData) {
22+
setCachedData(window.webflowNavHMRData);
23+
delete window.webflowNavHMRData;
24+
// Re-render existing instances with the updated render function.
25+
document.querySelectorAll('webflow-navigation').forEach((el) => {
26+
if (el.isConnected && el.shadowRoot) {
27+
connectedInstances.add(el);
28+
renderNavigation(el, cachedData());
17829
}
179-
};
180-
181-
const openNavMenu = (animationDuration = 500) => {
182-
navMobileToggle.classList.add('w--open');
183-
navOverlay.appendChild(navMenu);
184-
navMenu.setAttribute('data-nav-menu-open', 'true');
185-
navOverlay.style.display = 'block';
186-
navOverlay.style.top = '100%';
187-
const navMenuHeight = navMenu.clientHeight;
188-
navMenu.style.height = 0;
189-
navMenu.style.overflow = 'hidden';
190-
navMenu.style.transition = `height ${animationDuration}ms ease`;
191-
requestAnimationFrame(() => {
192-
navMenu.style.height = `${navMenuHeight}px`;
193-
window.setTimeout(() => {
194-
navMenu.style.height = '';
195-
}, animationDuration);
196-
});
197-
};
198-
199-
const closeNavMenu = () => {
200-
navMobileToggle.classList.remove('w--open');
201-
navMobileToggle.insertAdjacentElement('beforebegin', navMenu);
202-
navOverlay.style.display = 'none';
203-
navMenu.removeAttribute('data-nav-menu-open');
204-
};
205-
206-
shadowRoot.querySelectorAll('.nav_dropdown_component.w-dropdown').forEach((dropdown) => {
207-
dropdown.addEventListener('mouseenter', () => {
208-
if (navMenu.hasAttribute('data-nav-menu-open')) return;
209-
openNavDropdown(dropdown);
210-
});
211-
dropdown.addEventListener('mouseleave', () => {
212-
if (navMenu.hasAttribute('data-nav-menu-open')) return;
213-
closeNavDropdown(dropdown);
214-
});
215-
dropdown.querySelector('.w-dropdown-toggle').addEventListener('click', (e) => {
216-
e.preventDefault();
217-
if (!navMenu.hasAttribute('data-nav-menu-open')) return;
218-
if (dropdown.querySelector('nav.w-dropdown-list').classList.contains('w--open')) {
219-
closeNavDropdown(dropdown);
220-
} else {
221-
openNavDropdown(dropdown);
222-
}
223-
});
22430
});
31+
}
22532

226-
if (navMobileToggle) {
227-
navMobileToggle.addEventListener('click', () => {
228-
if (!navMobileToggle.classList.contains('w--open')) {
229-
transformMobileToggle(true);
230-
openNavMenu();
231-
} else {
232-
transformMobileToggle(false);
233-
closeNavMenu();
33+
import.meta.webpackHot.dispose(() => {
34+
// Persist data across HMR cycles.
35+
window.webflowNavHMRData = cachedData();
36+
document.querySelectorAll('webflow-navigation').forEach((el) => {
37+
if (el.shadowRoot) {
38+
el.shadowRoot.innerHTML = '';
39+
if ('adoptedStyleSheets' in Document.prototype) {
40+
el.shadowRoot.adoptedStyleSheets = [];
23441
}
235-
});
236-
}
237-
};
238-
239-
const webflowNavigationLoader = document.createElement('script');
240-
document.head.appendChild(webflowNavigationLoader);
241-
webflowNavigationLoader.src =
242-
env === 'development' ? '/navigation_loader.js' : 'https://web-cdn.bitrise.io/navigation_loader.js';
243-
244-
return {
245-
el: navContainer,
246-
reset: () => {
247-
if (navContainer) {
248-
navContainer.innerHTML = '';
249-
shadowRoot = null;
250-
}
251-
if (webflowNavigationLoader) {
252-
webflowNavigationLoader.remove();
253-
}
254-
if (window.webflowNavigationLoaded) {
255-
delete window.webflowNavigationLoaded;
25642
}
257-
const fontStyle = document.querySelector('style[data-webflow-navigation-fonts]');
258-
if (fontStyle) {
259-
fontStyle.remove();
260-
}
261-
},
262-
};
263-
};
264-
265-
const webflowNavigation = loadWebflowNavigation();
266-
267-
if (import.meta.webpackHot) {
268-
import.meta.webpackHot.dispose(() => {
269-
if (webflowNavigation) webflowNavigation.reset();
43+
});
44+
document.querySelector('style[data-webflow-navigation-fonts]')?.remove();
45+
document.querySelector('script[data-webflow-navigation-loader]')?.remove();
27046
});
47+
27148
import.meta.webpackHot.accept();
27249
}

0 commit comments

Comments
 (0)