Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sticky header tweaks og kommentarer #309

Merged
merged 9 commits into from
Jun 21, 2024
8 changes: 1 addition & 7 deletions packages/client/src/styles/sticky.module.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
/*
Note: this custom property is also used by consuming applications for positioning their own
sticky elements relative to the sticky header. It should specify the visible height of the
sticky header.
Do not change the name or value assignment logic of this property without notifying consumers.
*/
:root {
--decorator-sticky-offset: var(--header-height);
}
Expand All @@ -14,7 +8,7 @@
height: var(--header-height);
}

.stickyWrapper {
.absoluteWrapper {
position: absolute;
top: 0;
width: 100%;
Expand Down
122 changes: 73 additions & 49 deletions packages/client/src/views/sticky.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,112 @@
import cls from "decorator-client/src/styles/sticky.module.css";

const STICKY_OFFSET_PROPERTY = "--decorator-sticky-offset";

class Sticky extends HTMLElement {
private readonly outerElement: HTMLElement = this.querySelector(
`.${cls.stickyWrapper}`,
// This element is positioned relative to the top of the document and should
// update when the scroll position changes upwards.
private readonly absoluteElement: HTMLElement = this.querySelector(
`.${cls.absoluteWrapper}`,
)!;
private readonly innerElement: HTMLElement = this.querySelector(

// This element is nested under the absolute element, and is set to a fixed
// position when the header is fully scrolled into view. This prevents the
// header from sometimes moving erratically when the user is scrolling upwards.
private readonly fixedElement: HTMLElement = this.querySelector(
`.${cls.fixedWrapper}`,
)!;

private fixedLocked = false;
private deferredUpdate = false;
// Why we use two levels of positioning in this implementation:
// 1. Using only fixed positioning (and hiding the header with a negative top position)
// can cause the header to not reappear after certain DOM-mutations or various
// SPA behaviours. Catching every edge-case with such an implementation is tricky
// and would require more complexity.
// 2. Using only absolute positioning can cause the header to "bounce" when scrolling
// upwards (which updates the top position), degrading the user experience.

private menuIsOpen = false;
private deferUpdates = false;

private updateStickyPosition = () => {
if (this.deferredUpdate) {
private calculateAndUpdatePosition = () => {
if (this.deferUpdates) {
return;
}

const currentTop = this.outerElement.offsetTop;
const headerHeight = this.innerElement.clientHeight;
const scrollPos = window.scrollY;

const newTop = Math.min(
Math.max(currentTop, scrollPos - headerHeight),
const newOffset = Math.min(
Math.max(
this.getHeaderOffset(),
scrollPos - this.getHeaderHeight(),
),
scrollPos,
);

this.setStickyPosition(newTop);
this.setStickyPosition(newOffset);
};

private setStickyPosition = (position: number) => {
const scrollPos = window.scrollY;
const headerHeight = this.innerElement.clientHeight;

this.setFixed(position === scrollPos);
this.setFixed(position === scrollPos || this.menuIsOpen);

this.outerElement.style.top = `${position}px`;
this.absoluteElement.style.top = `${position}px`;

const visibleHeight = Math.max(
headerHeight + this.outerElement.offsetTop - scrollPos,
this.getHeaderHeight() + this.getHeaderOffset() - scrollPos,
0,
);

// This custom property is used by consuming applications for positioning their own
// sticky elements relative to the sticky header. It should specify the visible
// height of the sticky header.
// Do not change the name or value assignment of this property without notifying consumers.
document.documentElement.style.setProperty(
STICKY_OFFSET_PROPERTY,
"--decorator-sticky-offset",
`${visibleHeight}px`,
);
};

private setFixed = (fixed: boolean) => {
if (fixed || this.fixedLocked) {
this.innerElement.classList.add(cls.fixed);
if (fixed) {
this.fixedElement.classList.add(cls.fixed);
} else {
this.innerElement.classList.remove(cls.fixed);
this.fixedElement.classList.remove(cls.fixed);
}
};

// Set the header position to the top of the page and pause updates for a bit
// to ensure the header will not overlap elements which should be visible to
// the user.
// TODO: replace this janky solution with a scrollend event handler when
// browser support for this event has improved.
private deferStickyBehaviour = () => {
this.setStickyPosition(0);
this.deferredUpdate = true;
this.deferUpdates = true;

// TODO: replace this janky solution with a scrollend handler when browser support has improved
setTimeout(() => {
this.deferredUpdate = false;
this.deferUpdates = false;
}, 500);
};

private preventOverlapOnFocusChange = (e: FocusEvent) => {
const isWithinSticky = e
.composedPath?.()
?.some((path) =>
(path as HTMLElement)?.className?.includes(cls.fixedWrapper),
);

if (isWithinSticky) {
const targetElement = e.target as HTMLElement;
if (!targetElement) {
return;
}

const targetElement = e.target as HTMLElement;
if (!targetElement) {
// Ensure the header isn't hidden when the header itself gets focus
const targetIsInHeader = this.fixedElement.contains(targetElement);
if (targetIsInHeader) {
return;
}

const scrollPos = window.scrollY;
const targetPos = targetElement.offsetTop;
const headerHeight = this.innerElement.clientHeight;

const targetIsInHeaderArea =
targetPos >= scrollPos && targetPos <= scrollPos + headerHeight;
const targetIsOverlappedByHeader =
targetPos >= scrollPos &&
targetPos <= scrollPos + this.getHeaderHeight();

if (targetIsInHeaderArea) {
if (targetIsOverlappedByHeader) {
this.deferStickyBehaviour();
}
};
Expand All @@ -108,34 +124,42 @@ class Sticky extends HTMLElement {

const scrollPos = window.scrollY;
const targetPos = targetElement.offsetTop;
const headerHeight = this.innerElement.clientHeight;

const targetIsAboveHeader = targetPos <= scrollPos + headerHeight;
const targetIsAboveHeader =
targetPos <= scrollPos + this.getHeaderHeight();

if (targetIsAboveHeader) {
this.deferStickyBehaviour();
}
};

private getHeaderHeight = () => {
return this.fixedElement.clientHeight;
};

private getHeaderOffset = () => {
return this.absoluteElement.offsetTop;
};

private onMenuOpen = () => {
this.fixedLocked = true;
this.menuIsOpen = true;
this.setFixed(true);
};

private onMenuClose = () => {
this.fixedLocked = false;
this.outerElement.style.top = `${window.scrollY}px`;
this.updateStickyPosition();
this.menuIsOpen = false;
this.absoluteElement.style.top = `${window.scrollY}px`;
this.calculateAndUpdatePosition();
};

connectedCallback() {
if (!this.outerElement) {
if (!this.absoluteElement || !this.fixedElement) {
console.error("No sticky element found!");
return;
}

window.addEventListener("scroll", this.updateStickyPosition);
window.addEventListener("resize", this.updateStickyPosition);
window.addEventListener("scroll", this.calculateAndUpdatePosition);
window.addEventListener("resize", this.calculateAndUpdatePosition);

window.addEventListener("menuopened", this.onMenuOpen);
window.addEventListener("menuclosed", this.onMenuClose);
Expand All @@ -145,8 +169,8 @@ class Sticky extends HTMLElement {
}

disconnectedCallback() {
window.removeEventListener("scroll", this.updateStickyPosition);
window.removeEventListener("resize", this.updateStickyPosition);
window.removeEventListener("scroll", this.calculateAndUpdatePosition);
window.removeEventListener("resize", this.calculateAndUpdatePosition);

window.removeEventListener("menuopened", this.onMenuOpen);
window.removeEventListener("menuclosed", this.onMenuClose);
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/views/sticky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import cls from "decorator-client/src/styles/sticky.module.css";

export function Sticky({ children }: { children: Template }) {
return html` <d-sticky class="${cls.placeholder}">
<div class="${cls.stickyWrapper}">
<div class="${cls.absoluteWrapper}">
<div class="${cls.fixedWrapper}">${children}</div>
</div>
</d-sticky>`;
Expand Down
Loading