|
| 1 | +<svelte:window on:scroll={handleTargetScroll} on:resize={handleWindowResize} /> |
| 2 | + |
1 | 3 | <div
|
2 | 4 | bind:this={element}
|
3 | 5 | use:useActions={use}
|
4 | 6 | use:forwardEvents
|
5 | 7 | class={classMap({
|
6 | 8 | [className]: true,
|
7 | 9 | 'smui-bottom-app-bar': true,
|
| 10 | + 'smui-bottom-app-bar--standard': variant === 'standard', |
8 | 11 | 'smui-bottom-app-bar--fixed': variant === 'fixed',
|
9 | 12 | })}
|
| 13 | + style={Object.entries(internalStyles) |
| 14 | + .map(([name, value]) => `${name}: ${value};`) |
| 15 | + .concat([style]) |
| 16 | + .join(' ')} |
10 | 17 | {...$$restProps}
|
11 | 18 | >
|
12 | 19 | <slot />
|
|
28 | 35 | type OwnProps = {
|
29 | 36 | use?: ActionArray;
|
30 | 37 | class?: string;
|
| 38 | + style?: string; |
31 | 39 | color?: 'default' | 'primary' | 'secondary' | string;
|
32 |
| - variant?: 'fixed' | 'static'; |
| 40 | + variant?: 'fixed' | 'static' | 'standard'; |
33 | 41 | };
|
34 | 42 | type $$Props = OwnProps & SmuiAttrs<'div', OwnProps>;
|
35 | 43 |
|
|
39 | 47 | export let use: ActionArray = [];
|
40 | 48 | let className = '';
|
41 | 49 | export { className as class };
|
| 50 | + export let style = ''; |
42 | 51 | export let color: 'default' | 'primary' | 'secondary' | string = 'primary';
|
43 |
| - export let variant: 'fixed' | 'static' = 'fixed'; |
| 52 | + export let variant: 'fixed' | 'static' | 'standard' = 'standard'; |
44 | 53 |
|
45 | 54 | let element: HTMLDivElement;
|
46 | 55 |
|
| 56 | + let internalStyles: { [k: string]: string } = {}; |
47 | 57 | const colorStore = writable(color);
|
48 | 58 | $: $colorStore = color;
|
49 | 59 | setContext('SMUI:bottom-app-bar:color', colorStore);
|
50 |
| -
|
51 | 60 | let propStoreSet: Subscriber<{
|
52 |
| - variant: 'fixed' | 'static'; |
| 61 | + variant: 'fixed' | 'static' | 'standard'; |
53 | 62 | }>;
|
54 | 63 | let propStore = readable({ variant }, (set) => {
|
55 | 64 | propStoreSet = set;
|
|
60 | 69 | });
|
61 | 70 | }
|
62 | 71 |
|
| 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 | +
|
63 | 256 | export function getPropStore() {
|
64 | 257 | return propStore;
|
65 | 258 | }
|
|
0 commit comments