Make DragList generic over a key and reorder InputList and ImagesInput through it

This commit is contained in:
Jon Staab 2026-09-25 22:35:28 -07:00
parent fd7b50a7ec
commit b924523455
4 changed files with 114 additions and 111 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import cx from "classnames"
import {identity} from "@welshman/lib"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
@ -54,6 +55,7 @@
<DragList
class="flex flex-col items-center"
items={$userSpaceUrls}
getKey={identity}
onReorder={reorderSpaceUrls}>
{#snippet item(url)}
<PrimaryNavItemSpace {url} />

View file

@ -1,14 +1,15 @@
<script lang="ts">
<script lang="ts" generics="T">
import cx from "classnames"
import type {Snippet} from "svelte"
import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing"
import {insertAt, removeAt} from "@welshman/lib"
import {equals, insertAt, removeAt} from "@welshman/lib"
type Props = {
items: string[]
onReorder: (items: string[]) => void
item: Snippet<[string]>
items: T[]
getKey: (item: T) => string
onReorder: (items: T[]) => void
item: Snippet<[T]>
class?: string
itemClass?: string
role?: string
@ -17,6 +18,7 @@
const {
items,
getKey,
onReorder,
item,
class: className = "",
@ -25,20 +27,18 @@
itemRole = undefined,
}: Props = $props()
const isSameOrder = (a: string[], b: string[]) =>
a.length === b.length && a.every((item, index) => item === b[index])
const moveTo = (source: string, target: string) => {
const from = order.indexOf(source)
const to = order.indexOf(target)
const keys = order.map(getKey)
const from = keys.indexOf(source)
const to = keys.indexOf(target)
if (from >= 0 && to >= 0 && from !== to) {
preview = insertAt(to, order[from], removeAt(from, order))
}
}
const onDragStart = (e: DragEvent, value: string) => {
dragged = value
const onDragStart = (e: DragEvent, key: string) => {
dragged = key
dropped = false
if (e.dataTransfer) {
@ -50,19 +50,19 @@
e.preventDefault()
}
const onDragEnter = (e: DragEvent, value: string) => {
const onDragEnter = (e: DragEvent, key: string) => {
e.preventDefault()
if (dragged) {
moveTo(dragged, value)
moveTo(dragged, key)
}
}
const onDrop = (e: DragEvent, value: string) => {
const onDrop = (e: DragEvent, key: string) => {
e.preventDefault()
if (dragged) {
moveTo(dragged, value)
moveTo(dragged, key)
}
dragged = undefined
@ -81,8 +81,8 @@
dragged = undefined
}
let preview = $state<string[] | undefined>()
let lastItems = $state<string[]>([])
let preview = $state.raw<T[] | undefined>()
let lastItems = $state.raw<T[]>([])
let dragged = $state<string | undefined>()
let dropped = $state(false)
@ -91,7 +91,7 @@
// The preview outlives the drop, since the caller takes a moment to publish the new order.
$effect(() => {
if (!isSameOrder(items, lastItems)) {
if (!equals(items, lastItems)) {
lastItems = items
preview = undefined
}
@ -99,16 +99,17 @@
</script>
<div class={className} {role}>
{#each order as value (value)}
{#each order as value (getKey(value))}
{@const key = getKey(value)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class={cx("transition-opacity duration-200", itemClass, {"opacity-50": dragged === value})}
class={cx("transition-opacity duration-200", itemClass, {"opacity-50": dragged === key})}
draggable="true"
role={itemRole}
ondragstart={e => onDragStart(e, value)}
ondragstart={e => onDragStart(e, key)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, value)}
ondrop={e => onDrop(e, value)}
ondragenter={e => onDragEnter(e, key)}
ondrop={e => onDrop(e, key)}
ondragend={onDragEnd}>
{@render item(value)}
</div>

View file

@ -1,23 +1,28 @@
<script lang="ts">
import cx from "classnames"
import type {Maybe} from "@welshman/lib"
import {randomId} from "@welshman/lib"
import {removeAt, insertAt} from "@welshman/lib"
import {equals, randomId} from "@welshman/lib"
import {preventDefault, stopPropagation} from "@lib/html"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import DragList from "@lib/components/DragList.svelte"
interface Props {
type Props = {
value: (string | File)[]
multiple?: boolean
}
type Entry = {id: string; value: string | File}
let {value = $bindable(), multiple = true}: Props = $props()
const id = randomId()
const toEntry = (value: string | File) => ({id: randomId(), value})
const getKey = (entry: Entry) => entry.id
const getImageUrl = (item: string | File): string => {
if (typeof item === "string") {
return item
@ -25,15 +30,20 @@
return URL.createObjectURL(item)
}
const setEntries = (newEntries: Entry[]) => {
entries = newEntries
value = newEntries.map(entry => entry.value)
}
const addFiles = (files: FileList | File[]) => {
const newFiles = Array.from(files).filter(file => file.type.startsWith("image/"))
const newEntries = Array.from(files)
.filter(file => file.type.startsWith("image/"))
.map(toEntry)
value = multiple ? [...value, ...newFiles] : newFiles.slice(0, 1)
setEntries(multiple ? [...entries, ...newEntries] : newEntries.slice(0, 1))
}
const removeItem = (index: number) => {
value = removeAt(index, value)
}
const removeItem = (id: string) => setEntries(entries.filter(entry => entry.id !== id))
const onFileChange = (e: Event) => {
const target = e.target as HTMLInputElement
@ -63,55 +73,47 @@
dropActive = false
}
let draggedIndex: Maybe<number> = $state()
// Urls can repeat, so each image carries an id to key it by.
let entries = $state.raw(value.map(toEntry))
let dropActive = $state(false)
const handleDragStart = (e: DragEvent, index: number) => {
draggedIndex = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
$effect(() => {
if (
!equals(
value,
entries.map(entry => entry.value),
)
) {
entries = value.map(toEntry)
}
}
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault()
if (draggedIndex !== undefined && draggedIndex !== index) {
value = insertAt(index, value[draggedIndex], removeAt(draggedIndex, value))
draggedIndex = index
}
}
const handleDragEnd = () => {
draggedIndex = undefined
}
})
</script>
<div class="flex flex-col gap-2">
<div class={cx("grid gap-3", multiple ? "grid-cols-3" : "grid-cols-1")} role="list">
{#each value as item, index (index)}
<div
class={cx(
"border-line bg-surface-more relative overflow-hidden rounded-2xl border border-solid",
multiple ? "aspect-square cursor-move" : "aspect-video",
)}
style:border-color={draggedIndex === index ? "var(--primary)" : undefined}
draggable={multiple}
role="listitem"
aria-label={multiple ? "Draggable image" : "Image"}
ondragstart={e => handleDragStart(e, index)}
ondragover={e => handleDragOver(e, index)}
ondragend={handleDragEnd}>
<img src={getImageUrl(item)} alt="Upload preview" class="h-full w-full object-cover" />
<DragList
class="contents"
itemClass={cx(
"border-line bg-surface-more relative overflow-hidden rounded-2xl border border-solid",
multiple ? "aspect-square cursor-move" : "aspect-video",
)}
itemRole="listitem"
items={entries}
{getKey}
onReorder={setEntries}>
{#snippet item(entry)}
<img
src={getImageUrl(entry.value)}
alt="Upload preview"
class="h-full w-full object-cover" />
<Button
aria-label="Remove image"
class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1"
onclick={() => removeItem(index)}>
onclick={() => removeItem(entry.id)}>
<Icon icon={CloseCircle} size={4} />
</Button>
</div>
{/each}
{/snippet}
</DragList>
{#if multiple || value.length === 0}
<label
for={id}

View file

@ -1,8 +1,8 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Maybe} from "@welshman/lib"
import {append, removeAt, replaceAt, insertAt} from "@welshman/lib"
import {append, equals, randomId} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import DragList from "@lib/components/DragList.svelte"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
@ -14,71 +14,69 @@
placeholder?: string
}
type Entry = {id: string; value: string}
let {
value = $bindable(),
addLabel,
placeholder = "Enter text...",
allowAdd = true,
}: Props & {allowAdd?: boolean} = $props()
let draggedIndex: Maybe<number> = $state()
const onChange = (newValue: string[]) => {
value = newValue
const toEntry = (value: string) => ({id: randomId(), value})
const getKey = (entry: Entry) => entry.id
const setEntries = (newEntries: Entry[]) => {
entries = newEntries
value = newEntries.map(entry => entry.value)
}
const addItem = () => onChange(append("", value))
const addItem = () => setEntries(append(toEntry(""), entries))
const removeItem = (index: number) => onChange(removeAt(index, value))
const removeItem = (id: string) => setEntries(entries.filter(entry => entry.id !== id))
const updateItem = (index: number, item: string) => onChange(replaceAt(index, item, value))
const updateItem = (id: string, text: string) =>
setEntries(entries.map(entry => (entry.id === id ? {id, value: text} : entry)))
const handleDragStart = (e: DragEvent, index: number) => {
draggedIndex = index
// Strings can be empty or repeated, so each one carries an id to key it by.
let entries = $state.raw(value.map(toEntry))
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
$effect(() => {
if (
!equals(
value,
entries.map(entry => entry.value),
)
) {
entries = value.map(toEntry)
}
}
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault()
if (draggedIndex !== undefined && draggedIndex !== index) {
onChange(insertAt(index, value[draggedIndex], removeAt(draggedIndex, value)))
draggedIndex = index
}
}
const handleDragEnd = () => {
draggedIndex = undefined
}
})
</script>
<div class="flex flex-col gap-2" role="list">
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each value as item, index}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
aria-label="Draggable item"
ondragstart={e => handleDragStart(e, index)}
ondragover={e => handleDragOver(e, index)}
ondragend={handleDragEnd}>
<Button onclick={() => removeItem(index)}>
<DragList
class="contents"
itemClass="flex items-center gap-2"
itemRole="listitem"
items={entries}
{getKey}
onReorder={setEntries}>
{#snippet item(entry)}
<Button onclick={() => removeItem(entry.id)}>
<Icon icon={TrashBin2} />
</Button>
<input
type="text"
class="input w-full"
value={item}
value={entry.value}
{placeholder}
oninput={e => updateItem(index, e.currentTarget.value)} />
oninput={e => updateItem(entry.id, e.currentTarget.value)} />
<div class="cursor-move" role="button" aria-label="Drag handle">
<Icon icon={HamburgerMenu} />
</div>
</div>
{/each}
{/snippet}
</DragList>
{#if allowAdd}
<Button class="button button-link w-fit px-0" onclick={addItem}>
<Icon icon={AddCircle} size={5} />