Make DragList generic over a key and reorder InputList and ImagesInput through it
This commit is contained in:
parent
fd7b50a7ec
commit
b924523455
4 changed files with 114 additions and 111 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue