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

View file

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

View file

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

View file

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