braid-design-system
Version:
Themeable design system for the SEEK Group
194 lines (167 loc) • 5.37 kB
text/typescript
type Direction = 'horizontal' | 'vertical';
const easeModifier = (t: number) =>
t > 0.5 ? 4 * Math.pow(t - 1, 3) + 1 : 4 * Math.pow(t, 3);
const getExpectedTime = (
distance: number,
duration: number | null,
speed: number,
minDuration: number,
) => {
const calculatedDuration = distance / speed;
const normalizedDuration =
minDuration > calculatedDuration ? minDuration : calculatedDuration;
return duration !== null ? duration : normalizedDuration;
};
const getScrollPosition = (
scrollContainer: HTMLElement,
direction: Direction,
) => scrollContainer[direction === 'horizontal' ? 'scrollLeft' : 'scrollTop'];
const getScrollOffset = (
scrollContainer: HTMLElement,
targetElement: HTMLElement,
direction: Direction,
) => {
if (scrollContainer === window.document.documentElement) {
const scrollPosition = getScrollPosition(scrollContainer, direction);
const positionOnScreen =
targetElement.getBoundingClientRect()[
direction === 'horizontal' ? 'left' : 'top'
];
return positionOnScreen + scrollPosition;
}
let totalOffset = 0;
let currentElement = targetElement;
const offsetKey = direction === 'horizontal' ? 'offsetLeft' : 'offsetTop';
while (
scrollContainer.contains(currentElement) ||
currentElement === scrollContainer
) {
totalOffset += currentElement[offsetKey];
const { offsetParent } = currentElement;
if (!(offsetParent instanceof HTMLElement)) {
break;
}
currentElement = offsetParent;
}
return totalOffset;
};
const scrollTo = (
scrollContainer: HTMLElement,
direction: Direction,
to: number,
) => {
scrollContainer[direction === 'horizontal' ? 'scrollLeft' : 'scrollTop'] = to;
};
const getPossibleScroll = (
scrollContainer: HTMLElement,
direction: Direction,
) =>
scrollContainer[direction === 'horizontal' ? 'scrollWidth' : 'scrollHeight'] -
(scrollContainer === window.document.documentElement
? 0
: scrollContainer[
direction === 'horizontal' ? 'offsetWidth' : 'offsetHeight'
]);
const limitNumberToRange = (number: number, min: number, max: number) => {
if (number < min) {
return min;
}
if (number > max) {
return max;
}
return number;
};
interface ScrollOptions {
duration?: number | null;
speed?: number;
minDuration?: number;
}
const scroll = (
scrollContainer: HTMLElement,
direction: Direction,
to: number,
{ duration = null, speed = 2, minDuration = 0 }: ScrollOptions,
callback?: () => void,
) => {
const startTime = Date.now();
const initial = getScrollPosition(scrollContainer, direction);
const possibleScroll = getPossibleScroll(scrollContainer, direction);
const targetScrollPosition = limitNumberToRange(to, 0, possibleScroll);
const total = Math.abs(targetScrollPosition - initial);
const expectedTime = getExpectedTime(total, duration, speed, minDuration);
const scrollForwards = targetScrollPosition > initial;
const step = () => {
requestAnimationFrame(() => {
const timePassed = Date.now() - startTime;
const progress = timePassed / expectedTime;
const distance = easeModifier(progress) * total;
const newPosition = Math.floor(
scrollForwards ? initial + distance : initial - distance,
);
const isComplete = scrollForwards
? newPosition >= targetScrollPosition
: newPosition <= targetScrollPosition;
if (isComplete) {
scrollTo(scrollContainer, direction, targetScrollPosition);
if (callback) {
callback();
}
} else {
scrollTo(scrollContainer, direction, newPosition);
step();
}
});
};
if (targetScrollPosition !== initial) {
step();
} else if (callback) {
callback();
}
};
interface SmoothScrollOptions extends ScrollOptions {
scrollContainer?: HTMLElement;
direction?: Direction;
offset?: number;
position?: 'start' | 'end';
}
export const smoothScroll = (
element: HTMLElement,
{
scrollContainer = window.document.documentElement,
direction = 'vertical',
offset = 0,
position = 'start',
...scrollOptions
}: SmoothScrollOptions = {},
) =>
new Promise<void>((resolve) => {
const scrollOffset = getScrollOffset(scrollContainer, element, direction);
const scrollPosition =
position === 'end'
? scrollOffset -
scrollContainer.offsetWidth +
element.offsetWidth +
offset
: scrollOffset - offset;
scroll(scrollContainer, direction, scrollPosition, scrollOptions, resolve);
});
type SmoothScrollIntoViewOptions = Omit<SmoothScrollOptions, 'position'>;
export const smoothScrollIntoView = (
element: HTMLElement,
options: SmoothScrollIntoViewOptions,
) => {
const {
scrollContainer = window.document.documentElement,
direction = 'vertical',
offset = 0,
} = options;
const containerWidth = scrollContainer.offsetWidth;
const scrollOffset = getScrollOffset(scrollContainer, element, direction);
const positionOnScreen =
scrollOffset - getScrollPosition(scrollContainer, direction);
if (positionOnScreen < offset) {
smoothScroll(element, { ...options, position: 'start' });
} else if (positionOnScreen > containerWidth - element.offsetWidth - offset) {
smoothScroll(element, { ...options, position: 'end' });
}
};