Repository: jgthms/picknplace.js Branch: main Commit: a10b64363d3d Files: 6 Total size: 23.2 KB Directory structure: gitextract_1o5cpnee/ ├── .gitignore ├── README.md ├── index.css ├── index.html ├── index.js └── picknplace.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? _site .jekyll-cache ================================================ FILE: README.md ================================================ # picknplace.js A proof of concept of a viable drag and drop alternative. facebook ### Why? I find that the drag and drop experience can quickly become a nightmare, especially on mobile. Trying to tap, hold, drag, and scroll, all at the _same time_, is awkward, slow, and error-prone. I've long had in mind a simpler 2-step approach: picking an item first, _then_ placing it. So I implemented this basic version to showcase my idea. ### How it works When picking an item, a duplicate of the list is created on top of the original one. The duplicate is interactive and animated, and will update based on the scroll position. At the end, the user can either confirm or cancel the changes. ### Is this a library? Not exactly. This is merely a proof of concept, to convey what I had in mind. You can however look at the source code, for inspiration. ================================================ FILE: index.css ================================================ /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ html, body, p, ol, ul, li, dl, dt, dd, blockquote, figure, fieldset, legend, textarea, pre, iframe, hr, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } h1, h2, h3, h4, h5, h6 { font-size: 100%; font-weight: normal; } ul { list-style: none; } button, input, select { margin: 0; } html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } img, video { height: auto; max-width: 100%; } iframe { border: 0; } table { border-collapse: collapse; border-spacing: 0; } td, th { padding: 0; } :root { --yellow: #fabd1b; --orange: #ff8411; --crimson: #ff6738; --pink: #ff7e75; --purple: #ae9eff; --cyan: #18c6f6; --blue: #5294ff; --teal: #00c2a8; --green: #00d66b; --lime: #b3db00; --keyboardfocus-color: #000; } html { font-family: Poppins, Inter, "Inter Variable", system-ui, sans-serif; line-height: 1.5; font-weight: 400; background-color: #fcf5ef; color: #999999; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; padding: 0 3em; } body, header, footer, main, .pnp-list { display: flex; flex-direction: column; justify-content: center; } h1, strong, em, button { color: #111; font-weight: 500; } em { font-style: normal; } header, footer { gap: 1em; min-height: 86vh; padding: 6em 0; } footer { gap: 3em; p:not(:first-child) { color: #666; margin-top: 0.25em; } } a, button { color: inherit; cursor: pointer; text-decoration: none; } button:hover { text-decoration: underline; } a { border-bottom: 2px solid var(--yellow); color: #111; transition-duration: 200ms; transition-property: background-color; &:hover { background-color: var(--yellow); } } .accent { color: var(--yellow); } .accent-bg { strong { background-color: var(--yellow); display: inline-flex; padding: 0.125em 0.5em; border-radius: 0.5em; } } kbd { background-color: var(--yellow); background-color: rgba(0, 0, 0, 0.1); color: #333; display: inline-flex; font-family: inherit; padding: 0.125em 0.5em; border-radius: 0.5em; } body { align-items: center; } button { appearance: none; background: none; border: none; color: #111; outline-color: transparent; outline-width: 0; font-family: inherit; font-weight: 500; font-size: 1em; line-height: inherit; margin: 0; padding: 0; } main { align-items: stretch; flex-grow: 1; max-width: 21em; width: 100%; gap: 0; } h1 { font-size: 1.5em; font-weight: 600; letter-spacing: -0.04em; } .pnp-list { --spacing: 0.5em; padding: var(--spacing); gap: var(--spacing); background-color: #fff; align-items: stretch; border-radius: 1em; list-style: none; position: relative; } .element { background-color: var(--color); display: flex; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.06), 0 -1px 0 0 rgba(0, 0, 0, 0.1) inset; border-radius: 0.5em; padding: 0.5em 0.75em; justify-content: space-between; align-items: center; } .pnp-list.is-ready { .pnp-clone { transition-duration: 200ms; transition-property: transform; transition-timing-function: ease-out; } } .pnp-picked { opacity: 0; } .pnp-buttons { display: flex; align-items: center; gap: 0.5em; justify-content: end; } .pnp-controls { position: fixed; z-index: 10; left: 0; right: 0; display: flex; align-items: center; padding: 2em; justify-content: center; gap: 1em; top: 100%; opacity: 0; transition-duration: 200ms; transition-property: opacity, transform; pointer-events: none; } .button { display: flex; align-items: center; justify-content: center; padding: 0.5em 1em; border-radius: 9999px; &.is-primary { background-color: #111; color: #fff; } } @keyframes animRotate { from { transform: none; } to { transform: scale(1.04) rotate(-4deg); } } .element { transform-origin: center; will-change: transform; animation-duration: 100ms; animation-fill-mode: both; animation-timing-function: ease-out; } .pnp-ghost { --offset: 0px; list-style: none; position: fixed; left: 0; top: 0; opacity: 1; } .pnp-list { &[data-mode="picking"] { .pnp-buttons { opacity: 0; pointer-events: none; } } } .element:focus-within { outline: 2px solid var(--keyboardfocus-color); outline-offset: 2px; } .pnp-list { &[data-mode="picking"] { .pnp-item:not(.pnp-clone) { opacity: 0; pointer-events: none; } } } /* Desktop */ @media (hover: hover) { } /* Mobile */ @media (hover: none) { small { display: none; } .pnp-controls { &.is-active { opacity: 1; transform: translateY(-100%); pointer-events: auto; } } .pnp-ghost { .pnp-buttons { opacity: 0; pointer-events: none; } } } .element.n1 { --color: var(--yellow); } .element.n2 { --color: var(--orange); } .element.n3 { --color: var(--crimson); } .element.n4 { --color: var(--pink); } .element.n5 { --color: var(--purple); } .element.n6 { --color: var(--cyan); } .element.n7 { --color: var(--blue); } .element.n8 { --color: var(--teal); } .element.n9 { --color: var(--green); } .element.n10 { --color: var(--lime); } ================================================ FILE: index.html ================================================ picknplace.js, an alternative to drag and drop

picknplace.js

an alternative to drag and drop

3 steps: pick -> scroll -> place

Or use Enter to place, Esc to cancel
  1. One
  2. Two
  3. Three
  4. Four
  5. Five
  6. Six
  7. Seven
  8. Eight
  9. Nine
  10. Ten
================================================ FILE: index.js ================================================ import { createPickPlace } from "./picknplace.js"; const pnp = createPickPlace(); pnp.init(); ================================================ FILE: picknplace.js ================================================ export function createPickPlace(options = {}) { const root = options.root || document; const listSelector = options.listSelector || ".pnp-list"; const itemSelector = options.itemSelector || ".pnp-item"; const controlsSelector = options.controlsSelector || ".pnp-controls"; const buttonsSelector = options.buttonsSelector || ".pnp-buttons"; const pickSelector = options.pickSelector || ".pnp-pick"; const placeSelector = options.placeSelector || ".pnp-place"; const cancelSelector = options.cancelSelector || ".pnp-cancel"; const pickedClass = options.pickedClass || "pnp-picked"; const realClass = options.realClass || "pnp-real"; const ghostClass = options.ghostClass || "pnp-ghost"; const cloneClass = options.cloneClass || "pnp-clone"; let initialized = false; let $ghost = null; let scrollDirY = 0; let lastScrollY = window.scrollY; let targetIndex = null; let $controls = null; let ghostTop = 0; let ghostOffset = 0; // State let state = { mode: "idle", $list: null, $item: null, originalTop: 0, positions: [], currentIndex: null, }; const reduce = (state, event) => { switch (event.type) { case "pick": return { mode: "picking", $item: event.$item, $list: event.$list, originalTop: event.originalTop, positions: event.positions, currentIndex: event.currentIndex, }; case "place": return { mode: "idle", $list: null, $item: null, originalTop: 0, positions: [], currentIndex: null, }; case "cancel": return { mode: "idle", $list: null, $item: null, originalTop: 0, positions: [], currentIndex: null, }; default: return state; } }; const dispatch = (event) => { const prev = state; const next = reduce(state, event); if (next === prev) { return; } // Store and reset state = next; targetIndex = null; // Update the DOM const $list = prev.$list || next.$list; if ($list) { $list.dataset.mode = next.mode; } if (next.mode === "picking") { next.$item?.classList.add(pickedClass); setPickingMode(); next.$list?.classList.add("is-ready"); createGhost(next.$item); if ($controls) { $controls.classList.add("is-active"); } } if (next.mode === "idle") { prev.$item?.classList.remove(pickedClass); if (prev.$list) { prev.$list.classList.remove("is-ready"); setIdleMode(prev.$list); } if ($controls) { $controls.classList.remove("is-active"); } destroyGhost(); } if (event.type === "place") { sortDomByNewIndices(prev.$list, prev.positions); } }; // DOM const sortDomByNewIndices = ($list, positions) => { const ordered = positions .slice() .sort((a, b) => a.currentIndex - b.currentIndex); const frag = document.createDocumentFragment(); for (const p of ordered) frag.appendChild(p.el); $list.appendChild(frag); }; // Ghost const createGhost = (item) => { destroyGhost(); const clone = item.cloneNode(true); clone.classList.add(ghostClass); const rect = item.getBoundingClientRect(); Object.assign(clone.style, { position: "fixed", width: `${rect.width}px`, height: `${rect.height}px`, transform: `translate(${rect.left}px, calc(${rect.top}px + var(--offset))`, }); ghostTop = rect.top; const buttons = clone.querySelector(buttonsSelector); if (buttons) { buttons.innerHTML = ` `; } document.body.appendChild(clone); $ghost = clone; }; const destroyGhost = () => { if ($ghost) { $ghost.remove(); } $ghost = null; }; // Events const onKeyDown = (event) => { if (event.key === "Escape") { return dispatch({ type: "cancel", }); } if (event.key === "Enter") { return dispatch({ type: "place", }); } }; const onClick = (event) => { const target = event.target; if (!(target instanceof Element)) { return; } const cancelBtn = target.closest(cancelSelector); if (cancelBtn) { return dispatch({ type: "cancel", }); } const placeBtn = target.closest(placeSelector); if (placeBtn) { return dispatch({ type: "place", }); } const pickBtn = target.closest(pickSelector); if (pickBtn) { event.preventDefault(); event.stopPropagation(); if (state.mode === "picking") { return dispatch({ type: "cancel", }); } const $item = pickBtn.closest(itemSelector); const $list = $item?.closest(listSelector); if (!$item || !$list) { return; } const listRect = $list.getBoundingClientRect(); const $items = Array.from($list.children); const currentIndex = $items.indexOf($item); const positions = $items.map((el, index) => { const rect = el.getBoundingClientRect(); return { el, clone: null, originalIndex: index, currentIndex: index, originalTop: rect.top, rect, }; }); return dispatch({ type: "pick", $list, $item, originalTop: listRect.top, positions, currentIndex, }); } }; const swapByIndex = (positions, indexA, indexB) => { if (indexA === indexB) return; const a = positions.find((p) => p.currentIndex === indexA); const b = positions.find((p) => p.currentIndex === indexB); if (!a || !b) return; a.currentIndex = indexB; b.currentIndex = indexA; }; let scrollRaf = null; const onScroll = (event) => { if (state.mode !== "picking" || !$ghost || scrollRaf) { return; } const y = window.scrollY; scrollDirY = y - lastScrollY; lastScrollY = y; scrollRaf = requestAnimationFrame(() => { scrollRaf = null; const ghostRect = $ghost.getBoundingClientRect(); const ghostCenter = { x: ghostRect.left + ghostRect.width / 2, y: ghostRect.top + ghostRect.height / 2, }; const $items = Array.from(state.$list.children).filter( (x) => !x.classList.contains(cloneClass) ); let newTargetIndex; const listRect = state.$list.getBoundingClientRect(); const listSpacing = parseFloat(getComputedStyle(state.$list).paddingTop); // If the ghost goes above the list if (listRect.top + listSpacing > ghostTop) { ghostOffset = listRect.top - ghostTop + listSpacing; } else if ( ghostTop > listRect.top + listRect.height - ghostRect.height - listSpacing ) { const diff = listRect.top + listRect.height - ghostRect.height - listSpacing; ghostOffset = -1 * ghostTop + diff; } else { ghostOffset = 0; } $ghost.style.setProperty("--offset", `${ghostOffset}px`); if (ghostCenter.y > listRect.top + listRect.height) { newTargetIndex = $items.length - 1; } else if (scrollDirY >= 0) { // Going down: swap when center crosses the top edge for (const [index, $item] of $items.entries()) { const rect = $item.getBoundingClientRect(); if (ghostCenter.y < rect.top) { newTargetIndex = index - 1; break; } newTargetIndex = $items.length - 1; } } else { // Going up: swap when center crosses the bottom edge for (const [index, $item] of $items.entries()) { const rect = $item.getBoundingClientRect(); if (ghostCenter.y < rect.bottom) { newTargetIndex = index; break; } newTargetIndex = 0; } } if (newTargetIndex !== targetIndex) { swapByIndex(state.positions, targetIndex, newTargetIndex); transformItems(); targetIndex = newTargetIndex; } }); }; // Modes const setPickingMode = () => { const listRect = state.$list.getBoundingClientRect(); for (const position of state.positions) { const { el, rect } = position; const clone = el.cloneNode(true); clone.classList.remove(realClass); clone.classList.add(cloneClass); position.clone = clone; Object.assign(clone.style, { position: "absolute", left: "0px", top: "0px", width: `${rect.width}px`, transform: `translate( ${rect.left - listRect.left}px, ${rect.top - listRect.top}px )`, }); state.$list.appendChild(clone); } }; const transformItems = () => { const listRect = state.$list.getBoundingClientRect(); for (const position of state.positions) { const { clone, currentIndex, rect } = position; let top = rect.top; const found = state.positions.find( (pos) => currentIndex === pos.originalIndex ); if (found && found.originalTop) { top = found.originalTop; } Object.assign(clone.style, { transform: `translate( ${rect.left - listRect.left}px, ${top - state.originalTop}px )`, }); } }; const setIdleMode = ($list) => { const clones = $list.querySelectorAll(`.${cloneClass}`); for (const clone of clones) { clone.remove(); } }; // Lifecyle const init = () => { if (initialized) { return; } $controls = root.querySelector(controlsSelector); root.addEventListener("click", onClick, true); root.addEventListener("keydown", onKeyDown, true); window.addEventListener("scroll", onScroll, { passive: true }); initialized = true; }; return { init, }; }