feat: smooth sortable row interactions
This commit is contained in:
@@ -353,6 +353,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-key-ascii .ui-sortable {
|
.theme-key-ascii .ui-sortable {
|
||||||
|
--sortable-drop-line: #ffffff;
|
||||||
|
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +366,15 @@
|
|||||||
outline: 1px dashed #ffffff;
|
outline: 1px dashed #ffffff;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
|
||||||
|
top: -5px;
|
||||||
|
height: 1px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-key-ascii .ui-sortable-handle {
|
.theme-key-ascii .ui-sortable-handle {
|
||||||
|
|||||||
@@ -236,9 +236,20 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
|
|||||||
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
|
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable {
|
||||||
|
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
.theme-key-light .ui-sortable-item.is-drop-target {
|
.theme-key-light .ui-sortable-item.is-drop-target {
|
||||||
outline-color: color-mix(in srgb, var(--accent) 48%, #0f172a);
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
|
||||||
|
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable-item.is-drop-target::before {
|
||||||
|
background: var(--sortable-drop-line);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Telegram notifications banner: keep the warm warning tint but swap the
|
/* Telegram notifications banner: keep the warm warning tint but swap the
|
||||||
|
|||||||
@@ -1231,6 +1231,16 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
|||||||
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
|
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
|
||||||
|
top: -7px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #000080;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 #ffffff,
|
||||||
|
0 -1px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-key-windows95 .ui-sortable-handle {
|
.theme-key-windows95 .ui-sortable-handle {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { flip } from "svelte/animate";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import { GripVertical } from "./icons.js";
|
import { GripVertical } from "./icons.js";
|
||||||
|
|
||||||
@@ -19,15 +21,37 @@
|
|||||||
|
|
||||||
let dragIndex = null;
|
let dragIndex = null;
|
||||||
let dropIndex = null;
|
let dropIndex = null;
|
||||||
|
$: dragActive = dragIndex !== null;
|
||||||
|
|
||||||
|
const flipConfig = {
|
||||||
|
duration(distance) {
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(220, 110 + distance * 0.35);
|
||||||
|
},
|
||||||
|
easing: cubicOut,
|
||||||
|
};
|
||||||
|
|
||||||
function handleDragStart(event, index) {
|
function handleDragStart(event, index) {
|
||||||
if (disabled) return;
|
if (disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
dragIndex = index;
|
dragIndex = index;
|
||||||
dropIndex = index;
|
dropIndex = index;
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
// Firefox requires data to be set for a drag to start.
|
// Firefox requires data to be set for a drag to start.
|
||||||
event.dataTransfer.setData("text/plain", String(index));
|
event.dataTransfer.setData("text/plain", String(index));
|
||||||
|
const dragRow = event.currentTarget?.closest?.(".ui-sortable-item");
|
||||||
|
if (dragRow) {
|
||||||
|
const rect = dragRow.getBoundingClientRect();
|
||||||
|
event.dataTransfer.setDragImage(dragRow, 18, rect.height / 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +59,7 @@
|
|||||||
if (dragIndex === null) return;
|
if (dragIndex === null) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||||
dropIndex = index;
|
if (dropIndex !== index) dropIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(event, index) {
|
function handleDrop(event, index) {
|
||||||
@@ -52,13 +76,14 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn("ui-sortable", containerClass)} role="list">
|
<div class={cn("ui-sortable", containerClass)} class:is-drag-active={dragActive} role="list">
|
||||||
{#each items as item, index (getKey(item, index))}
|
{#each items as item, index (getKey(item, index))}
|
||||||
<div
|
<div
|
||||||
class={cn("ui-sortable-item", className)}
|
class={cn("ui-sortable-item", className)}
|
||||||
class:is-dragging={dragIndex === index}
|
class:is-dragging={dragIndex === index}
|
||||||
class:is-drop-target={dropIndex === index && dragIndex !== index}
|
class:is-drop-target={dropIndex === index && dragIndex !== index}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
|
animate:flip={flipConfig}
|
||||||
on:dragover={(event) => handleDragOver(event, index)}
|
on:dragover={(event) => handleDragOver(event, index)}
|
||||||
on:drop={(event) => handleDrop(event, index)}
|
on:drop={(event) => handleDrop(event, index)}
|
||||||
on:dragend={reset}
|
on:dragend={reset}
|
||||||
@@ -67,7 +92,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ui-sortable-handle"
|
class="ui-sortable-handle"
|
||||||
draggable={!disabled}
|
draggable={!disabled}
|
||||||
|
{disabled}
|
||||||
aria-label={handleLabel}
|
aria-label={handleLabel}
|
||||||
|
aria-grabbed={dragIndex === index}
|
||||||
title={handleLabel}
|
title={handleLabel}
|
||||||
on:dragstart={(event) => handleDragStart(event, index)}
|
on:dragstart={(event) => handleDragStart(event, index)}
|
||||||
>
|
>
|
||||||
@@ -80,19 +107,54 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ui-sortable {
|
.ui-sortable {
|
||||||
|
--sortable-accent: var(--admin-ring, var(--admin-accent, var(--accent, #4f8cff)));
|
||||||
|
--sortable-drop-soft: color-mix(in srgb, var(--sortable-accent) 10%, transparent);
|
||||||
|
--sortable-drop-line: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--sortable-accent) 78%,
|
||||||
|
var(--admin-text, #ffffff)
|
||||||
|
);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-sortable-item {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.ui-sortable-item.is-dragging {
|
.ui-sortable-item.is-dragging {
|
||||||
opacity: 0.5;
|
opacity: 0.46;
|
||||||
|
transform: scale(0.992);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-sortable-item.is-drop-target {
|
.ui-sortable-item.is-drop-target {
|
||||||
outline: 2px dashed var(--admin-accent, #4f8cff);
|
background: var(--sortable-drop-soft);
|
||||||
outline-offset: 2px;
|
box-shadow:
|
||||||
border-radius: 8px;
|
inset 0 0 0 1px color-mix(in srgb, var(--sortable-accent) 38%, transparent),
|
||||||
|
0 8px 24px color-mix(in srgb, var(--sortable-accent) 8%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-item.is-drop-target::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--sortable-drop-line);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sortable-accent) 16%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-sortable-handle {
|
.ui-sortable-handle {
|
||||||
@@ -103,17 +165,49 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--admin-muted, inherit);
|
color: var(--admin-muted, inherit);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-sortable-handle:hover {
|
.ui-sortable-handle:hover {
|
||||||
|
background: color-mix(in srgb, var(--sortable-accent) 10%, transparent);
|
||||||
|
color: var(--admin-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-handle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sortable-accent) 22%, transparent);
|
||||||
color: var(--admin-text, inherit);
|
color: var(--admin-text, inherit);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-sortable-handle:active {
|
.ui-sortable-handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-handle:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ui-sortable-item,
|
||||||
|
.ui-sortable-handle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-sortable-item.is-dragging,
|
||||||
|
.ui-sortable-item.is-drop-target,
|
||||||
|
.ui-sortable-handle:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -576,6 +576,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"ui-sortable-item",
|
"ui-sortable-item",
|
||||||
"ui-sortable-handle",
|
"ui-sortable-handle",
|
||||||
"is-drop-target",
|
"is-drop-target",
|
||||||
|
"ui-sortable-item.is-drop-target::before",
|
||||||
)
|
)
|
||||||
|
|
||||||
for key in ("light", "ascii", "windows95"):
|
for key in ("light", "ascii", "windows95"):
|
||||||
|
|||||||
Reference in New Issue
Block a user