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">
|
<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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue