Skip to content

Commit

Permalink
Reapply "Hide top on scroll (#589)" (#596)
Browse files Browse the repository at this point in the history
This reverts commit bb3a0e3.

# Motivation

The animation has been approved by design, so we can keep the current
implementation of the auto-hide header as it is.

# Changes

- Reapply "Hide top on scroll (#589)"

# Screenshots

More details in original pr --
#589
  • Loading branch information
mstrasinskis authored Feb 20, 2025
1 parent 617d456 commit c61e663
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 6 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 9 additions & 2 deletions src/lib/components/Content.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
import { onDestroy } from "svelte";
import ContentBackdrop from "$lib/components/ContentBackdrop.svelte";
import Header from "$lib/components/Header.svelte";
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
export let back = false;
// Observed: nested component - bottom sheet - might not call destroy when navigating route and therefore offset might not be reseted which is not the case here
onDestroy(() => ($layoutBottomOffset = 0));
let scrollContainer: HTMLDivElement;
</script>

<div
Expand All @@ -25,9 +28,13 @@
<slot name="toolbar-end" slot="toolbar-end" />
</Header>

<div class="scrollable-content" class:open={$layoutMenuOpen}>
<div
class="scrollable-content"
class:open={$layoutMenuOpen}
bind:this={scrollContainer}
>
<ContentBackdrop />

<ScrollSentinel {scrollContainer} />
<slot />
</div>
</div>
Expand Down
15 changes: 14 additions & 1 deletion src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import Toolbar from "$lib/components/Toolbar.svelte";
import MenuButton from "$lib/components/MenuButton.svelte";
import Back from "$lib/components/Back.svelte";
import { layoutContentTopHidden } from "$lib/stores/layout.store";
export let back = false;
</script>

<header data-tid="header-component">
<header data-tid="header-component" class:hidden={$layoutContentTopHidden}>
<Toolbar>
<svelte:fragment slot="start">
{#if back}
Expand All @@ -27,11 +28,23 @@
header {
--toolbar-padding: 0;
transition: all var(--animation-time-normal) ease-in-out;
@include media.min-width(medium) {
--toolbar-padding: 0 var(--padding-2x);
}
@include media.min-width(large) {
--toolbar-padding: 0;
}
&.hidden {
opacity: 0;
transform: translateY(-100%);
// Reset on tablet+
@include media.min-width(medium) {
opacity: 1;
transform: none;
}
}
}
</style>
6 changes: 5 additions & 1 deletion src/lib/components/Island.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onDestroy } from "svelte";
import { layoutContentScrollY } from "$lib/stores/layout.store";
import { BREAKPOINT_LARGE } from "$lib/constants/constants";
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
export let testId: string | undefined = undefined;
Expand All @@ -10,12 +11,15 @@
layoutContentScrollY.set(innerWidth < BREAKPOINT_LARGE ? "auto" : "hidden");
onDestroy(() => layoutContentScrollY.set("auto"));
let scrollContainer: HTMLElement;
</script>

<svelte:window bind:innerWidth />

<div class="island" data-tid={testId}>
<div class="scrollable-island">
<div class="scrollable-island" bind:this={scrollContainer}>
<ScrollSentinel {scrollContainer} />
<slot />
</div>
</div>
Expand Down
31 changes: 31 additions & 0 deletions src/lib/components/ScrollSentinel.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts">
import { layoutContentTopHidden } from "$lib/stores/layout.store";
import { onMount } from "svelte";
// The ScrollSentinel component should be placed right before the content
// inside the scrollable container.
export let scrollContainer: HTMLElement;
// To observe when the top leaves the view
let element: HTMLElement;
onMount(() => {
const observer = new IntersectionObserver(
([entry]) => layoutContentTopHidden.set(!entry.isIntersecting),
{ root: scrollContainer, threshold: 0 },
);
observer.observe(element);
return () => observer.disconnect();
});
</script>

<div data-tid="sentinel" class="sentinel" bind:this={element}></div>

<style lang="scss">
.sentinel {
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
</style>
2 changes: 2 additions & 0 deletions src/lib/components/SplitContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
} from "$lib/stores/layout.store";
import Header from "$lib/components/Header.svelte";
import ContentBackdrop from "$lib/components/ContentBackdrop.svelte";
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
export let back = false;
export const resetScrollPosition = () => {
Expand Down Expand Up @@ -43,6 +44,7 @@
<div class="scrollable-content-end" bind:this={scrollableElement}>
<ContentBackdrop />

<ScrollSentinel scrollContainer={scrollableElement} />
<slot name="end" />
</div>
</div>
Expand Down
19 changes: 17 additions & 2 deletions src/lib/components/SplitPane.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import { layoutMenuOpen } from "$lib/stores/layout.store";
import {
layoutContentTopHidden,
layoutMenuOpen,
} from "$lib/stores/layout.store";
let innerWidth = 0;
Expand All @@ -18,7 +21,7 @@

<svelte:window bind:innerWidth />

<div class="split-pane">
<div class="split-pane" class:header-hidden={$layoutContentTopHidden}>
<slot name="menu" />
<slot />
</div>
Expand All @@ -42,6 +45,18 @@
var(--header-offset, 0px) + var(--header-height)
);
padding-top: var(--split-pane-content-top-offset);
transition: padding-top var(--animation-time-normal) ease;
&.header-hidden {
padding-top: 0;
// Reset on tablet+
@include media.min-width(medium) {
padding-top: var(--split-pane-content-top-offset);
}
@include media.min-width(large) {
padding-top: var(--header-offset, 0);
}
}
:global(header) {
position: fixed;
Expand Down
1 change: 1 addition & 0 deletions src/lib/stores/layout.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ import { writable } from "svelte/store";
export const layoutBottomOffset = writable<number>(0);
export const layoutMenuOpen = writable<boolean>(false);
export const layoutContentScrollY = writable<"hidden" | "auto">("auto");
export const layoutContentTopHidden = writable<boolean>(false);
53 changes: 53 additions & 0 deletions src/tests/lib/components/ScrollSentinel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
import { layoutContentTopHidden } from "$lib/stores/layout.store";
import { render } from "@testing-library/svelte";
import { get } from "svelte/store";

describe("ScrollSentinel", () => {
let mockObserverInstance: MockIntersectionObserver;

class MockIntersectionObserver implements IntersectionObserver {
observe: (target: Element) => void = vi.fn();
unobserve: (target: Element) => void = vi.fn();
disconnect: () => void = vi.fn();
takeRecords: () => IntersectionObserverEntry[] = () => [];
root: Element | Document | null = null;
rootMargin: string = "";
thresholds: ReadonlyArray<number> = [];

constructor(private callback: IntersectionObserverCallback) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
mockObserverInstance = this;
}

// Simulates IntersectionObserver changes
trigger(entries: Partial<IntersectionObserverEntry>[]) {
this.callback(entries as IntersectionObserverEntry[], this);
}
}

beforeEach(() => {
vi.spyOn(global, "IntersectionObserver").mockImplementation(
(callback) => new MockIntersectionObserver(callback),
);
});

afterEach(() => {
vi.restoreAllMocks();
});

it("should render a sentinel element", () => {
const { container } = render(ScrollSentinel);
expect(container.querySelector("[data-tid='sentinel']")).not.toBeNull();
});

it("should update the store on intersection", () => {
expect(get(layoutContentTopHidden)).toBe(false);

mockObserverInstance.trigger([{ isIntersecting: false }]);
expect(get(layoutContentTopHidden)).toBe(true);

mockObserverInstance.trigger([{ isIntersecting: true }]);
expect(get(layoutContentTopHidden)).toBe(false);
});
});

0 comments on commit c61e663

Please sign in to comment.