✨ feature: Implement dynamic browser chrome tinting for header and footer visibility
This commit is contained in:
+4
-1
@@ -26,7 +26,10 @@ $showHero = $post->post_parent === $services ? true : false;
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="<?php bloginfo( 'charset' ); ?>">
|
<meta charset="<?php bloginfo( 'charset' ); ?>">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<!-- Status bar tint (top, behind notch/Dynamic Island). Updated at runtime
|
||||||
|
by static/js/modules/ChromeTint.js based on which element is in view. -->
|
||||||
|
<meta name="theme-color" content="#032F46">
|
||||||
|
|
||||||
<?php wp_head(); ?>
|
<?php wp_head(); ?>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Chrome Tint
|
||||||
|
* -
|
||||||
|
* Tint the browser chrome (status bar + home indicator area) to match
|
||||||
|
* the site header / footer when those elements are in view.
|
||||||
|
*
|
||||||
|
* - top of screen (status bar / notch / Dynamic Island):
|
||||||
|
* controlled by the <meta name="theme-color"> tag. iOS Safari
|
||||||
|
* picks up runtime updates.
|
||||||
|
*
|
||||||
|
* - bottom of screen (home indicator area, behind the address bar):
|
||||||
|
* a fixed pseudo-element filled with `env(safe-area-inset-bottom)`
|
||||||
|
* takes its background from a body class that this module toggles
|
||||||
|
* when the footer is in view.
|
||||||
|
*
|
||||||
|
* The header color (#032F46) is the darker end of the .site-header
|
||||||
|
* gradient. The footer color (#102C45) is the sRGB equivalent of
|
||||||
|
* the `--color-cwc-blue-02` token used by .site-footer. If those
|
||||||
|
* colors change in source, update HEADER_COLOR / FOOTER_COLOR below.
|
||||||
|
*
|
||||||
|
* - "In view" = any pixel of the element overlaps the viewport.
|
||||||
|
*
|
||||||
|
* No-ops if either the header or footer is missing, or if
|
||||||
|
* IntersectionObserver isn't supported (older browsers).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEADER_COLOR = '#032F46';
|
||||||
|
const FOOTER_COLOR = '#102C45';
|
||||||
|
const THEME_META_NAME = 'theme-color';
|
||||||
|
const FOOTER_IN_VIEW_CLASS = 'footer-in-view';
|
||||||
|
|
||||||
|
function ensureThemeColorMeta() {
|
||||||
|
let meta = document.head.querySelector(`meta[name="${THEME_META_NAME}"]`);
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.setAttribute('name', THEME_META_NAME);
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTopTint(color) {
|
||||||
|
const meta = ensureThemeColorMeta();
|
||||||
|
if (color == null) {
|
||||||
|
// Clear: iOS Safari falls back to the page background.
|
||||||
|
meta.removeAttribute('content');
|
||||||
|
} else {
|
||||||
|
meta.setAttribute('content', color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBottomTint(active) {
|
||||||
|
document.body.classList.toggle(FOOTER_IN_VIEW_CLASS, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChromeTint() {
|
||||||
|
if (typeof IntersectionObserver === 'undefined') return;
|
||||||
|
|
||||||
|
const header = document.querySelector('.site-header');
|
||||||
|
const footer = document.querySelector('.site-footer');
|
||||||
|
if (!header && !footer) return;
|
||||||
|
|
||||||
|
// Top tint state. Truthy means the header is currently in view.
|
||||||
|
let headerInView = false;
|
||||||
|
// Bottom tint state. Truthy means the footer is currently in view.
|
||||||
|
let footerInView = false;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
// Top tint is the header color ONLY when the header is in view.
|
||||||
|
// When only the footer is in view, the top tint is cleared (the
|
||||||
|
// user only asked for header-color at the top).
|
||||||
|
if (headerInView) {
|
||||||
|
setTopTint(HEADER_COLOR);
|
||||||
|
} else {
|
||||||
|
setTopTint(null);
|
||||||
|
}
|
||||||
|
// Bottom tint is the footer color ONLY when the footer is in view.
|
||||||
|
setBottomTint(footerInView);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildObserver = (el, onChange) => {
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
onChange(entry.isIntersecting);
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
// Zero threshold + no rootMargin = "any pixel in view".
|
||||||
|
{ threshold: 0, rootMargin: '0px' },
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return io;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (header) buildObserver(header, (v) => { headerInView = v; });
|
||||||
|
if (footer) buildObserver(footer, (v) => { footerInView = v; });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default initChromeTint;
|
||||||
@@ -9,6 +9,7 @@ import tagExternalLinks from './modules/TagExternalLinks.js';
|
|||||||
import Navigation from './modules/Navigation.js';
|
import Navigation from './modules/Navigation.js';
|
||||||
import initRecentPostsCarousels from './modules/RecentPostsCarousel.js';
|
import initRecentPostsCarousels from './modules/RecentPostsCarousel.js';
|
||||||
import { initContactInfoMap } from './modules/ContactInfoMap.js';
|
import { initContactInfoMap } from './modules/ContactInfoMap.js';
|
||||||
|
import initChromeTint from './modules/ChromeTint.js';
|
||||||
|
|
||||||
// Add passive event listeners
|
// Add passive event listeners
|
||||||
! function (e) {
|
! function (e) {
|
||||||
@@ -76,6 +77,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Initialize Contact Info Leaflet maps.
|
// Initialize Contact Info Leaflet maps.
|
||||||
initContactInfoMap();
|
initContactInfoMap();
|
||||||
|
|
||||||
|
// Tint the browser chrome (status bar + home indicator) to match
|
||||||
|
// the site header/footer when those elements are in view.
|
||||||
|
initChromeTint();
|
||||||
|
|
||||||
// Add Back to Top button to body
|
// Add Back to Top button to body
|
||||||
// const backToTop = document.createElement('x-back-to-top');
|
// const backToTop = document.createElement('x-back-to-top');
|
||||||
// document.body.appendChild(backToTop);
|
// document.body.appendChild(backToTop);
|
||||||
|
|||||||
@@ -70,3 +70,37 @@ main#maincontent {
|
|||||||
/* Responsive embeds */
|
/* Responsive embeds */
|
||||||
.embed { position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; }
|
.embed { position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; }
|
||||||
.embed iframe, .embed object, .embed embed, .embed video { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
.embed iframe, .embed object, .embed embed, .embed video { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bottom chrome tint.
|
||||||
|
*
|
||||||
|
* On devices with `viewport-fit=cover` (iOS Safari), the area behind
|
||||||
|
* the home indicator is part of the page background. The fixed
|
||||||
|
* pseudo-element below fills the `env(safe-area-inset-bottom)` band
|
||||||
|
* and shows the footer color when the body has the `.footer-in-view`
|
||||||
|
* class. That class is toggled by static/js/modules/ChromeTint.js
|
||||||
|
* when the .site-footer is in view.
|
||||||
|
*
|
||||||
|
* No effect on browsers without safe-area-inset-bottom (height resolves
|
||||||
|
* to 0px and the pseudo-element is invisible).
|
||||||
|
*/
|
||||||
|
body::after {
|
||||||
|
background: var(--color-background);
|
||||||
|
bottom: 0;
|
||||||
|
content: "";
|
||||||
|
height: env(safe-area-inset-bottom);
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.footer-in-view::after {
|
||||||
|
background: var(--color-cwc-blue-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
body::after { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome tint behavior:
|
||||||
|
*
|
||||||
|
* - Top tint (status bar / notch / Dynamic Island):
|
||||||
|
* <meta name="theme-color"> = HEADER_COLOR when the site header is
|
||||||
|
* in view; cleared (no content) otherwise.
|
||||||
|
*
|
||||||
|
* - Bottom tint (home indicator area, behind the address bar):
|
||||||
|
* <body class="footer-in-view"> when the site footer is in view;
|
||||||
|
* absent otherwise. The CSS body::after pseudo-element picks up
|
||||||
|
* the footer color from this class.
|
||||||
|
*
|
||||||
|
* Tests run against the local Herd site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const site = "http://community-works-collaborative.test/";
|
||||||
|
|
||||||
|
const HEADER_COLOR = "#032F46";
|
||||||
|
|
||||||
|
test.describe("chrome tint", () => {
|
||||||
|
test.use({ viewport: { width: 402, height: 874 } });
|
||||||
|
|
||||||
|
test("static theme-color meta is present in the document head", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
const content = await page.evaluate(() => {
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
return meta ? meta.getAttribute("content") : null;
|
||||||
|
});
|
||||||
|
// Either the server-rendered value or the value set by JS on
|
||||||
|
// initial paint (the header is in view at page top) should be
|
||||||
|
// the header color.
|
||||||
|
expect(content).toBe(HEADER_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewport meta includes viewport-fit=cover", async ({ page }) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
const viewport = await page.evaluate(() => {
|
||||||
|
const meta = document.querySelector('meta[name="viewport"]');
|
||||||
|
return meta ? meta.getAttribute("content") : null;
|
||||||
|
});
|
||||||
|
expect(viewport).toContain("viewport-fit=cover");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("top tint is set when header is in view at page top", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
// Wait for ChromeTint.js to run (it observes on DOMContentLoaded).
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => {
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
return meta && meta.getAttribute("content") === expected;
|
||||||
|
},
|
||||||
|
HEADER_COLOR,
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
const content = await page.evaluate(() =>
|
||||||
|
document.querySelector('meta[name="theme-color"]').getAttribute("content"),
|
||||||
|
);
|
||||||
|
expect(content).toBe(HEADER_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("top tint clears when neither header nor footer is in view", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
// Wait for the initial intersection observation to apply.
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => {
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
return meta && meta.getAttribute("content") === expected;
|
||||||
|
},
|
||||||
|
HEADER_COLOR,
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
// Scroll down so the header is out of view and the footer is not yet visible.
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
||||||
|
// Give the IntersectionObserver a tick to fire.
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const content = await page.evaluate(() => {
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
return meta ? meta.getAttribute("content") : null;
|
||||||
|
});
|
||||||
|
// When the header is out of view, the top tint should be cleared
|
||||||
|
// (no content attribute) so iOS falls back to the page background.
|
||||||
|
expect(content).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bottom tint class is absent when footer is out of view", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
// At page top, the footer is not in view.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const hasClass = await page.evaluate(() =>
|
||||||
|
document.body.classList.contains("footer-in-view"),
|
||||||
|
);
|
||||||
|
expect(hasClass).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bottom tint class is added when footer enters the viewport", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
// Scroll to the very bottom of the page.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.scrollTo(0, document.documentElement.scrollHeight),
|
||||||
|
);
|
||||||
|
// Wait for the IntersectionObserver to fire.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.body.classList.contains("footer-in-view"),
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
const hasClass = await page.evaluate(() =>
|
||||||
|
document.body.classList.contains("footer-in-view"),
|
||||||
|
);
|
||||||
|
expect(hasClass).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bottom tint class is removed when footer scrolls out of view", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
// Scroll to bottom first.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.scrollTo(0, document.documentElement.scrollHeight),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.body.classList.contains("footer-in-view"),
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
// Scroll back up.
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.body.classList.contains("footer-in-view"),
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
const hasClass = await page.evaluate(() =>
|
||||||
|
document.body.classList.contains("footer-in-view"),
|
||||||
|
);
|
||||||
|
expect(hasClass).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSS body::after fills the home indicator safe area", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(site, { waitUntil: "networkidle" });
|
||||||
|
// Verify the body::after pseudo-element has the right CSS
|
||||||
|
// (env(safe-area-inset-bottom) resolves to 0 on most desktops,
|
||||||
|
// so we just check that the rule is present and references the
|
||||||
|
// expected values).
|
||||||
|
const css = await page.evaluate(() => {
|
||||||
|
// We can't read pseudo-elements directly, but we can check
|
||||||
|
// the stylesheet for the rules.
|
||||||
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
|
try {
|
||||||
|
for (const rule of Array.from(sheet.cssRules)) {
|
||||||
|
if (rule.selectorText === "body::after") {
|
||||||
|
return rule.cssText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cross-origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
expect(css).toContain("env(safe-area-inset-bottom)");
|
||||||
|
expect(css).toContain("position: fixed");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user