Skip to content

Commit 68deb4b

Browse files
committed
feat: add standard variant for bottom app bar with scrolling behavior
1 parent 8448267 commit 68deb4b

File tree

6 files changed

+261
-11
lines changed

6 files changed

+261
-11
lines changed

packages/bottom-app-bar/_mixins.scss

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ $acceleration-curve-timing-function: cubic-bezier(0.4, 0, 1, 1) !default;
1818
display: flex;
1919
flex-direction: row;
2020
width: 100%;
21+
--smui-bottom-app-bar--fab-offset: 0px;
2122

22-
&.smui-bottom-app-bar--fixed {
23+
&.smui-bottom-app-bar--fixed,
24+
&.smui-bottom-app-bar--standard {
2325
position: fixed;
2426
bottom: 0;
2527
left: 0;
@@ -42,9 +44,11 @@ $acceleration-curve-timing-function: cubic-bezier(0.4, 0, 1, 1) !default;
4244
}
4345

4446
.mdc-fab {
45-
margin-top: -1 *
46-
($section-padding-vertical + math.div($fab-diameter, 2));
47-
// transform: translateY(-50%);
47+
position: relative;
48+
top: calc(
49+
var(--smui-bottom-app-bar--fab-offset, 0px) -
50+
($section-padding-vertical + math.div($fab-diameter, 2))
51+
);
4852
}
4953

5054
&:first-child {
@@ -138,7 +142,8 @@ $acceleration-curve-timing-function: cubic-bezier(0.4, 0, 1, 1) !default;
138142
@include feature-targeting.targets($feat-color) {
139143
background: radial-gradient(
140144
($fab-diameter + ($inset-fab-padding * 2))
141-
($fab-diameter + ($inset-fab-padding * 2)) at $xpos 0,
145+
($fab-diameter + ($inset-fab-padding * 2)) at $xpos
146+
var(--smui-bottom-app-bar--fab-offset, 0px),
142147
rgba(0, 0, 0, 0)
143148
((math.div($fab-diameter, 2) + $inset-fab-padding) * 0.99),
144149
$color ((math.div($fab-diameter, 2) + $inset-fab-padding) * 1.01)

packages/bottom-app-bar/src/BottomAppBar.svelte

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
<svelte:window on:scroll={handleTargetScroll} on:resize={handleWindowResize} />
2+
13
<div
24
bind:this={element}
35
use:useActions={use}
46
use:forwardEvents
57
class={classMap({
68
[className]: true,
79
'smui-bottom-app-bar': true,
10+
'smui-bottom-app-bar--standard': variant === 'standard',
811
'smui-bottom-app-bar--fixed': variant === 'fixed',
912
})}
13+
style={Object.entries(internalStyles)
14+
.map(([name, value]) => `${name}: ${value};`)
15+
.concat([style])
16+
.join(' ')}
1017
{...$$restProps}
1118
>
1219
<slot />
@@ -28,8 +35,9 @@
2835
type OwnProps = {
2936
use?: ActionArray;
3037
class?: string;
38+
style?: string;
3139
color?: 'default' | 'primary' | 'secondary' | string;
32-
variant?: 'fixed' | 'static';
40+
variant?: 'fixed' | 'static' | 'standard';
3341
};
3442
type $$Props = OwnProps & SmuiAttrs<'div', OwnProps>;
3543
@@ -39,17 +47,18 @@
3947
export let use: ActionArray = [];
4048
let className = '';
4149
export { className as class };
50+
export let style = '';
4251
export let color: 'default' | 'primary' | 'secondary' | string = 'primary';
43-
export let variant: 'fixed' | 'static' = 'fixed';
52+
export let variant: 'fixed' | 'static' | 'standard' = 'standard';
4453
4554
let element: HTMLDivElement;
4655
56+
let internalStyles: { [k: string]: string } = {};
4757
const colorStore = writable(color);
4858
$: $colorStore = color;
4959
setContext('SMUI:bottom-app-bar:color', colorStore);
50-
5160
let propStoreSet: Subscriber<{
52-
variant: 'fixed' | 'static';
61+
variant: 'fixed' | 'static' | 'standard';
5362
}>;
5463
let propStore = readable({ variant }, (set) => {
5564
propStoreSet = set;
@@ -60,6 +69,190 @@
6069
});
6170
}
6271
72+
function addStyle(name: string, value: string) {
73+
if (internalStyles[name] != value) {
74+
if (value === '' || value == null) {
75+
delete internalStyles[name];
76+
internalStyles = internalStyles;
77+
} else {
78+
internalStyles[name] = value;
79+
}
80+
}
81+
}
82+
83+
// A lot of this code was adapted from
84+
// https://github.com/material-components/material-components-web/blob/v14.0.0/packages/mdc-top-app-bar/standard/foundation.ts
85+
86+
const DEBOUNCE_THROTTLE_RESIZE_TIME_MS = 100;
87+
/**
88+
* Indicates if the bottom app bar was docked in the previous scroll handler iteration.
89+
*/
90+
let wasDocked = true;
91+
/**
92+
* Indicates if the bottom app bar is docked in the fully shown position.
93+
*/
94+
let isDockedShowing = true;
95+
/**
96+
* Variable for current scroll position of the bottom app bar
97+
*/
98+
let currentAppBarOffsetBottom = 0;
99+
/**
100+
* Used to prevent the bottom app bar from being scrolled out of view during resize events
101+
*/
102+
let isCurrentlyBeingResized = false;
103+
/**
104+
* The timeout that's used to throttle the resize events
105+
*/
106+
let resizeThrottleId: NodeJS.Timeout | number = 0;
107+
/**
108+
* Used for diffs of current scroll position vs previous scroll position
109+
*/
110+
let lastScrollPosition: number;
111+
/**
112+
* Used to verify when the bottom app bar is completely showing or completely hidden
113+
*/
114+
let bottomAppBarHeight: number;
115+
/**
116+
* The timeout that's used to debounce toggling the isCurrentlyBeingResized
117+
* variable after a resize
118+
*/
119+
let resizeDebounceId: NodeJS.Timeout | number = 0;
120+
121+
function getViewportScrollY() {
122+
const win = window;
123+
const el = window as unknown as Element;
124+
return win.pageYOffset !== undefined ? win.pageYOffset : el.scrollTop;
125+
}
126+
127+
function getTopAppBarHeight() {
128+
return element.getBoundingClientRect().height;
129+
}
130+
131+
let oldVariant: 'fixed' | 'static' | 'standard' | null = null;
132+
$: if (element && variant !== oldVariant) {
133+
if (variant === 'standard') {
134+
lastScrollPosition = getViewportScrollY();
135+
bottomAppBarHeight = getTopAppBarHeight();
136+
} else if (oldVariant === 'standard') {
137+
addStyle('bottom', '');
138+
addStyle('--smui-bottom-app-bar--fab-offset', '0px');
139+
}
140+
oldVariant = variant;
141+
}
142+
143+
/**
144+
* Scroll handler for the default scroll behavior of the bottom app bar.
145+
*/
146+
function handleTargetScroll() {
147+
if (variant !== 'standard') {
148+
return;
149+
}
150+
151+
const currentScrollPosition = Math.max(getViewportScrollY(), 0);
152+
const diff = currentScrollPosition - lastScrollPosition;
153+
lastScrollPosition = currentScrollPosition;
154+
155+
// If the window is being resized the lastScrollPosition needs to be updated
156+
// but the current scroll of the bottom app bar should stay in the same
157+
// position.
158+
if (!isCurrentlyBeingResized) {
159+
currentAppBarOffsetBottom += diff;
160+
161+
if (currentAppBarOffsetBottom > 0) {
162+
currentAppBarOffsetBottom = 0;
163+
} else if (Math.abs(currentAppBarOffsetBottom) > bottomAppBarHeight) {
164+
currentAppBarOffsetBottom = -bottomAppBarHeight;
165+
}
166+
167+
moveTopAppBar();
168+
}
169+
}
170+
171+
/**
172+
* Top app bar resize handler that throttle/debounce functions that execute updates.
173+
*/
174+
function handleWindowResize() {
175+
if (variant !== 'standard') {
176+
return;
177+
}
178+
179+
// Throttle resize events 10 p/s
180+
if (!resizeThrottleId) {
181+
resizeThrottleId = setTimeout(() => {
182+
resizeThrottleId = 0;
183+
throttledResizeHandler();
184+
}, DEBOUNCE_THROTTLE_RESIZE_TIME_MS);
185+
}
186+
187+
isCurrentlyBeingResized = true;
188+
189+
if (resizeDebounceId) {
190+
clearTimeout(resizeDebounceId);
191+
}
192+
193+
resizeDebounceId = setTimeout(() => {
194+
handleTargetScroll();
195+
isCurrentlyBeingResized = false;
196+
resizeDebounceId = 0;
197+
}, DEBOUNCE_THROTTLE_RESIZE_TIME_MS);
198+
}
199+
200+
/**
201+
* Function to determine if the DOM needs to update.
202+
*/
203+
function checkForUpdate(): boolean {
204+
const offscreenBoundaryBottom = -bottomAppBarHeight;
205+
const hasAnyPixelsOffscreen = currentAppBarOffsetBottom < 0;
206+
const hasAnyPixelsOnscreen =
207+
currentAppBarOffsetBottom > offscreenBoundaryBottom;
208+
const partiallyShowing = hasAnyPixelsOffscreen && hasAnyPixelsOnscreen;
209+
210+
// If it's partially showing, it can't be docked.
211+
if (partiallyShowing) {
212+
wasDocked = false;
213+
} else {
214+
// Not previously docked and not partially showing, it's now docked.
215+
if (!wasDocked) {
216+
wasDocked = true;
217+
return true;
218+
} else if (isDockedShowing !== hasAnyPixelsOnscreen) {
219+
isDockedShowing = hasAnyPixelsOnscreen;
220+
return true;
221+
}
222+
}
223+
224+
return partiallyShowing;
225+
}
226+
227+
/**
228+
* Function to move the bottom app bar if needed.
229+
*/
230+
function moveTopAppBar() {
231+
if (checkForUpdate()) {
232+
let offset = currentAppBarOffsetBottom;
233+
addStyle('--smui-bottom-app-bar--fab-offset', offset * 0.75 + 'px');
234+
addStyle('bottom', offset + 'px');
235+
}
236+
}
237+
238+
/**
239+
* Throttled function that updates the bottom app bar scrolled values if the
240+
* bottom app bar height changes.
241+
*/
242+
function throttledResizeHandler() {
243+
const currentHeight = getTopAppBarHeight();
244+
if (bottomAppBarHeight !== currentHeight) {
245+
wasDocked = false;
246+
247+
// Since the bottom app bar has a different height depending on the screen width, this
248+
// will ensure that the bottom app bar remains in the correct location if
249+
// completely hidden and a resize makes the bottom app bar a different height.
250+
currentAppBarOffsetBottom -= bottomAppBarHeight - currentHeight;
251+
bottomAppBarHeight = currentHeight;
252+
}
253+
handleTargetScroll();
254+
}
255+
63256
export function getPropStore() {
64257
return propStore;
65258
}

packages/site/src/routes/demo/bottom-app-bar/_Variants.svelte

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
<!-- Check out iframe/*.svelte too see how these work. -->
2+
<iframe
3+
class="bottom-app-bar-iframe"
4+
src="/demo/bottom-app-bar/iframe/standard"
5+
title="standard"
6+
/>
7+
<a style="display: none;" href="/demo/bottom-app-bar/iframe/standard"
8+
>helper needed for export</a
9+
>
10+
211
<iframe
312
class="bottom-app-bar-iframe"
413
src="/demo/bottom-app-bar/iframe/fixed"

packages/site/src/routes/demo/bottom-app-bar/iframe/inset-fab-right/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
/>
1010
</AutoAdjust>
1111

12-
<BottomAppBar bind:this={bottomAppBar} variant="fixed">
12+
<BottomAppBar bind:this={bottomAppBar}>
1313
<Section>
1414
<IconButton class="material-icons" aria-label="Archive">archive</IconButton>
1515
<IconButton class="material-icons" aria-label="Mark unread">mail</IconButton

packages/site/src/routes/demo/bottom-app-bar/iframe/inset-fab/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
/>
1010
</AutoAdjust>
1111

12-
<BottomAppBar bind:this={bottomAppBar} variant="fixed">
12+
<BottomAppBar bind:this={bottomAppBar}>
1313
<Section>
1414
<IconButton class="material-icons">menu</IconButton>
1515
</Section>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<AutoAdjust {bottomAppBar}>
2+
<h5>Standard</h5>
3+
4+
<LoremIpsum />
5+
<img
6+
alt="Page content placeholder"
7+
src="/page-content.jpg"
8+
style="display: block; max-width: 100%; height: auto; margin: 1em auto;"
9+
/>
10+
</AutoAdjust>
11+
12+
<BottomAppBar bind:this={bottomAppBar}>
13+
<Section>
14+
<IconButton class="material-icons">menu</IconButton>
15+
</Section>
16+
<Section>
17+
<IconButton class="material-icons" aria-label="Search">search</IconButton>
18+
<IconButton class="material-icons" aria-label="More">more_vert</IconButton>
19+
</Section>
20+
</BottomAppBar>
21+
22+
<script lang="ts">
23+
import BottomAppBar, {
24+
Section,
25+
AutoAdjust,
26+
} from '@smui-extra/bottom-app-bar';
27+
import IconButton from '@smui/icon-button';
28+
import LoremIpsum from '$lib/LoremIpsum.svelte';
29+
30+
let bottomAppBar: BottomAppBar;
31+
</script>
32+
33+
<style>
34+
/* Hide everything above this component. */
35+
:global(app),
36+
:global(body),
37+
:global(html) {
38+
display: block !important;
39+
height: auto !important;
40+
width: auto !important;
41+
position: static !important;
42+
}
43+
</style>

0 commit comments

Comments
 (0)