UNPKG

@helpwave/hightide

Version:

helpwave's component and theming library

268 lines (264 loc) 8.8 kB
// src/components/user-action/ScrollPicker.tsx import { useCallback, useEffect, useState } from "react"; import clsx from "clsx"; // src/util/noop.ts var noop = () => void 0; // src/util/array.ts var defaultRangeOptions = { allowEmptyRange: false, stepSize: 1, exclusiveStart: false, exclusiveEnd: true }; var range = (endOrRange, options) => { const { allowEmptyRange, stepSize, exclusiveStart, exclusiveEnd } = { ...defaultRangeOptions, ...options }; let start = 0; let end; if (typeof endOrRange === "number") { end = endOrRange; } else { start = endOrRange[0]; end = endOrRange[1]; } if (!exclusiveEnd) { end -= 1; } if (exclusiveStart) { start += 1; } if (end - 1 < start) { if (!allowEmptyRange) { console.warn(`range: end (${end}) < start (${start}) should be allowed explicitly, set options.allowEmptyRange to true`); } return []; } return Array.from({ length: end - start }, (_, index) => index * stepSize + start); }; var getNeighbours = (list, item, neighbourDistance = 2) => { const index = list.indexOf(item); const totalItems = neighbourDistance * 2 + 1; if (list.length < totalItems) { console.warn("List is to short"); return list; } if (index === -1) { console.error("item not found in list"); return list.splice(0, totalItems); } let start = index - neighbourDistance; if (start < 0) { start += list.length; } const end = (index + neighbourDistance + 1) % list.length; const result = []; let ignoreOnce = list.length === totalItems; for (let i = start; i !== end || ignoreOnce; i = (i + 1) % list.length) { result.push(list[i]); if (end === i && ignoreOnce) { ignoreOnce = false; } } return result; }; // src/util/math.ts var clamp = (value, min = 0, max = 1) => { return Math.min(Math.max(value, min), max); }; // src/components/user-action/ScrollPicker.tsx import { jsx, jsxs } from "react/jsx-runtime"; var up = 1; var down = -1; var ScrollPicker = ({ options, mapping, selected, onChange = noop, disabled = false }) => { let selectedIndex = 0; if (selected && options.indexOf(selected) !== -1) { selectedIndex = options.indexOf(selected); } const [{ currentIndex, transition, items, lastTimeStamp }, setAnimation] = useState({ targetIndex: selectedIndex, currentIndex: disabled ? selectedIndex : 0, velocity: 0, animationVelocity: Math.floor(options.length / 2), transition: 0, items: options }); const itemsShownCount = 5; const shownItems = getNeighbours(range(items.length), currentIndex).map((index) => ({ name: mapping(items[index]), index })); const itemHeight = 40; const distance = 8; const containerHeight = itemHeight * (itemsShownCount - 2) + distance * (itemsShownCount - 2 + 1); const getDirection = useCallback((targetIndex, currentIndex2, transition2, length) => { if (targetIndex === currentIndex2) { return transition2 > 0 ? up : down; } let distanceForward = targetIndex - currentIndex2; if (distanceForward < 0) { distanceForward += length; } return distanceForward >= length / 2 ? down : up; }, []); const animate = useCallback((timestamp, startTime) => { setAnimation((prevState) => { const { targetIndex, currentIndex: currentIndex2, transition: transition2, animationVelocity, velocity, items: items2, lastScrollTimeStamp } = prevState; if (disabled) { return { ...prevState, currentIndex: targetIndex, velocity: 0, lastTimeStamp: timestamp }; } if (targetIndex === currentIndex2 && velocity === 0 && transition2 === 0 || !startTime) { return { ...prevState, lastTimeStamp: timestamp }; } const progress = (timestamp - startTime) / 1e3; const direction = getDirection(targetIndex, currentIndex2, transition2, items2.length); let newVelocity = velocity; let usedVelocity; let newCurrentIndex = currentIndex2; const isAutoScrolling = velocity === 0 && (!lastScrollTimeStamp || timestamp - lastScrollTimeStamp > 300); const newLastScrollTimeStamp = velocity !== 0 ? timestamp : lastScrollTimeStamp; if (isAutoScrolling) { usedVelocity = direction * animationVelocity; } else { usedVelocity = velocity; newVelocity = velocity * 0.5; if (Math.abs(newVelocity) <= 0.05) { newVelocity = 0; } } let newTransition = transition2 + usedVelocity * progress; const changeThreshold = 0.5; while (newTransition >= changeThreshold) { if (newCurrentIndex === targetIndex && newTransition >= changeThreshold && isAutoScrolling) { newTransition = 0; break; } newCurrentIndex = (currentIndex2 + 1) % items2.length; newTransition -= 1; } if (newTransition >= changeThreshold) { newTransition = 0; } while (newTransition <= -changeThreshold) { if (newCurrentIndex === targetIndex && newTransition <= -changeThreshold && isAutoScrolling) { newTransition = 0; break; } newCurrentIndex = currentIndex2 === 0 ? items2.length - 1 : currentIndex2 - 1; newTransition += 1; } let newTargetIndex = targetIndex; if (!isAutoScrolling) { newTargetIndex = newCurrentIndex; } if ((currentIndex2 !== newTargetIndex || newTargetIndex !== targetIndex) && newTargetIndex === newCurrentIndex) { onChange(items2[newCurrentIndex]); } return { targetIndex: newTargetIndex, currentIndex: newCurrentIndex, animationVelocity, transition: newTransition, velocity: newVelocity, items: items2, lastTimeStamp: timestamp, lastScrollTimeStamp: newLastScrollTimeStamp }; }); }, [disabled, getDirection, onChange]); useEffect(() => { requestAnimationFrame((timestamp) => animate(timestamp, lastTimeStamp)); }); const opacity = (transition2, index, itemsCount) => { const max = 100; const min = 0; const distance2 = max - min; let opacityValue = min; const unitTransition = clamp(transition2 / 0.5); if (index === 1 || index === itemsCount - 2) { if (index === 1 && transition2 > 0) { opacityValue += Math.floor(unitTransition * distance2); } if (index === itemsCount - 2 && transition2 < 0) { opacityValue += Math.floor(unitTransition * distance2); } } else { opacityValue = max; } return clamp(1 - opacityValue / max); }; return /* @__PURE__ */ jsx( "div", { className: "relative overflow-hidden", style: { height: containerHeight }, onWheel: (event) => { if (event.deltaY !== 0) { setAnimation(({ velocity, ...animationData }) => ({ ...animationData, velocity: velocity + event.deltaY })); } }, children: /* @__PURE__ */ jsxs("div", { className: "absolute top-1/2 -translate-y-1/2 -translate-x-1/2 left-1/2", children: [ /* @__PURE__ */ jsx( "div", { className: "absolute z-[1] top-1/2 -translate-y-1/2 -translate-x-1/2 left-1/2 w-full min-w-[40px] border border-divider/50 border-y-2 border-x-0 ", style: { height: `${itemHeight}px` } } ), /* @__PURE__ */ jsx( "div", { className: "flex-col-2 select-none", style: { transform: `translateY(${-transition * (distance + itemHeight)}px)`, columnGap: `${distance}px` }, children: shownItems.map(({ name, index }, arrayIndex) => /* @__PURE__ */ jsx( "div", { className: clsx( `flex-col-2 items-center justify-center rounded-md`, { "text-primary font-bold": currentIndex === index, "text-on-background": currentIndex === index, "cursor-pointer": !disabled, "cursor-not-allowed": disabled } ), style: { opacity: currentIndex !== index ? opacity(transition, arrayIndex, shownItems.length) : void 0, height: `${itemHeight}px`, maxHeight: `${itemHeight}px` }, onClick: () => !disabled && setAnimation((prevState) => ({ ...prevState, targetIndex: index })), children: name }, index )) } ) ] }) } ); }; export { ScrollPicker }; //# sourceMappingURL=ScrollPicker.mjs.map