|
| 1 | +import type { ReadableBoxedValues, WritableBoxedValues } from "svelte-toolbelt"; |
| 2 | +import { untrack } from "svelte"; |
| 3 | +import type { DrawerDirection } from "./types.js"; |
| 4 | +import { isVertical } from "./internal/helpers/is.js"; |
| 5 | +import { setStyles } from "./internal/helpers/index.js"; |
| 6 | +import { TRANSITIONS, VELOCITY_THRESHOLD } from "./internal/constants.js"; |
| 7 | + |
| 8 | +type SnapPointsProps = WritableBoxedValues<{ |
| 9 | + activeSnapPoint: number | string | null; |
| 10 | +}> & |
| 11 | + ReadableBoxedValues<{ |
| 12 | + snapPoints: (number | string)[]; |
| 13 | + fadeFromIndex: number; |
| 14 | + drawerRef: HTMLElement | null; |
| 15 | + overlayRef: HTMLElement | null; |
| 16 | + direction: DrawerDirection; |
| 17 | + }> & { |
| 18 | + onSnapPointChange: (activeSnapPointIdx: number) => void; |
| 19 | + setActiveSnapPoint: (newValue: number | string | null) => void; |
| 20 | + }; |
| 21 | + |
| 22 | +export class SnapPoints { |
| 23 | + #activeSnapPoint: SnapPointsProps["activeSnapPoint"]; |
| 24 | + #snapPoints: SnapPointsProps["snapPoints"]; |
| 25 | + #fadeFromIndex: SnapPointsProps["fadeFromIndex"]; |
| 26 | + #drawerRef: SnapPointsProps["drawerRef"]; |
| 27 | + #overlayRef: SnapPointsProps["overlayRef"]; |
| 28 | + #direction: SnapPointsProps["direction"]; |
| 29 | + #onSnapPointChange: SnapPointsProps["onSnapPointChange"]; |
| 30 | + #setActiveSnapPoint: SnapPointsProps["setActiveSnapPoint"]; |
| 31 | + |
| 32 | + constructor(props: SnapPointsProps) { |
| 33 | + this.#activeSnapPoint = props.activeSnapPoint; |
| 34 | + this.#snapPoints = props.snapPoints; |
| 35 | + this.#fadeFromIndex = props.fadeFromIndex; |
| 36 | + this.#drawerRef = props.drawerRef; |
| 37 | + this.#overlayRef = props.overlayRef; |
| 38 | + this.#direction = props.direction; |
| 39 | + this.#onSnapPointChange = props.onSnapPointChange; |
| 40 | + this.#setActiveSnapPoint = props.setActiveSnapPoint; |
| 41 | + |
| 42 | + $effect(() => { |
| 43 | + const activeSnapPoint = this.#activeSnapPoint.current; |
| 44 | + const snapPoints = this.#snapPoints.current; |
| 45 | + const snapPointsOffset = this.snapPointsOffset; |
| 46 | + untrack(() => { |
| 47 | + const newIndex = |
| 48 | + snapPoints?.findIndex((snapPoint) => snapPoint === activeSnapPoint) ?? -1; |
| 49 | + if ( |
| 50 | + snapPointsOffset && |
| 51 | + newIndex !== -1 && |
| 52 | + typeof snapPointsOffset[newIndex] === "number" |
| 53 | + ) { |
| 54 | + this.snapToPoint(snapPointsOffset[newIndex]); |
| 55 | + } |
| 56 | + }); |
| 57 | + }); |
| 58 | + } |
| 59 | + |
| 60 | + isLastSnapPoint = $derived.by(() => { |
| 61 | + const activeSnapPoint = this.#activeSnapPoint.current; |
| 62 | + const snapPoints = this.#snapPoints.current; |
| 63 | + return activeSnapPoint === snapPoints?.[snapPoints.length - 1] || null; |
| 64 | + }); |
| 65 | + |
| 66 | + shouldFade = $derived.by(() => { |
| 67 | + const snapPoints = this.#snapPoints.current; |
| 68 | + const fadeFromIndex = this.#fadeFromIndex.current; |
| 69 | + const activeSnapPoint = this.#activeSnapPoint.current; |
| 70 | + return ( |
| 71 | + (snapPoints && |
| 72 | + snapPoints.length > 0 && |
| 73 | + (fadeFromIndex || fadeFromIndex === 0) && |
| 74 | + !Number.isNaN(fadeFromIndex) && |
| 75 | + snapPoints[fadeFromIndex] === activeSnapPoint) || |
| 76 | + !snapPoints |
| 77 | + ); |
| 78 | + }); |
| 79 | + |
| 80 | + activeSnapPointIndex = $derived.by(() => { |
| 81 | + const snapPoints = this.#snapPoints.current; |
| 82 | + const activeSnapPoint = this.#activeSnapPoint.current; |
| 83 | + return snapPoints?.findIndex((snapPoint) => snapPoint === activeSnapPoint) ?? null; |
| 84 | + }); |
| 85 | + |
| 86 | + snapPointsOffset = $derived.by(() => { |
| 87 | + const snapPoints = this.#snapPoints.current; |
| 88 | + if (!snapPoints) return []; |
| 89 | + const direction = this.#direction.current; |
| 90 | + return snapPoints.map((snapPoint) => { |
| 91 | + const hasWindow = typeof window !== "undefined"; |
| 92 | + const isPx = typeof snapPoint === "string"; |
| 93 | + let snapPointAsNumber = 0; |
| 94 | + |
| 95 | + if (isPx) { |
| 96 | + snapPointAsNumber = Number.parseInt(snapPoint, 10); |
| 97 | + } |
| 98 | + |
| 99 | + if (isVertical(direction)) { |
| 100 | + const height = isPx |
| 101 | + ? snapPointAsNumber |
| 102 | + : hasWindow |
| 103 | + ? snapPoint * window.innerHeight |
| 104 | + : 0; |
| 105 | + |
| 106 | + if (hasWindow) { |
| 107 | + return direction === "bottom" |
| 108 | + ? window.innerHeight - height |
| 109 | + : -window.innerHeight + height; |
| 110 | + } |
| 111 | + return height; |
| 112 | + } |
| 113 | + const width = isPx ? snapPointAsNumber : hasWindow ? snapPoint * window.innerWidth : 0; |
| 114 | + |
| 115 | + if (hasWindow) { |
| 116 | + return direction === "right" |
| 117 | + ? window.innerWidth - width |
| 118 | + : -window.innerWidth + width; |
| 119 | + } |
| 120 | + |
| 121 | + return width; |
| 122 | + }); |
| 123 | + }); |
| 124 | + |
| 125 | + activeSnapPointOffset = $derived.by(() => { |
| 126 | + const activeSnapPointIndex = this.activeSnapPointIndex; |
| 127 | + const snapPointsOffset = this.snapPointsOffset; |
| 128 | + return activeSnapPointIndex !== null ? snapPointsOffset?.[activeSnapPointIndex] : null; |
| 129 | + }); |
| 130 | + |
| 131 | + snapToPoint = (dimension: number) => { |
| 132 | + const snapPointsOffset = this.snapPointsOffset; |
| 133 | + const onSnapPointChange = this.#onSnapPointChange; |
| 134 | + const newSnapPointIndex = |
| 135 | + snapPointsOffset?.findIndex((snapPointDim) => snapPointDim === dimension) ?? null; |
| 136 | + onSnapPointChange(newSnapPointIndex); |
| 137 | + const drawerNode = this.#drawerRef.current; |
| 138 | + const direction = this.#direction.current; |
| 139 | + setStyles(drawerNode, { |
| 140 | + transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(",")})`, |
| 141 | + transform: isVertical(direction) |
| 142 | + ? `translate3d(0, ${dimension}px, 0)` |
| 143 | + : `translate3d(${dimension}px, 0, 0)`, |
| 144 | + }); |
| 145 | + |
| 146 | + if ( |
| 147 | + snapPointsOffset && |
| 148 | + newSnapPointIndex !== snapPointsOffset.length - 1 && |
| 149 | + newSnapPointIndex !== this.#fadeFromIndex.current |
| 150 | + ) { |
| 151 | + setStyles(this.#overlayRef.current, { |
| 152 | + transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(",")})`, |
| 153 | + opacity: "0", |
| 154 | + }); |
| 155 | + } else { |
| 156 | + setStyles(this.#overlayRef.current, { |
| 157 | + transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(",")})`, |
| 158 | + opacity: "1", |
| 159 | + }); |
| 160 | + } |
| 161 | + |
| 162 | + this.#setActiveSnapPoint( |
| 163 | + newSnapPointIndex !== null ? this.#snapPoints.current?.[newSnapPointIndex] : null |
| 164 | + ); |
| 165 | + }; |
| 166 | + |
| 167 | + onReleaseSnapPoints = ({ |
| 168 | + draggedDistance, |
| 169 | + closeDrawer, |
| 170 | + velocity, |
| 171 | + dismissible, |
| 172 | + }: { |
| 173 | + draggedDistance: number; |
| 174 | + closeDrawer: () => void; |
| 175 | + velocity: number; |
| 176 | + dismissible: boolean; |
| 177 | + }) => { |
| 178 | + if (this.#fadeFromIndex.current === undefined) return; |
| 179 | + const direction = this.#direction.current; |
| 180 | + const activeSnapPointOffset = this.activeSnapPointOffset; |
| 181 | + const activeSnapPointIndex = this.activeSnapPointIndex; |
| 182 | + const fadeFromIndex = this.#fadeFromIndex.current; |
| 183 | + |
| 184 | + const currentPosition = |
| 185 | + direction === "bottom" || direction === "right" |
| 186 | + ? (activeSnapPointOffset ?? 0) - draggedDistance |
| 187 | + : (activeSnapPointOffset ?? 0) + draggedDistance; |
| 188 | + const isOverlaySnapPoint = activeSnapPointIndex === fadeFromIndex - 1; |
| 189 | + const isFirst = activeSnapPointIndex === 0; |
| 190 | + const hasDraggedUp = draggedDistance > 0; |
| 191 | + |
| 192 | + if (isOverlaySnapPoint) { |
| 193 | + setStyles(this.#overlayRef.current, { |
| 194 | + transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(",")})`, |
| 195 | + }); |
| 196 | + } |
| 197 | + |
| 198 | + if (velocity > 2 && !hasDraggedUp) { |
| 199 | + if (dismissible) closeDrawer(); |
| 200 | + else this.snapToPoint(this.snapPointsOffset[0]); // snap to initial point |
| 201 | + return; |
| 202 | + } |
| 203 | + |
| 204 | + if (velocity > 2 && hasDraggedUp && this.snapPointsOffset && this.#snapPoints.current) { |
| 205 | + this.snapToPoint(this.snapPointsOffset[this.#snapPoints.current.length - 1] as number); |
| 206 | + return; |
| 207 | + } |
| 208 | + |
| 209 | + // Find the closest snap point to the current position |
| 210 | + const closestSnapPoint = this.snapPointsOffset?.reduce((prev, curr) => { |
| 211 | + if (typeof prev !== "number" || typeof curr !== "number") return prev; |
| 212 | + |
| 213 | + return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) |
| 214 | + ? curr |
| 215 | + : prev; |
| 216 | + }); |
| 217 | + |
| 218 | + const dim = isVertical(direction) ? window.innerHeight : window.innerWidth; |
| 219 | + if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) { |
| 220 | + const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down |
| 221 | + |
| 222 | + // Don't do anything if we swipe upwards while being on the last snap point |
| 223 | + if (dragDirection > 0 && this.isLastSnapPoint) { |
| 224 | + this.snapToPoint(this.snapPointsOffset[this.#snapPoints.current.length - 1]); |
| 225 | + return; |
| 226 | + } |
| 227 | + |
| 228 | + if (isFirst && dragDirection < 0 && dismissible) { |
| 229 | + closeDrawer(); |
| 230 | + } |
| 231 | + |
| 232 | + if (activeSnapPointIndex === null) return; |
| 233 | + |
| 234 | + this.snapToPoint(this.snapPointsOffset[activeSnapPointIndex + dragDirection]); |
| 235 | + return; |
| 236 | + } |
| 237 | + |
| 238 | + this.snapToPoint(closestSnapPoint); |
| 239 | + }; |
| 240 | + |
| 241 | + onDragSnapPoints = ({ draggedDistance }: { draggedDistance: number }) => { |
| 242 | + if (this.activeSnapPointOffset === null) return; |
| 243 | + const direction = this.#direction.current; |
| 244 | + const newValue = |
| 245 | + direction === "bottom" || direction === "right" |
| 246 | + ? this.activeSnapPointOffset - draggedDistance |
| 247 | + : this.activeSnapPointOffset + draggedDistance; |
| 248 | + |
| 249 | + // Don't do anything if we exceed the last(biggest) snap point |
| 250 | + if ( |
| 251 | + (direction === "bottom" || direction === "right") && |
| 252 | + newValue < this.snapPointsOffset[this.snapPointsOffset.length - 1] |
| 253 | + ) { |
| 254 | + return; |
| 255 | + } |
| 256 | + if ( |
| 257 | + (direction === "top" || direction === "left") && |
| 258 | + newValue > this.snapPointsOffset[this.snapPointsOffset.length - 1] |
| 259 | + ) { |
| 260 | + return; |
| 261 | + } |
| 262 | + |
| 263 | + setStyles(this.#drawerRef.current, { |
| 264 | + transform: isVertical(direction) |
| 265 | + ? `translate3d(0, ${newValue}px, 0)` |
| 266 | + : `translate3d(${newValue}px, 0, 0)`, |
| 267 | + }); |
| 268 | + }; |
| 269 | + |
| 270 | + getPercentageDragged = (absDraggedDistance: number, isDraggingDown: boolean) => { |
| 271 | + const snapPoints = this.#snapPoints.current; |
| 272 | + const activeSnapPointIndex = this.activeSnapPointIndex; |
| 273 | + const snapPointsOffset = this.snapPointsOffset; |
| 274 | + const fadeFromIndex = this.#fadeFromIndex.current; |
| 275 | + if ( |
| 276 | + !snapPoints || |
| 277 | + typeof activeSnapPointIndex !== "number" || |
| 278 | + !snapPointsOffset || |
| 279 | + fadeFromIndex === undefined |
| 280 | + ) |
| 281 | + return null; |
| 282 | + |
| 283 | + // If this is true we are dragging to a snap point that is supposed to have an overlay |
| 284 | + const isOverlaySnapPoint = activeSnapPointIndex === fadeFromIndex - 1; |
| 285 | + const isOverlaySnapPointOrHigher = activeSnapPointIndex >= fadeFromIndex; |
| 286 | + |
| 287 | + if (isOverlaySnapPointOrHigher && isDraggingDown) { |
| 288 | + return 0; |
| 289 | + } |
| 290 | + |
| 291 | + // Don't animate, but still use this one if we are dragging away from the overlaySnapPoint |
| 292 | + if (isOverlaySnapPoint && !isDraggingDown) return 1; |
| 293 | + if (!this.shouldFade && !isOverlaySnapPoint) return null; |
| 294 | + |
| 295 | + // Either fadeFrom index or the one before |
| 296 | + const targetSnapPointIndex = isOverlaySnapPoint |
| 297 | + ? activeSnapPointIndex + 1 |
| 298 | + : activeSnapPointIndex - 1; |
| 299 | + |
| 300 | + // Get the distance from overlaySnapPoint to the one before or vice-versa to calculate the opacity percentage accordingly |
| 301 | + const snapPointDistance = isOverlaySnapPoint |
| 302 | + ? snapPointsOffset[targetSnapPointIndex] - snapPointsOffset[targetSnapPointIndex - 1] |
| 303 | + : snapPointsOffset[targetSnapPointIndex + 1] - snapPointsOffset[targetSnapPointIndex]; |
| 304 | + |
| 305 | + const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance); |
| 306 | + |
| 307 | + if (isOverlaySnapPoint) { |
| 308 | + return 1 - percentageDragged; |
| 309 | + } else { |
| 310 | + return percentageDragged; |
| 311 | + } |
| 312 | + }; |
| 313 | +} |
0 commit comments