Skip to content

Commit d866a56

Browse files
Sky Nav: Remove layout shift caused by progressive enhancement (#1947)
1 parent a84e50f commit d866a56

File tree

5 files changed

+84
-2
lines changed

5 files changed

+84
-2
lines changed

.changeset/stale-donuts-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cloudfour/patterns': minor
3+
---
4+
5+
Remove small viewport Sky Nav layout shift caused by progressive enhancement

src/components/sky-nav/sky-nav.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ $_masthead-height-sm: ms.step(7);
265265
@media (min-width: $_breakpoint-wide) {
266266
display: none;
267267
}
268+
269+
/**
270+
* There is no need to show the menu toggle if JS is not available.
271+
*/
272+
.no-js & {
273+
display: none;
274+
}
268275
}
269276

270277
/**
@@ -345,6 +352,22 @@ $_masthead-height-sm: ms.step(7);
345352
list-style: none; /* 1 */
346353
padding-inline-start: 0; /* 1 */
347354

355+
/**
356+
* The Sky Nav is progressively enhanced with JS at smaller viewports. By default
357+
* it is open for when JS is not available. In cases where JS _is_ available,
358+
* this causes a poor loading UX where the menu jumps from open to closed
359+
* causing a large layout shift.
360+
*
361+
* To keep the progressive enhancement in place _and_ not have the layout shift,
362+
* we hide the menu items during the "loading" state. The "loading" state
363+
* happens after the UI renders and before the Sky Nav JS is loaded.
364+
*/
365+
@media (width < $_breakpoint-wide) {
366+
.is-loading & {
367+
display: none;
368+
}
369+
}
370+
348371
/**
349372
* When the layout is wide enough to expose these items…
350373
*

src/components/sky-nav/sky-nav.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,31 @@ const initSkyNavJS = (utils: PleasantestUtils, navButton: ElementHandle) =>
2121
test(
2222
'can be opened on small screens',
2323
withBrowser({ device: iPhone }, async ({ utils, screen, user, page }) => {
24-
await utils.injectHTML(await skyNavMarkup({ includeMainDemo: true, menu }));
24+
// The rendered markup needs to be captured so we can pass it into the
25+
// `page.evaluate` function below
26+
const renderedMarkup = await skyNavMarkup({
27+
includeMainDemo: true,
28+
menu,
29+
});
30+
// Pleasantest uses `document.innerHTML` to inject the markup into the DOM,
31+
// but that means inline scripts are not executed.
32+
// @see https://github.com/cloudfour/pleasantest/issues/526
33+
//
34+
// > HTML5 specifies that a <script> tag inserted with innerHTML should not execute.
35+
// @see https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML#security_considerations
36+
//
37+
// This causes this test to fail because the Sky Nav template has an inline
38+
// script. To get around that, the code below uses `document.write` instead
39+
// which _will_ execute the inline script.
40+
//
41+
// Using `document.write` is discouraged. For the purposes of this test,
42+
// though, it is okay.
43+
// @see https://developer.mozilla.org/en-US/docs/Web/API/Document/write
44+
await page.evaluate((renderedMarkup) => {
45+
document.body.innerHTML = '';
46+
document.write(renderedMarkup);
47+
}, renderedMarkup);
48+
2549
await loadGlobalCSS(utils);
2650
const navButton = await screen.getByRole('button', {
2751
name: /toggle main menu/i,

src/components/sky-nav/sky-nav.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const initSkyNav = (navButton: HTMLButtonElement) => {
2121
'(prefers-reduced-motion: reduce)'
2222
);
2323

24+
// The Sky Nav component has inline synchronous JS logic to add an `is-loading`
25+
// state to remove the layout shift at smaller viewports. That state no longer
26+
// applies at this point since the Sky Nav JS has loaded & is ready to take over.
27+
navWrapper.classList.remove('is-loading');
28+
2429
/**
2530
* Update Menu Layout
2631
* Sets visibility of menu & navButton for small vs large screen layouts.

src/components/sky-nav/sky-nav.twig

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
{% set main_id = main_id|default('main') %}
22
{% set _main_href = '#' ~ main_id %}
33

4-
<div class="c-sky-nav js-sky-nav{% if class %} {{ class }}{% endif %}">
4+
{#
5+
The Sky Nav component uses progressive enhancement and assumes JS is not
6+
available as the default. The `no-js` CSS class is the hook to style properly
7+
in that use case.
8+
#}
9+
<div class="c-sky-nav js-sky-nav no-js{% if class %} {{ class }}{% endif %}">
510
{# https://webaim.org/techniques/skipnav/ #}
611
{% embed '@cloudfour/components/button/button.twig' with {
712
class: 'c-sky-nav__skip',
@@ -72,6 +77,26 @@
7277
</nav>
7378
</header>
7479
</div>
80+
<script>
81+
{#
82+
The Sky Nav is progressively enhanced with JS at smaller viewports.
83+
By default it is open for when JS is not available. In cases where JS
84+
_is_ available, this causes a poor loading UX where the menu jumps from
85+
open to closed causing a large layout shift.
86+
87+
To keep the progressive enhancement in place _and_ not have the layout
88+
shift, the Sky Nav component assumes `no-js` to begin with. If JS is enabled:
89+
90+
1. Remove the `no-js` state (which removes no-JS menu styles)
91+
2. Add the `is-loading` state. This happens before the Sky Nav JS has loaded
92+
allowing us a hook to hide the menu items to remove the layout shift. Once
93+
the Sky Nav JS loads, it removes the `is-loading` state CSS class and the
94+
menu functions as was originally designed.
95+
#}
96+
const skyNav = document.querySelector('.js-sky-nav');
97+
skyNav.classList.remove('no-js'); // 1
98+
skyNav.classList.add('is-loading'); // 2
99+
</script>
75100

76101
{#
77102
For some reason Twig.js really doesn't like including this template in a demo,

0 commit comments

Comments
 (0)