├── images ├── twitter.png └── facebook.png ├── index.js ├── .gitignore ├── README.md ├── index.css ├── index.html └── picknplace.js /images/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgthms/picknplace.js/HEAD/images/twitter.png -------------------------------------------------------------------------------- /images/facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgthms/picknplace.js/HEAD/images/facebook.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { createPickPlace } from "./picknplace.js"; 2 | 3 | const pnp = createPickPlace(); 4 | pnp.init(); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | _site 26 | .jekyll-cache 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # picknplace.js 2 | 3 | A proof of concept of a viable drag and drop alternative. 4 | 5 | facebook 6 | 7 | ### Why? 8 | 9 | I find that the drag and drop experience can quickly become a nightmare, especially on mobile. 10 | Trying to tap, hold, drag, and scroll, all at the _same time_, is awkward, slow, and error-prone. 11 | I've long had in mind a simpler 2-step approach: picking an item first, _then_ placing it. 12 | So I implemented this basic version to showcase my idea. 13 | 14 | ### How it works 15 | 16 | When picking an item, a duplicate of the list is created on top of the original one. 17 | The duplicate is interactive and animated, and will update based on the scroll position. 18 | At the end, the user can either confirm or cancel the changes. 19 | 20 | ### Is this a library? 21 | 22 | Not exactly. This is merely a proof of concept, to convey what I had in mind. 23 | You can however look at the source code, for inspiration. 24 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ 2 | html, 3 | body, 4 | p, 5 | ol, 6 | ul, 7 | li, 8 | dl, 9 | dt, 10 | dd, 11 | blockquote, 12 | figure, 13 | fieldset, 14 | legend, 15 | textarea, 16 | pre, 17 | iframe, 18 | hr, 19 | h1, 20 | h2, 21 | h3, 22 | h4, 23 | h5, 24 | h6 { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | h1, 29 | h2, 30 | h3, 31 | h4, 32 | h5, 33 | h6 { 34 | font-size: 100%; 35 | font-weight: normal; 36 | } 37 | ul { 38 | list-style: none; 39 | } 40 | button, 41 | input, 42 | select { 43 | margin: 0; 44 | } 45 | html { 46 | box-sizing: border-box; 47 | } 48 | *, 49 | *::before, 50 | *::after { 51 | box-sizing: inherit; 52 | } 53 | img, 54 | video { 55 | height: auto; 56 | max-width: 100%; 57 | } 58 | iframe { 59 | border: 0; 60 | } 61 | table { 62 | border-collapse: collapse; 63 | border-spacing: 0; 64 | } 65 | td, 66 | th { 67 | padding: 0; 68 | } 69 | 70 | :root { 71 | --yellow: #fabd1b; 72 | --orange: #ff8411; 73 | --crimson: #ff6738; 74 | --pink: #ff7e75; 75 | --purple: #ae9eff; 76 | --cyan: #18c6f6; 77 | --blue: #5294ff; 78 | --teal: #00c2a8; 79 | --green: #00d66b; 80 | --lime: #b3db00; 81 | --keyboardfocus-color: #000; 82 | } 83 | 84 | html { 85 | font-family: Poppins, Inter, "Inter Variable", system-ui, sans-serif; 86 | line-height: 1.5; 87 | font-weight: 400; 88 | 89 | background-color: #fcf5ef; 90 | color: #999999; 91 | 92 | font-synthesis: none; 93 | text-rendering: optimizeLegibility; 94 | -webkit-font-smoothing: antialiased; 95 | -moz-osx-font-smoothing: grayscale; 96 | 97 | padding: 0 3em; 98 | } 99 | 100 | body, 101 | header, 102 | footer, 103 | main, 104 | .pnp-list { 105 | display: flex; 106 | flex-direction: column; 107 | justify-content: center; 108 | } 109 | 110 | h1, 111 | strong, 112 | em, 113 | button { 114 | color: #111; 115 | font-weight: 500; 116 | } 117 | 118 | em { 119 | font-style: normal; 120 | } 121 | 122 | header, 123 | footer { 124 | gap: 1em; 125 | min-height: 86vh; 126 | padding: 6em 0; 127 | } 128 | 129 | footer { 130 | gap: 3em; 131 | 132 | p:not(:first-child) { 133 | color: #666; 134 | margin-top: 0.25em; 135 | } 136 | } 137 | 138 | a, 139 | button { 140 | color: inherit; 141 | cursor: pointer; 142 | text-decoration: none; 143 | } 144 | 145 | button:hover { 146 | text-decoration: underline; 147 | } 148 | 149 | a { 150 | border-bottom: 2px solid var(--yellow); 151 | color: #111; 152 | transition-duration: 200ms; 153 | transition-property: background-color; 154 | 155 | &:hover { 156 | background-color: var(--yellow); 157 | } 158 | } 159 | 160 | .accent { 161 | color: var(--yellow); 162 | } 163 | 164 | .accent-bg { 165 | strong { 166 | background-color: var(--yellow); 167 | display: inline-flex; 168 | padding: 0.125em 0.5em; 169 | border-radius: 0.5em; 170 | } 171 | } 172 | 173 | kbd { 174 | background-color: var(--yellow); 175 | background-color: rgba(0, 0, 0, 0.1); 176 | color: #333; 177 | display: inline-flex; 178 | font-family: inherit; 179 | padding: 0.125em 0.5em; 180 | border-radius: 0.5em; 181 | } 182 | 183 | body { 184 | align-items: center; 185 | } 186 | 187 | button { 188 | appearance: none; 189 | background: none; 190 | border: none; 191 | color: #111; 192 | outline-color: transparent; 193 | outline-width: 0; 194 | font-family: inherit; 195 | font-weight: 500; 196 | font-size: 1em; 197 | line-height: inherit; 198 | margin: 0; 199 | padding: 0; 200 | } 201 | 202 | main { 203 | align-items: stretch; 204 | flex-grow: 1; 205 | max-width: 21em; 206 | width: 100%; 207 | gap: 0; 208 | } 209 | 210 | h1 { 211 | font-size: 1.5em; 212 | font-weight: 600; 213 | letter-spacing: -0.04em; 214 | } 215 | 216 | .pnp-list { 217 | --spacing: 0.5em; 218 | padding: var(--spacing); 219 | gap: var(--spacing); 220 | background-color: #fff; 221 | align-items: stretch; 222 | border-radius: 1em; 223 | list-style: none; 224 | position: relative; 225 | } 226 | 227 | .element { 228 | background-color: var(--color); 229 | display: flex; 230 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.06), 231 | 0 -1px 0 0 rgba(0, 0, 0, 0.1) inset; 232 | border-radius: 0.5em; 233 | padding: 0.5em 0.75em; 234 | justify-content: space-between; 235 | align-items: center; 236 | } 237 | 238 | .pnp-list.is-ready { 239 | .pnp-clone { 240 | transition-duration: 200ms; 241 | transition-property: transform; 242 | transition-timing-function: ease-out; 243 | } 244 | } 245 | 246 | .pnp-picked { 247 | opacity: 0; 248 | } 249 | 250 | .pnp-buttons { 251 | display: flex; 252 | align-items: center; 253 | gap: 0.5em; 254 | justify-content: end; 255 | } 256 | 257 | .pnp-controls { 258 | position: fixed; 259 | z-index: 10; 260 | left: 0; 261 | right: 0; 262 | display: flex; 263 | align-items: center; 264 | padding: 2em; 265 | justify-content: center; 266 | gap: 1em; 267 | top: 100%; 268 | opacity: 0; 269 | transition-duration: 200ms; 270 | transition-property: opacity, transform; 271 | pointer-events: none; 272 | } 273 | 274 | .button { 275 | display: flex; 276 | align-items: center; 277 | justify-content: center; 278 | padding: 0.5em 1em; 279 | border-radius: 9999px; 280 | 281 | &.is-primary { 282 | background-color: #111; 283 | color: #fff; 284 | } 285 | } 286 | 287 | @keyframes animRotate { 288 | from { 289 | transform: none; 290 | } 291 | 292 | to { 293 | transform: scale(1.04) rotate(-4deg); 294 | } 295 | } 296 | 297 | .element { 298 | transform-origin: center; 299 | will-change: transform; 300 | animation-duration: 100ms; 301 | animation-fill-mode: both; 302 | animation-timing-function: ease-out; 303 | } 304 | 305 | .pnp-ghost { 306 | --offset: 0px; 307 | list-style: none; 308 | position: fixed; 309 | left: 0; 310 | top: 0; 311 | opacity: 1; 312 | } 313 | 314 | .pnp-list { 315 | &[data-mode="picking"] { 316 | .pnp-buttons { 317 | opacity: 0; 318 | pointer-events: none; 319 | } 320 | } 321 | } 322 | .element:focus-within { 323 | outline: 2px solid var(--keyboardfocus-color); 324 | outline-offset: 2px; 325 | } 326 | .pnp-list { 327 | &[data-mode="picking"] { 328 | .pnp-item:not(.pnp-clone) { 329 | opacity: 0; 330 | pointer-events: none; 331 | } 332 | } 333 | } 334 | 335 | /* Desktop */ 336 | @media (hover: hover) { 337 | } 338 | 339 | /* Mobile */ 340 | @media (hover: none) { 341 | small { 342 | display: none; 343 | } 344 | 345 | .pnp-controls { 346 | &.is-active { 347 | opacity: 1; 348 | transform: translateY(-100%); 349 | pointer-events: auto; 350 | } 351 | } 352 | 353 | .pnp-ghost { 354 | .pnp-buttons { 355 | opacity: 0; 356 | pointer-events: none; 357 | } 358 | } 359 | } 360 | 361 | .element.n1 { 362 | --color: var(--yellow); 363 | } 364 | .element.n2 { 365 | --color: var(--orange); 366 | } 367 | .element.n3 { 368 | --color: var(--crimson); 369 | } 370 | .element.n4 { 371 | --color: var(--pink); 372 | } 373 | .element.n5 { 374 | --color: var(--purple); 375 | } 376 | .element.n6 { 377 | --color: var(--cyan); 378 | } 379 | .element.n7 { 380 | --color: var(--blue); 381 | } 382 | .element.n8 { 383 | --color: var(--teal); 384 | } 385 | .element.n9 { 386 | --color: var(--green); 387 | } 388 | .element.n10 { 389 | --color: var(--lime); 390 | } 391 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | picknplace.js, an alternative to drag and drop 46 | 47 | 48 | 49 |
50 |
51 |
52 |

picknplace.js

53 |

an alternative to drag and drop

54 |
55 | 56 |

57 | 3 steps: pick -> scroll -> 58 | place 59 |

60 | 61 | 62 | Or use Enter to place, Esc to cancel 63 | 64 |
65 | 66 |
    67 |
  1. 68 |
    69 | One 70 |
    71 | 72 |
    73 |
    74 |
  2. 75 |
  3. 76 |
    77 | Two 78 |
    79 | 80 |
    81 |
    82 |
  4. 83 |
  5. 84 |
    85 | Three 86 |
    87 | 88 |
    89 |
    90 |
  6. 91 |
  7. 92 |
    93 | Four 94 |
    95 | 96 |
    97 |
    98 |
  8. 99 |
  9. 100 |
    101 | Five 102 |
    103 | 104 |
    105 |
    106 |
  10. 107 |
  11. 108 |
    109 | Six 110 |
    111 | 112 |
    113 |
    114 |
  12. 115 |
  13. 116 |
    117 | Seven 118 |
    119 | 120 |
    121 |
    122 |
  14. 123 |
  15. 124 |
    125 | Eight 126 |
    127 | 128 |
    129 |
    130 |
  16. 131 |
  17. 132 |
    133 | Nine 134 |
    135 | 136 |
    137 |
    138 |
  18. 139 |
  19. 140 |
    141 | Ten 142 |
    143 | 144 |
    145 |
    146 |
  20. 147 |
148 | 149 |
150 | 151 | 152 |
153 | 154 | 214 |
215 | 216 | 217 | -------------------------------------------------------------------------------- /picknplace.js: -------------------------------------------------------------------------------- 1 | export function createPickPlace(options = {}) { 2 | const root = options.root || document; 3 | const listSelector = options.listSelector || ".pnp-list"; 4 | const itemSelector = options.itemSelector || ".pnp-item"; 5 | const controlsSelector = options.controlsSelector || ".pnp-controls"; 6 | const buttonsSelector = options.buttonsSelector || ".pnp-buttons"; 7 | const pickSelector = options.pickSelector || ".pnp-pick"; 8 | const placeSelector = options.placeSelector || ".pnp-place"; 9 | const cancelSelector = options.cancelSelector || ".pnp-cancel"; 10 | const pickedClass = options.pickedClass || "pnp-picked"; 11 | const realClass = options.realClass || "pnp-real"; 12 | const ghostClass = options.ghostClass || "pnp-ghost"; 13 | const cloneClass = options.cloneClass || "pnp-clone"; 14 | 15 | let initialized = false; 16 | let $ghost = null; 17 | let scrollDirY = 0; 18 | let lastScrollY = window.scrollY; 19 | let targetIndex = null; 20 | let $controls = null; 21 | let ghostTop = 0; 22 | let ghostOffset = 0; 23 | 24 | // State 25 | let state = { 26 | mode: "idle", 27 | $list: null, 28 | $item: null, 29 | originalTop: 0, 30 | positions: [], 31 | currentIndex: null, 32 | }; 33 | 34 | const reduce = (state, event) => { 35 | switch (event.type) { 36 | case "pick": 37 | return { 38 | mode: "picking", 39 | $item: event.$item, 40 | $list: event.$list, 41 | originalTop: event.originalTop, 42 | positions: event.positions, 43 | currentIndex: event.currentIndex, 44 | }; 45 | 46 | case "place": 47 | return { 48 | mode: "idle", 49 | $list: null, 50 | $item: null, 51 | originalTop: 0, 52 | positions: [], 53 | currentIndex: null, 54 | }; 55 | 56 | case "cancel": 57 | return { 58 | mode: "idle", 59 | $list: null, 60 | $item: null, 61 | originalTop: 0, 62 | positions: [], 63 | currentIndex: null, 64 | }; 65 | 66 | default: 67 | return state; 68 | } 69 | }; 70 | 71 | const dispatch = (event) => { 72 | const prev = state; 73 | const next = reduce(state, event); 74 | 75 | if (next === prev) { 76 | return; 77 | } 78 | 79 | // Store and reset 80 | state = next; 81 | targetIndex = null; 82 | 83 | // Update the DOM 84 | const $list = prev.$list || next.$list; 85 | 86 | if ($list) { 87 | $list.dataset.mode = next.mode; 88 | } 89 | 90 | if (next.mode === "picking") { 91 | next.$item?.classList.add(pickedClass); 92 | setPickingMode(); 93 | next.$list?.classList.add("is-ready"); 94 | createGhost(next.$item); 95 | 96 | if ($controls) { 97 | $controls.classList.add("is-active"); 98 | } 99 | } 100 | 101 | if (next.mode === "idle") { 102 | prev.$item?.classList.remove(pickedClass); 103 | 104 | if (prev.$list) { 105 | prev.$list.classList.remove("is-ready"); 106 | setIdleMode(prev.$list); 107 | } 108 | 109 | if ($controls) { 110 | $controls.classList.remove("is-active"); 111 | } 112 | 113 | destroyGhost(); 114 | } 115 | 116 | if (event.type === "place") { 117 | sortDomByNewIndices(prev.$list, prev.positions); 118 | } 119 | }; 120 | 121 | // DOM 122 | const sortDomByNewIndices = ($list, positions) => { 123 | const ordered = positions 124 | .slice() 125 | .sort((a, b) => a.currentIndex - b.currentIndex); 126 | 127 | const frag = document.createDocumentFragment(); 128 | for (const p of ordered) frag.appendChild(p.el); 129 | 130 | $list.appendChild(frag); 131 | }; 132 | 133 | // Ghost 134 | const createGhost = (item) => { 135 | destroyGhost(); 136 | 137 | const clone = item.cloneNode(true); 138 | clone.classList.add(ghostClass); 139 | 140 | const rect = item.getBoundingClientRect(); 141 | 142 | Object.assign(clone.style, { 143 | position: "fixed", 144 | width: `${rect.width}px`, 145 | height: `${rect.height}px`, 146 | transform: `translate(${rect.left}px, calc(${rect.top}px + var(--offset))`, 147 | }); 148 | ghostTop = rect.top; 149 | 150 | const buttons = clone.querySelector(buttonsSelector); 151 | 152 | if (buttons) { 153 | buttons.innerHTML = ` 154 | 155 | 156 | `; 157 | } 158 | 159 | document.body.appendChild(clone); 160 | $ghost = clone; 161 | }; 162 | 163 | const destroyGhost = () => { 164 | if ($ghost) { 165 | $ghost.remove(); 166 | } 167 | 168 | $ghost = null; 169 | }; 170 | 171 | // Events 172 | const onKeyDown = (event) => { 173 | if (event.key === "Escape") { 174 | return dispatch({ 175 | type: "cancel", 176 | }); 177 | } 178 | 179 | if (event.key === "Enter") { 180 | return dispatch({ 181 | type: "place", 182 | }); 183 | } 184 | }; 185 | 186 | const onClick = (event) => { 187 | const target = event.target; 188 | 189 | if (!(target instanceof Element)) { 190 | return; 191 | } 192 | 193 | const cancelBtn = target.closest(cancelSelector); 194 | 195 | if (cancelBtn) { 196 | return dispatch({ 197 | type: "cancel", 198 | }); 199 | } 200 | 201 | const placeBtn = target.closest(placeSelector); 202 | 203 | if (placeBtn) { 204 | return dispatch({ 205 | type: "place", 206 | }); 207 | } 208 | 209 | const pickBtn = target.closest(pickSelector); 210 | 211 | if (pickBtn) { 212 | event.preventDefault(); 213 | event.stopPropagation(); 214 | 215 | if (state.mode === "picking") { 216 | return dispatch({ 217 | type: "cancel", 218 | }); 219 | } 220 | 221 | const $item = pickBtn.closest(itemSelector); 222 | const $list = $item?.closest(listSelector); 223 | 224 | if (!$item || !$list) { 225 | return; 226 | } 227 | 228 | const listRect = $list.getBoundingClientRect(); 229 | 230 | const $items = Array.from($list.children); 231 | const currentIndex = $items.indexOf($item); 232 | 233 | const positions = $items.map((el, index) => { 234 | const rect = el.getBoundingClientRect(); 235 | 236 | return { 237 | el, 238 | clone: null, 239 | originalIndex: index, 240 | currentIndex: index, 241 | originalTop: rect.top, 242 | rect, 243 | }; 244 | }); 245 | 246 | return dispatch({ 247 | type: "pick", 248 | $list, 249 | $item, 250 | originalTop: listRect.top, 251 | positions, 252 | currentIndex, 253 | }); 254 | } 255 | }; 256 | 257 | const swapByIndex = (positions, indexA, indexB) => { 258 | if (indexA === indexB) return; 259 | 260 | const a = positions.find((p) => p.currentIndex === indexA); 261 | const b = positions.find((p) => p.currentIndex === indexB); 262 | 263 | if (!a || !b) return; 264 | 265 | a.currentIndex = indexB; 266 | b.currentIndex = indexA; 267 | }; 268 | 269 | let scrollRaf = null; 270 | 271 | const onScroll = (event) => { 272 | if (state.mode !== "picking" || !$ghost || scrollRaf) { 273 | return; 274 | } 275 | 276 | const y = window.scrollY; 277 | scrollDirY = y - lastScrollY; 278 | lastScrollY = y; 279 | 280 | scrollRaf = requestAnimationFrame(() => { 281 | scrollRaf = null; 282 | 283 | const ghostRect = $ghost.getBoundingClientRect(); 284 | const ghostCenter = { 285 | x: ghostRect.left + ghostRect.width / 2, 286 | y: ghostRect.top + ghostRect.height / 2, 287 | }; 288 | 289 | const $items = Array.from(state.$list.children).filter( 290 | (x) => !x.classList.contains(cloneClass) 291 | ); 292 | 293 | let newTargetIndex; 294 | 295 | const listRect = state.$list.getBoundingClientRect(); 296 | const listSpacing = parseFloat(getComputedStyle(state.$list).paddingTop); 297 | 298 | // If the ghost goes above the list 299 | if (listRect.top + listSpacing > ghostTop) { 300 | ghostOffset = listRect.top - ghostTop + listSpacing; 301 | } else if ( 302 | ghostTop > 303 | listRect.top + listRect.height - ghostRect.height - listSpacing 304 | ) { 305 | const diff = 306 | listRect.top + listRect.height - ghostRect.height - listSpacing; 307 | ghostOffset = -1 * ghostTop + diff; 308 | } else { 309 | ghostOffset = 0; 310 | } 311 | 312 | $ghost.style.setProperty("--offset", `${ghostOffset}px`); 313 | 314 | if (ghostCenter.y > listRect.top + listRect.height) { 315 | newTargetIndex = $items.length - 1; 316 | } else if (scrollDirY >= 0) { 317 | // Going down: swap when center crosses the top edge 318 | for (const [index, $item] of $items.entries()) { 319 | const rect = $item.getBoundingClientRect(); 320 | 321 | if (ghostCenter.y < rect.top) { 322 | newTargetIndex = index - 1; 323 | break; 324 | } 325 | 326 | newTargetIndex = $items.length - 1; 327 | } 328 | } else { 329 | // Going up: swap when center crosses the bottom edge 330 | for (const [index, $item] of $items.entries()) { 331 | const rect = $item.getBoundingClientRect(); 332 | 333 | if (ghostCenter.y < rect.bottom) { 334 | newTargetIndex = index; 335 | break; 336 | } 337 | 338 | newTargetIndex = 0; 339 | } 340 | } 341 | 342 | if (newTargetIndex !== targetIndex) { 343 | swapByIndex(state.positions, targetIndex, newTargetIndex); 344 | transformItems(); 345 | targetIndex = newTargetIndex; 346 | } 347 | }); 348 | }; 349 | 350 | // Modes 351 | const setPickingMode = () => { 352 | const listRect = state.$list.getBoundingClientRect(); 353 | 354 | for (const position of state.positions) { 355 | const { el, rect } = position; 356 | const clone = el.cloneNode(true); 357 | clone.classList.remove(realClass); 358 | clone.classList.add(cloneClass); 359 | position.clone = clone; 360 | 361 | Object.assign(clone.style, { 362 | position: "absolute", 363 | left: "0px", 364 | top: "0px", 365 | width: `${rect.width}px`, 366 | transform: `translate( 367 | ${rect.left - listRect.left}px, 368 | ${rect.top - listRect.top}px 369 | )`, 370 | }); 371 | 372 | state.$list.appendChild(clone); 373 | } 374 | }; 375 | 376 | const transformItems = () => { 377 | const listRect = state.$list.getBoundingClientRect(); 378 | 379 | for (const position of state.positions) { 380 | const { clone, currentIndex, rect } = position; 381 | 382 | let top = rect.top; 383 | 384 | const found = state.positions.find( 385 | (pos) => currentIndex === pos.originalIndex 386 | ); 387 | 388 | if (found && found.originalTop) { 389 | top = found.originalTop; 390 | } 391 | 392 | Object.assign(clone.style, { 393 | transform: `translate( 394 | ${rect.left - listRect.left}px, 395 | ${top - state.originalTop}px 396 | )`, 397 | }); 398 | } 399 | }; 400 | 401 | const setIdleMode = ($list) => { 402 | const clones = $list.querySelectorAll(`.${cloneClass}`); 403 | 404 | for (const clone of clones) { 405 | clone.remove(); 406 | } 407 | }; 408 | 409 | // Lifecyle 410 | const init = () => { 411 | if (initialized) { 412 | return; 413 | } 414 | 415 | $controls = root.querySelector(controlsSelector); 416 | root.addEventListener("click", onClick, true); 417 | root.addEventListener("keydown", onKeyDown, true); 418 | window.addEventListener("scroll", onScroll, { passive: true }); 419 | 420 | initialized = true; 421 | }; 422 | 423 | return { 424 | init, 425 | }; 426 | } 427 | --------------------------------------------------------------------------------