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.
### 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
-
-
-
-
-
-
-
-
-
-
================================================
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,
};
}