From a04a3c09705de4c94f1867569b124afe58947c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Sun, 14 Jun 2026 18:10:00 +0200 Subject: [PATCH] weirsoe-ui.js: reusable sortable (desktop DnD + clean drag image + mobile touch) and copy-link Generic drag-and-drop sortable list + copy-to-clipboard, extracted from the wish app so any consumer can reuse it. [data-wt-sortable] + data-order-url on a container; [data-item-id] children; .drag-handle affordance. Desktop uses native DnD with a solid drag image (no smeared translucent ghost); mobile uses long-press to lift + drag-reorder + edge auto-scroll. Plus .drag-handle/.wt-dragging CSS. --- README.md | 17 +---- weirsoe-theme.css | 11 +++ weirsoe-ui.js | 178 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 weirsoe-ui.js diff --git a/README.md b/README.md index 603e294..c6543bf 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ across sites. |---|---| | `weirsoe-theme.css` | Tokens (`--color-*`) + base (glow, selection, transitions) + `.wt-card/.wt-btn/.wt-input/.wt-tag` + `.prose` + theme-toggle styles | | `weirsoe-theme.js` | Persisted dark⇄light toggle + a no-flash snippet | +| `weirsoe-ui.js` | Drag-and-drop sortable lists (`[data-wt-sortable]` + `data-order-url`, clean drag image) + copy-to-clipboard (`[data-copy-link]`) | | `adapters/bulma.css` | Maps Bulma classes → tokens (for Bulma sites) | -| `adapters/bootstrap.css` | Maps Bootstrap 5 classes → tokens (for Bootstrap sites) | | `examples/demo.html` | Standalone preview | ## Quick start (vanilla) @@ -52,21 +52,6 @@ Load the adapter **after** Bulma and **after** `weirsoe-theme.css`: Your existing `.box`, `.button`, `.input`, `.tag`, `.navbar`… are now dark and card-styled. `wish.weircon.dk` (Christmas-Community fork) consumes it this way. -## Theming an existing Bootstrap site - -Load the adapter **after** Bootstrap and **after** `weirsoe-theme.css`, and mirror -`data-theme` onto Bootstrap’s `data-bs-theme` on `` so the two stay in sync: - -```html - - - -``` - -Your existing `.card`, `.btn`, `.form-control`, `.alert`, `.table`, `.navbar`, -`.badge`… become dark and card-styled. `email-manager.weircon.dk` consumes it -this way. - ## Tokens `--color-bg --color-surface --color-surface-2 --color-surface-3 --color-border diff --git a/weirsoe-theme.css b/weirsoe-theme.css index 54b4841..413f122 100644 --- a/weirsoe-theme.css +++ b/weirsoe-theme.css @@ -161,6 +161,17 @@ body::before { .wt-card--link:hover .wt-card-title, a.wt-card--hover:hover .wt-card-title { color: var(--color-accent); } .wt-card-desc { margin-top: 0.5rem; font-size: 0.875rem; color: var(--color-muted); } +/* Drag-and-drop sortable lists (paired with weirsoe-ui.js). Mark the container + with [data-wt-sortable] + data-order-url; children need [data-item-id]; add a + .drag-handle as the grab affordance. */ +.drag-handle { + cursor: grab; color: var(--color-muted); + margin-right: 0.4rem; font-size: 1.2rem; line-height: 1; user-select: none; +} +.drag-handle:active { cursor: grabbing; } +[data-wt-sortable] > [data-item-id] { cursor: default; } +.wt-dragging { opacity: 0.45; } + /* Pill chip / tag-link (e.g. blog tags) */ .wt-chip { display: inline-flex; align-items: center; border-radius: 999px; diff --git a/weirsoe-ui.js b/weirsoe-ui.js new file mode 100644 index 0000000..b262e75 --- /dev/null +++ b/weirsoe-ui.js @@ -0,0 +1,178 @@ +/* weirsoe-ui.js — small, dependency-free UI behaviours for weirsoe-theme. + * + * • Sortable lists: mark a container with [data-wt-sortable] and a + * data-order-url; its [data-item-id] children become drag-reorderable, and + * on drop the new order is POSTed as urlencoded `order[]=…` to data-order-url. + * Add a .drag-handle inside each child as the grab affordance. A clean, solid + * drag image is used so translucent/blurred card surfaces don't smear. + * + *
+ *
⠿ …
+ *
+ * + * • Copy to clipboard: any [data-copy-link] button copies its attribute value + * and shows brief "Kopieret ✓" feedback. + */ +(function () { + function afterElement(container, y) { + var els = Array.prototype.slice.call( + container.querySelectorAll('[data-item-id]:not(.wt-dragging)') + ); + var best = { offset: -Infinity, el: null }; + els.forEach(function (child) { + var box = child.getBoundingClientRect(); + var offset = y - box.top - box.height / 2; + if (offset < 0 && offset > best.offset) best = { offset: offset, el: child }; + }); + return best.el; + } + + function cleanDragImage(el, e) { + // A solid clone as the drag image — avoids the browser snapshotting the + // card's translucent/blurred surface + page glow (which looks smeared). + try { + var g = el.cloneNode(true); + g.classList.remove('wt-dragging'); + var cs = getComputedStyle(el); + var surface = getComputedStyle(document.documentElement) + .getPropertyValue('--color-surface').trim(); + g.style.position = 'fixed'; + g.style.top = '-9999px'; + g.style.left = '-9999px'; + g.style.width = el.offsetWidth + 'px'; + g.style.margin = '0'; + g.style.opacity = '1'; + g.style.boxShadow = 'none'; + g.style.backdropFilter = 'none'; + g.style.webkitBackdropFilter = 'none'; + g.style.background = + cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' + ? cs.backgroundColor + : surface || '#121a27'; + document.body.appendChild(g); + e.dataTransfer.setDragImage(g, 16, 16); + setTimeout(function () { if (g.parentNode) g.parentNode.removeChild(g); }, 0); + } catch (err) { /* setDragImage unsupported — fall back to default ghost */ } + } + + function persist(container) { + var url = container.getAttribute('data-order-url'); + if (!url) return; + var ids = Array.prototype.slice + .call(container.querySelectorAll('[data-item-id]')) + .map(function (el) { return el.getAttribute('data-item-id'); }); + var body = ids.map(function (id) { return 'order[]=' + encodeURIComponent(id); }).join('&'); + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body, + credentials: 'same-origin', + }).catch(function () { /* non-fatal */ }); + } + + function initSortable(container) { + // ---- desktop: native HTML5 drag-and-drop ---- + var dragEl = null; + container.querySelectorAll('[data-item-id]').forEach(function (el) { + el.setAttribute('draggable', 'true'); + el.addEventListener('dragstart', function (e) { + dragEl = el; + el.classList.add('wt-dragging'); + e.dataTransfer.effectAllowed = 'move'; + cleanDragImage(el, e); + }); + el.addEventListener('dragend', function () { + el.classList.remove('wt-dragging'); + dragEl = null; + persist(container); + }); + }); + container.addEventListener('dragover', function (e) { + e.preventDefault(); + if (!dragEl) return; + var after = afterElement(container, e.clientY); + if (after == null) container.appendChild(dragEl); + else container.insertBefore(dragEl, after); + }); + + // ---- mobile: long-press to lift, drag to reorder, edge auto-scroll ---- + // (native DnD doesn't fire on touch). Hold ~220ms to lift; a scroll-intent + // move before that cancels the lift so the page still scrolls normally. + var holdTimer = null, lifted = false, touchEl = null, startY = 0, scrollSpeed = 0, raf = null; + + function startScroll() { + if (raf) return; + var tick = function () { + if (scrollSpeed !== 0) window.scrollBy(0, scrollSpeed); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + } + function stopScroll() { if (raf) { cancelAnimationFrame(raf); raf = null; } scrollSpeed = 0; } + function endTouch() { + if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } + if (lifted && touchEl) { touchEl.classList.remove('wt-dragging'); persist(container); } + lifted = false; touchEl = null; stopScroll(); + } + + container.querySelectorAll('[data-item-id]').forEach(function (el) { + el.addEventListener('touchstart', function (e) { + if (e.touches.length !== 1) return; + // don't hijack taps on interactive controls + if (e.target.closest('a, button, input, select, textarea, summary, label')) return; + startY = e.touches[0].clientY; + holdTimer = setTimeout(function () { + lifted = true; touchEl = el; el.classList.add('wt-dragging'); + if (navigator.vibrate) navigator.vibrate(12); + startScroll(); + }, 220); + }, { passive: true }); + }); + + container.addEventListener('touchmove', function (e) { + if (!lifted) { + if (holdTimer && Math.abs(e.touches[0].clientY - startY) > 12) { + clearTimeout(holdTimer); holdTimer = null; // moved first → it's a scroll + } + return; + } + if (!touchEl) return; + e.preventDefault(); // we own the gesture; stop the page from scrolling under us + var y = e.touches[0].clientY; + var after = afterElement(container, y); + if (after == null) container.appendChild(touchEl); + else container.insertBefore(touchEl, after); + var edge = 90, vh = window.innerHeight; + if (y < edge) scrollSpeed = -Math.ceil((edge - y) / 5); + else if (y > vh - edge) scrollSpeed = Math.ceil((y - (vh - edge)) / 5); + else scrollSpeed = 0; + }, { passive: false }); + + container.addEventListener('touchend', endTouch); + container.addEventListener('touchcancel', endTouch); + } + + function wire() { + document.querySelectorAll('[data-wt-sortable]').forEach(initSortable); + } + + document.addEventListener('click', function (e) { + var btn = e.target.closest && e.target.closest('[data-copy-link]'); + if (!btn) return; + e.preventDefault(); + var text = btn.getAttribute('data-copy-link'); + var done = function () { + var old = btn.textContent; + btn.textContent = 'Kopieret ✓'; + setTimeout(function () { btn.textContent = old; }, 1600); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, function () { window.prompt('Kopiér linket:', text); }); + } else { + window.prompt('Kopiér linket:', text); + } + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); + else wire(); +})();