instantsearch-ui-components
Version:
Common UI components for InstantSearch.
481 lines (470 loc) • 19.6 kB
JavaScript
;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.createStickToBottom = createStickToBottom;
var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
/* !---------------------------------------------------------------------------------------------
* Copyright (c) StackBlitz. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
var DEFAULT_SPRING_ANIMATION = {
/**
* A value from 0 to 1, on how much to damp the animation.
* 0 means no damping, 1 means full damping.
*
* @default 0.7
*/
damping: 0.7,
/**
* The stiffness of how fast/slow the animation gets up to speed.
*
* @default 0.05
*/
stiffness: 0.05,
/**
* The inertial mass associated with the animation.
* Higher numbers make the animation slower.
*
* @default 1.25
*/
mass: 1.25
};
var STICK_TO_BOTTOM_OFFSET_PX = 70;
var SIXTY_FPS_INTERVAL_MS = 1000 / 60;
var RETAIN_ANIMATION_DURATION_MS = 350;
var mouseDown = false;
if (typeof window !== 'undefined') {
var _window$document, _window$document2, _window$document3;
(_window$document = window.document) === null || _window$document === void 0 ? void 0 : _window$document.addEventListener('mousedown', function () {
mouseDown = true;
});
(_window$document2 = window.document) === null || _window$document2 === void 0 ? void 0 : _window$document2.addEventListener('mouseup', function () {
mouseDown = false;
});
(_window$document3 = window.document) === null || _window$document3 === void 0 ? void 0 : _window$document3.addEventListener('click', function () {
mouseDown = false;
});
}
function createStickToBottom(_ref) {
var useCallback = _ref.useCallback,
useEffect = _ref.useEffect,
useMemo = _ref.useMemo,
useRef = _ref.useRef,
useState = _ref.useState;
return function useStickToBottom() {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var _useState = useState(false),
_useState2 = (0, _slicedToArray2.default)(_useState, 2),
escapedFromLock = _useState2[0],
updateEscapedFromLock = _useState2[1];
var _useState3 = useState(options.initial !== false),
_useState4 = (0, _slicedToArray2.default)(_useState3, 2),
isAtBottom = _useState4[0],
updateIsAtBottom = _useState4[1];
var _useState5 = useState(false),
_useState6 = (0, _slicedToArray2.default)(_useState5, 2),
isNearBottom = _useState6[0],
setIsNearBottom = _useState6[1];
var optionsRef = useRef(null);
optionsRef.current = options;
// Create refs early so they can be used in other hooks
var scrollRef = useRef(null);
var contentRef = useRef(null);
var isSelecting = useCallback(function () {
var _scrollRef$current;
if (!mouseDown) {
return false;
}
if (typeof window === 'undefined') {
return false;
}
var selection = window.getSelection();
if (!selection || !selection.rangeCount) {
return false;
}
var range = selection.getRangeAt(0);
return range.commonAncestorContainer.contains(scrollRef.current) || ((_scrollRef$current = scrollRef.current) === null || _scrollRef$current === void 0 ? void 0 : _scrollRef$current.contains(range.commonAncestorContainer));
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: state is intentionally stable
var state = useMemo(function () {
var lastCalculation;
return {
escapedFromLock: escapedFromLock,
isAtBottom: isAtBottom,
resizeDifference: 0,
accumulated: 0,
velocity: 0,
get scrollTop() {
var _scrollRef$current$sc, _scrollRef$current2;
return (_scrollRef$current$sc = (_scrollRef$current2 = scrollRef.current) === null || _scrollRef$current2 === void 0 ? void 0 : _scrollRef$current2.scrollTop) !== null && _scrollRef$current$sc !== void 0 ? _scrollRef$current$sc : 0;
},
set scrollTop(scrollTop) {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollTop;
state.ignoreScrollToTop = scrollRef.current.scrollTop;
}
},
get targetScrollTop() {
if (!scrollRef.current || !contentRef.current) {
return 0;
}
return scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight;
},
get calculatedTargetScrollTop() {
var _lastCalculation;
if (!scrollRef.current || !contentRef.current) {
return 0;
}
var targetScrollTop = this.targetScrollTop;
if (!optionsRef.current.targetScrollTop) {
return targetScrollTop;
}
if (((_lastCalculation = lastCalculation) === null || _lastCalculation === void 0 ? void 0 : _lastCalculation.targetScrollTop) === targetScrollTop) {
return lastCalculation.calculatedScrollTop;
}
var calculatedScrollTop = Math.max(Math.min(optionsRef.current.targetScrollTop(targetScrollTop, {
scrollElement: scrollRef.current,
contentElement: contentRef.current
}), targetScrollTop), 0);
lastCalculation = {
targetScrollTop: targetScrollTop,
calculatedScrollTop: calculatedScrollTop
};
requestAnimationFrame(function () {
lastCalculation = undefined;
});
return calculatedScrollTop;
},
get scrollDifference() {
return this.calculatedTargetScrollTop - this.scrollTop;
},
get isNearBottom() {
return this.scrollDifference <= STICK_TO_BOTTOM_OFFSET_PX;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
var setIsAtBottom = useCallback(function (value) {
state.isAtBottom = value;
updateIsAtBottom(value);
}, [state]);
var setEscapedFromLock = useCallback(function (value) {
state.escapedFromLock = value;
updateEscapedFromLock(value);
}, [state]);
var scrollToBottom = useCallback(function () {
var _state$animation2;
var scrollOptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
if (typeof scrollOptions === 'string') {
scrollOptions = {
animation: scrollOptions
};
}
if (!scrollOptions.preserveScrollPosition) {
setIsAtBottom(true);
}
var waitElapsed = Date.now() + (Number(scrollOptions.wait) || 0);
var behavior = mergeAnimations(optionsRef.current, scrollOptions.animation);
var _scrollOptions = scrollOptions,
_scrollOptions$ignore = _scrollOptions.ignoreEscapes,
ignoreEscapes = _scrollOptions$ignore === void 0 ? false : _scrollOptions$ignore;
var durationElapsed;
var startTarget = state.calculatedTargetScrollTop;
if (scrollOptions.duration instanceof Promise) {
scrollOptions.duration.then(function () {
durationElapsed = Date.now();
}, function () {
durationElapsed = Date.now();
});
} else {
var _scrollOptions$durati;
durationElapsed = waitElapsed + ((_scrollOptions$durati = scrollOptions.duration) !== null && _scrollOptions$durati !== void 0 ? _scrollOptions$durati : 0);
}
var next = function next() {
var promise = new Promise(requestAnimationFrame).then(function () {
var _state$lastTick;
if (!state.isAtBottom) {
state.animation = undefined;
return false;
}
var scrollTop = state.scrollTop;
var tick = performance.now();
var tickDelta = (tick - ((_state$lastTick = state.lastTick) !== null && _state$lastTick !== void 0 ? _state$lastTick : tick)) / SIXTY_FPS_INTERVAL_MS;
state.animation || (state.animation = {
behavior: behavior,
promise: promise,
ignoreEscapes: ignoreEscapes
});
if (state.animation.behavior === behavior) {
state.lastTick = tick;
}
if (isSelecting()) {
return next();
}
if (waitElapsed > Date.now()) {
return next();
}
if (scrollTop < Math.min(startTarget, state.calculatedTargetScrollTop)) {
var _state$animation;
if (((_state$animation = state.animation) === null || _state$animation === void 0 ? void 0 : _state$animation.behavior) === behavior) {
if (behavior === 'instant') {
state.scrollTop = state.calculatedTargetScrollTop;
return next();
}
state.velocity = (behavior.damping * state.velocity + behavior.stiffness * state.scrollDifference) / behavior.mass;
state.accumulated += state.velocity * tickDelta;
state.scrollTop += state.accumulated;
if (state.scrollTop !== scrollTop) {
state.accumulated = 0;
}
}
return next();
}
if (durationElapsed > Date.now()) {
startTarget = state.calculatedTargetScrollTop;
return next();
}
state.animation = undefined;
/**
* If we're still below the target, then queue
* up another scroll to the bottom with the last
* requested animatino.
*/
if (state.scrollTop < state.calculatedTargetScrollTop) {
return scrollToBottom({
animation: mergeAnimations(optionsRef.current, optionsRef.current.resize),
ignoreEscapes: ignoreEscapes,
duration: Math.max(0, durationElapsed - Date.now()) || undefined
});
}
return state.isAtBottom;
});
return promise.then(function (result) {
requestAnimationFrame(function () {
if (!state.animation) {
state.lastTick = undefined;
state.velocity = 0;
}
});
return result;
});
};
if (scrollOptions.wait !== true) {
state.animation = undefined;
}
if (((_state$animation2 = state.animation) === null || _state$animation2 === void 0 ? void 0 : _state$animation2.behavior) === behavior) {
return state.animation.promise;
}
return next();
}, [setIsAtBottom, isSelecting, state]);
var stopScroll = useCallback(function () {
setEscapedFromLock(true);
setIsAtBottom(false);
}, [setEscapedFromLock, setIsAtBottom]);
var handleScroll = useCallback(function (_ref2) {
var target = _ref2.target;
if (target !== scrollRef.current) {
return;
}
var scrollTop = state.scrollTop,
ignoreScrollToTop = state.ignoreScrollToTop;
var _state$lastScrollTop = state.lastScrollTop,
lastScrollTop = _state$lastScrollTop === void 0 ? scrollTop : _state$lastScrollTop;
state.lastScrollTop = scrollTop;
state.ignoreScrollToTop = undefined;
if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) {
/**
* When the user scrolls up while the animation plays, the `scrollTop` may
* not come in separate events; if this happens, to make sure `isScrollingUp`
* is correct, set the lastScrollTop to the ignored event.
*/
lastScrollTop = ignoreScrollToTop;
}
setIsNearBottom(state.isNearBottom);
/**
* Scroll events may come before a ResizeObserver event,
* so in order to ignore resize events correctly we use a
* timeout.
*
* @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228
*/
setTimeout(function () {
var _state$animation3;
/**
* When theres a resize difference ignore the resize event.
*/
if (state.resizeDifference || scrollTop === ignoreScrollToTop) {
return;
}
if (isSelecting()) {
setEscapedFromLock(true);
setIsAtBottom(false);
return;
}
var isScrollingDown = scrollTop > lastScrollTop;
var isScrollingUp = scrollTop < lastScrollTop;
if ((_state$animation3 = state.animation) !== null && _state$animation3 !== void 0 && _state$animation3.ignoreEscapes) {
state.scrollTop = lastScrollTop;
return;
}
if (isScrollingUp) {
setEscapedFromLock(true);
setIsAtBottom(false);
}
if (isScrollingDown) {
setEscapedFromLock(false);
}
if (!state.escapedFromLock && state.isNearBottom) {
setIsAtBottom(true);
}
}, 1);
}, [setEscapedFromLock, setIsAtBottom, isSelecting, state]);
var handleWheel = useCallback(function (_ref3) {
var _state$animation4;
var target = _ref3.target,
deltaY = _ref3.deltaY;
var element = target;
while (!['scroll', 'auto'].includes(getComputedStyle(element).overflow)) {
if (!element.parentElement) {
return;
}
element = element.parentElement;
}
/**
* The browser may cancel the scrolling from the mouse wheel
* if we update it from the animation in meantime.
* To prevent this, always escape when the wheel is scrolled up.
*/
if (element === scrollRef.current && deltaY < 0 && scrollRef.current.scrollHeight > scrollRef.current.clientHeight && !((_state$animation4 = state.animation) !== null && _state$animation4 !== void 0 && _state$animation4.ignoreEscapes)) {
setEscapedFromLock(true);
setIsAtBottom(false);
}
}, [setEscapedFromLock, setIsAtBottom, state]);
// Attach scroll and wheel event listeners
useEffect(function () {
var scroll = scrollRef.current;
if (!scroll) {
return undefined;
}
scroll.addEventListener('scroll', handleScroll, {
passive: true
});
scroll.addEventListener('wheel', handleWheel, {
passive: true
});
return function () {
scroll.removeEventListener('scroll', handleScroll);
scroll.removeEventListener('wheel', handleWheel);
};
}, [handleScroll, handleWheel]);
// Attach ResizeObserver to content element
useEffect(function () {
var content = contentRef.current;
if (!content) {
return undefined;
}
var previousHeight;
var resizeObserver = new ResizeObserver(function (_ref4) {
var _previousHeight;
var _ref5 = (0, _slicedToArray2.default)(_ref4, 1),
entry = _ref5[0];
var height = entry.contentRect.height;
var difference = height - ((_previousHeight = previousHeight) !== null && _previousHeight !== void 0 ? _previousHeight : height);
state.resizeDifference = difference;
/**
* Sometimes the browser can overscroll past the target,
* so check for this and adjust appropriately.
*/
if (state.scrollTop > state.targetScrollTop) {
state.scrollTop = state.targetScrollTop;
}
setIsNearBottom(state.isNearBottom);
if (difference >= 0) {
/**
* If it's a positive resize, scroll to the bottom when
* we're already at the bottom.
*/
var animation = mergeAnimations(optionsRef.current, previousHeight ? optionsRef.current.resize : optionsRef.current.initial);
scrollToBottom({
animation: animation,
wait: true,
preserveScrollPosition: true,
duration: animation === 'instant' ? undefined : RETAIN_ANIMATION_DURATION_MS
});
} else if (state.isNearBottom) {
/**
* Else if it's a negative resize, check if we're near the bottom
* if we are want to un-escape from the lock, because the resize
* could have caused the container to be at the bottom.
*/
setEscapedFromLock(false);
setIsAtBottom(true);
}
previousHeight = height;
/**
* Reset the resize difference after the scroll event
* has fired. Requires a rAF to wait for the scroll event,
* and a setTimeout to wait for the other timeout we have in
* resizeObserver in case the scroll event happens after the
* resize event.
*/
requestAnimationFrame(function () {
setTimeout(function () {
if (state.resizeDifference === difference) {
state.resizeDifference = 0;
}
}, 1);
});
});
resizeObserver.observe(content);
state.resizeObserver = resizeObserver;
return function () {
resizeObserver.disconnect();
state.resizeObserver = undefined;
};
}, [state, setIsNearBottom, setEscapedFromLock, setIsAtBottom, scrollToBottom]);
return {
contentRef: contentRef,
scrollRef: scrollRef,
scrollToBottom: scrollToBottom,
stopScroll: stopScroll,
isAtBottom: isAtBottom || isNearBottom,
isNearBottom: isNearBottom,
escapedFromLock: escapedFromLock,
state: state
};
};
}
var animationCache = new Map();
function mergeAnimations() {
var result = _objectSpread({}, DEFAULT_SPRING_ANIMATION);
var instant = false;
for (var _len = arguments.length, animations = new Array(_len), _key = 0; _key < _len; _key++) {
animations[_key] = arguments[_key];
}
animations.forEach(function (animation) {
var _animation$damping, _animation$stiffness, _animation$mass;
if (animation === 'instant') {
instant = true;
return;
}
if ((0, _typeof2.default)(animation) !== 'object') {
return;
}
instant = false;
result.damping = (_animation$damping = animation.damping) !== null && _animation$damping !== void 0 ? _animation$damping : result.damping;
result.stiffness = (_animation$stiffness = animation.stiffness) !== null && _animation$stiffness !== void 0 ? _animation$stiffness : result.stiffness;
result.mass = (_animation$mass = animation.mass) !== null && _animation$mass !== void 0 ? _animation$mass : result.mass;
});
var key = JSON.stringify(result);
if (!animationCache.has(key)) {
animationCache.set(key, Object.freeze(result));
}
return instant ? 'instant' : animationCache.get(key);
}