Skip to content

Commit b177693

Browse files
Hide top on scroll (#589)
# Motivation To improve the mobile experience by maximizing screen space, this update hides the header when the user scrolls down. Initially, the approach was to hide the header on scroll down and show it on scroll up. However, this caused glitches due to container resizing because of SplitPage/padding: 0. Instead, a more reliable solution was chosen: hiding the header when the scrollable content starts leaving the visible viewport. demo: https://qsgjb-riaaa-aaaaa-aaaga-cai.mstr-ingress.devenv.dfinity.network/ # Changes - Introduced a new ScrollSentinel component that observes when it leaves the viewport and updates layoutContentTopHidden. - The layoutContentTopHidden value is now used to toggle the header’s visibility (to removed SplitPage top padding and the header when the value is true) - Tested on iOS and Android. # Screenshots https://github.com/user-attachments/assets/72df4b50-f0b6-4a80-862c-4b6f48fe95fd ### The glitch of the first approach https://github.com/user-attachments/assets/e3b3c78e-7185-42a9-a92b-a4b40e127b35 --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 4ba38aa commit b177693

File tree

9 files changed

+132
-6
lines changed

9 files changed

+132
-6
lines changed
-1.16 KB
Loading

src/lib/components/Content.svelte

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
import { onDestroy } from "svelte";
88
import ContentBackdrop from "$lib/components/ContentBackdrop.svelte";
99
import Header from "$lib/components/Header.svelte";
10+
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
1011
1112
export let back = false;
1213
1314
// 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
1415
onDestroy(() => ($layoutBottomOffset = 0));
16+
17+
let scrollContainer: HTMLDivElement;
1518
</script>
1619

1720
<div
@@ -25,9 +28,13 @@
2528
<slot name="toolbar-end" slot="toolbar-end" />
2629
</Header>
2730

28-
<div class="scrollable-content" class:open={$layoutMenuOpen}>
31+
<div
32+
class="scrollable-content"
33+
class:open={$layoutMenuOpen}
34+
bind:this={scrollContainer}
35+
>
2936
<ContentBackdrop />
30-
37+
<ScrollSentinel {scrollContainer} />
3138
<slot />
3239
</div>
3340
</div>

src/lib/components/Header.svelte

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import Toolbar from "$lib/components/Toolbar.svelte";
33
import MenuButton from "$lib/components/MenuButton.svelte";
44
import Back from "$lib/components/Back.svelte";
5+
import { layoutContentTopHidden } from "$lib/stores/layout.store";
56
67
export let back = false;
78
</script>
89

9-
<header data-tid="header-component">
10+
<header data-tid="header-component" class:hidden={$layoutContentTopHidden}>
1011
<Toolbar>
1112
<svelte:fragment slot="start">
1213
{#if back}
@@ -27,11 +28,23 @@
2728
2829
header {
2930
--toolbar-padding: 0;
31+
transition: all var(--animation-time-normal) ease-in-out;
32+
3033
@include media.min-width(medium) {
3134
--toolbar-padding: 0 var(--padding-2x);
3235
}
3336
@include media.min-width(large) {
3437
--toolbar-padding: 0;
3538
}
39+
40+
&.hidden {
41+
opacity: 0;
42+
transform: translateY(-100%);
43+
// Reset on tablet+
44+
@include media.min-width(medium) {
45+
opacity: 1;
46+
transform: none;
47+
}
48+
}
3649
}
3750
</style>

src/lib/components/Island.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onDestroy } from "svelte";
33
import { layoutContentScrollY } from "$lib/stores/layout.store";
44
import { BREAKPOINT_LARGE } from "$lib/constants/constants";
5+
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
56
67
export let testId: string | undefined = undefined;
78
@@ -10,12 +11,15 @@
1011
layoutContentScrollY.set(innerWidth < BREAKPOINT_LARGE ? "auto" : "hidden");
1112
1213
onDestroy(() => layoutContentScrollY.set("auto"));
14+
15+
let scrollContainer: HTMLElement;
1316
</script>
1417

1518
<svelte:window bind:innerWidth />
1619

1720
<div class="island" data-tid={testId}>
18-
<div class="scrollable-island">
21+
<div class="scrollable-island" bind:this={scrollContainer}>
22+
<ScrollSentinel {scrollContainer} />
1923
<slot />
2024
</div>
2125
</div>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script lang="ts">
2+
import { layoutContentTopHidden } from "$lib/stores/layout.store";
3+
import { onMount } from "svelte";
4+
5+
// The ScrollSentinel component should be placed right before the content
6+
// inside the scrollable container.
7+
export let scrollContainer: HTMLElement;
8+
9+
// To observe when the top leaves the view
10+
let element: HTMLElement;
11+
12+
onMount(() => {
13+
const observer = new IntersectionObserver(
14+
([entry]) => layoutContentTopHidden.set(!entry.isIntersecting),
15+
{ root: scrollContainer, threshold: 0 },
16+
);
17+
observer.observe(element);
18+
return () => observer.disconnect();
19+
});
20+
</script>
21+
22+
<div data-tid="sentinel" class="sentinel" bind:this={element}></div>
23+
24+
<style lang="scss">
25+
.sentinel {
26+
width: 0;
27+
height: 0;
28+
opacity: 0;
29+
visibility: hidden;
30+
}
31+
</style>

src/lib/components/SplitContent.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
} from "$lib/stores/layout.store";
88
import Header from "$lib/components/Header.svelte";
99
import ContentBackdrop from "$lib/components/ContentBackdrop.svelte";
10+
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
1011
1112
export let back = false;
1213
export const resetScrollPosition = () => {
@@ -43,6 +44,7 @@
4344
<div class="scrollable-content-end" bind:this={scrollableElement}>
4445
<ContentBackdrop />
4546

47+
<ScrollSentinel scrollContainer={scrollableElement} />
4648
<slot name="end" />
4749
</div>
4850
</div>

src/lib/components/SplitPane.svelte

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
<script lang="ts">
2-
import { layoutMenuOpen } from "$lib/stores/layout.store";
2+
import {
3+
layoutContentTopHidden,
4+
layoutMenuOpen,
5+
} from "$lib/stores/layout.store";
36
47
let innerWidth = 0;
58
@@ -18,7 +21,7 @@
1821

1922
<svelte:window bind:innerWidth />
2023

21-
<div class="split-pane">
24+
<div class="split-pane" class:header-hidden={$layoutContentTopHidden}>
2225
<slot name="menu" />
2326
<slot />
2427
</div>
@@ -42,6 +45,18 @@
4245
var(--header-offset, 0px) + var(--header-height)
4346
);
4447
padding-top: var(--split-pane-content-top-offset);
48+
transition: padding-top var(--animation-time-normal) ease;
49+
50+
&.header-hidden {
51+
padding-top: 0;
52+
// Reset on tablet+
53+
@include media.min-width(medium) {
54+
padding-top: var(--split-pane-content-top-offset);
55+
}
56+
@include media.min-width(large) {
57+
padding-top: var(--header-offset, 0);
58+
}
59+
}
4560
4661
:global(header) {
4762
position: fixed;

src/lib/stores/layout.store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ import { writable } from "svelte/store";
33
export const layoutBottomOffset = writable<number>(0);
44
export const layoutMenuOpen = writable<boolean>(false);
55
export const layoutContentScrollY = writable<"hidden" | "auto">("auto");
6+
export const layoutContentTopHidden = writable<boolean>(false);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import ScrollSentinel from "$lib/components/ScrollSentinel.svelte";
2+
import { layoutContentTopHidden } from "$lib/stores/layout.store";
3+
import { render } from "@testing-library/svelte";
4+
import { get } from "svelte/store";
5+
6+
describe("ScrollSentinel", () => {
7+
let mockObserverInstance: MockIntersectionObserver;
8+
9+
class MockIntersectionObserver implements IntersectionObserver {
10+
observe: (target: Element) => void = vi.fn();
11+
unobserve: (target: Element) => void = vi.fn();
12+
disconnect: () => void = vi.fn();
13+
takeRecords: () => IntersectionObserverEntry[] = () => [];
14+
root: Element | Document | null = null;
15+
rootMargin: string = "";
16+
thresholds: ReadonlyArray<number> = [];
17+
18+
constructor(private callback: IntersectionObserverCallback) {
19+
// eslint-disable-next-line @typescript-eslint/no-this-alias
20+
mockObserverInstance = this;
21+
}
22+
23+
// Simulates IntersectionObserver changes
24+
trigger(entries: Partial<IntersectionObserverEntry>[]) {
25+
this.callback(entries as IntersectionObserverEntry[], this);
26+
}
27+
}
28+
29+
beforeEach(() => {
30+
vi.spyOn(global, "IntersectionObserver").mockImplementation(
31+
(callback) => new MockIntersectionObserver(callback),
32+
);
33+
});
34+
35+
afterEach(() => {
36+
vi.restoreAllMocks();
37+
});
38+
39+
it("should render a sentinel element", () => {
40+
const { container } = render(ScrollSentinel);
41+
expect(container.querySelector("[data-tid='sentinel']")).not.toBeNull();
42+
});
43+
44+
it("should update the store on intersection", () => {
45+
expect(get(layoutContentTopHidden)).toBe(false);
46+
47+
mockObserverInstance.trigger([{ isIntersecting: false }]);
48+
expect(get(layoutContentTopHidden)).toBe(true);
49+
50+
mockObserverInstance.trigger([{ isIntersecting: true }]);
51+
expect(get(layoutContentTopHidden)).toBe(false);
52+
});
53+
});

0 commit comments

Comments
 (0)