a list a person reorders by carrying its cards
npx shadcn add @ag/drag-listAlready installed? Preview an update
npx shadcn add @ag/drag-list --dry-runYour installed source stays unchanged until you update it. How updates work
- New: a list a person reorders by carrying its cards, swift-utils' InkDragList on the web, on
carry: one Tab stop, the arrows walking the cards, space carrying, return opening, delete deleting;allowsanswerstrueor the reason a move is refused, which the carried card says in the bad tone while over that place; a list in a page scrolls the page under the card. Controlled:onMove({ item, from, to }). - Its items are cards with
item-card's parts, no longer a tinted fill and a coloured edge:renderItemwrites the parts,tintis the card's hue by the flare rule,materialdraws the list plain or as a pane of glass with its cards inset on it, anddetailopens an item whole (a press that does not travel, or return; esc, a press outside or its ✕ put it back). Its glance fits its tile.
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Backdrop } from "@/registry/base-nova/ui/backdrop"
import { CardFooter } from "@/registry/base-nova/ui/card"
import { DragList, type DragListMove } from "@/registry/base-nova/ui/drag-list"
import { CardAuthor, CardKind } from "@/registry/base-nova/ui/item-card"
import {
type Material,
MaterialProvider,
} from "@/registry/base-nova/ui/material"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// #region checklist
// A release checklist, put in order by carrying its steps, each a card of its kind's
// hue with who does it. Every step goes anywhere. A step pressed without moving opens whole.
type Step = {
id: string
title: string
kind: "build" | "check" | "ship"
who: string
face?: string
note: string
}
const STEPS: Step[] = [
{
id: "notes",
title: "write the release notes",
kind: "check",
who: "Adrian Galilea",
face: "/adriangalilea.jpg",
note: "What changed for the person using it, in their words.",
},
{
id: "bump",
title: "bump the version",
kind: "build",
who: "Ada Lovelace",
note: "The version comes from the commits since the last release.",
},
{
id: "build",
title: "build every platform",
kind: "build",
who: "Ada Lovelace",
note: "The Mac, the phone, the TV: each from the same commit.",
},
{
id: "smoke",
title: "smoke test on a phone",
kind: "check",
who: "Walter Cronkite",
face: "/people/cronkite.jpg",
note: "Install it, open it, play a minute of something.",
},
{
id: "ship",
title: "ship it",
kind: "ship",
who: "Adrian Galilea",
face: "/adriangalilea.jpg",
note: "Once every check is done.",
},
]
const HUE: Record<Step["kind"], string> = {
build: "oklch(68% 0.13 250)",
check: "oklch(72% 0.14 155)",
ship: "oklch(70% 0.16 40)",
}
/** The consumer's own move: the list hands it over, the consumer writes it. */
function moved<T>(items: readonly T[], { from, to }: DragListMove<T>): T[] {
const rest = items.filter((_, i) => i !== from)
return [...rest.slice(0, to), items[from] as T, ...rest.slice(to)]
}
function Checklist({ material }: { material: Material }) {
const [steps, setSteps] = React.useState(STEPS)
return (
<DragList
label="release checklist"
material={material}
className="w-full max-w-sm"
items={steps}
itemId={(s) => s.id}
name={(s) => s.title}
tint={(s) => HUE[s.kind]}
onMove={(move) => setSteps((all) => moved(all, move))}
onDelete={(s) => setSteps((all) => all.filter((x) => x.id !== s.id))}
renderItem={(s) => (
<>
<CardKind>{s.kind}</CardKind>
<p className="font-semibold text-sm">{s.title}</p>
<CardFooter>
<CardAuthor name={s.who} src={s.face} />
</CardFooter>
</>
)}
detail={(s) => (
<>
<CardKind>{s.kind}</CardKind>
<p className="text-pretty text-sm text-current/70">{s.note}</p>
<CardFooter>
<CardAuthor name={s.who} src={s.face} />
</CardFooter>
</>
)}
/>
)
}
/** Plain on the page, or glass over one of two pictures, each with light and dark
* in it: a valley of dark trees under a pale cliff, a snowfield under a blue sky. */
const LOOKS = {
plain: null,
valley: "/lightbox/card.jpg",
snow: "/lightbox/tall.jpg",
} as const
type Look = keyof typeof LOOKS
function Looks() {
const [look, setLook] = React.useState<Look>("plain")
const picture = LOOKS[look]
return (
<div className="w-full space-y-4">
<ToggleGroup
size="sm"
aria-label="material"
value={look}
onValueChange={(v) => setLook(v as Look)}
>
<ToggleGroupItem value="plain">plain</ToggleGroupItem>
<ToggleGroupItem value="valley">glass, valley</ToggleGroupItem>
<ToggleGroupItem value="snow">glass, snow</ToggleGroupItem>
</ToggleGroup>
{picture === null ? (
<Checklist material="plain" />
) : (
<MaterialProvider material="glass">
<Backdrop src={picture} className="w-full rounded-3xl p-6">
<Checklist material="glass" />
</Backdrop>
</MaterialProvider>
)}
</div>
)
}
// #endregion
// #region rule
// A rule, said where it bites: ship stays last. Carry a step below it, or ship above
// any step: the card wears the bad tone and says why; let go there, it flies home and
// leans toward where it was asked to go.
const LAST = ["tag the release", "upload the build", "ship it"]
function ShipLast() {
const [steps, setSteps] = React.useState(LAST)
const ship = (all: readonly string[]) => all.length - 1
return (
<DragList
label="release steps, ship last"
className="w-full max-w-xs"
items={steps}
itemId={(s) => s}
name={(s) => s}
allows={({ item, to }) =>
item === "ship it" || to === ship(steps)
? "ship stays the last step"
: true
}
onMove={(move) => setSteps((all) => moved(all, move))}
renderItem={(s) => <p className="text-sm">{s}</p>}
/>
)
}
// #endregion
// #region page
// A long list in the page: the edge drive scrolls the page itself under the card.
const MANY = Array.from({ length: 24 }, (_, i) => ({
id: `t${i}`,
title: `track ${String(i + 1).padStart(2, "0")}`,
}))
function InThePage() {
const [tracks, setTracks] = React.useState(MANY)
return (
<DragList
label="tracks"
className="w-full max-w-sm"
items={tracks}
itemId={(t) => t.id}
name={(t) => t.title}
onMove={(move) => setTracks((all) => moved(all, move))}
renderItem={(t) => <p className="text-sm tabular-nums">{t.title}</p>}
/>
)
}
// #endregion
export default function DragListDemo() {
return (
<div className="space-y-8">
<Sample
name="checklist"
glance="large"
title="a checklist, carried into order"
description="Press and move a step (a finger: hold it first), or Tab in and press space, move it with the arrows, space to put it down, esc to put it back. A step pressed without moving, or Enter, opens it whole; delete takes it out. Plain or glass."
with="checklist"
>
<Looks />
</Sample>
<Sample
name="rule"
title="a rule, and its refusal said"
description="allows answers true or the reason: carry a step below ship, or ship anywhere."
with="checklist rule"
>
<ShipLast />
</Sample>
<Sample
name="page"
title="a long list in the page"
description="Carry a track toward the window's top or bottom edge: the page scrolls under it, slowly at the zone's edge, faster deeper in, and stops the moment the card leaves the zone."
with="page"
>
<InThePage />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A LIST A PERSON REORDERS by carrying its items, swift-utils' InkDragList on the
// web: each item a `card` on the list's ground, taken up by a press (a mouse once it
// moves, a finger once held), carried with life (it grows a touch from under the
// press, throws its shadow, leans into its travel), the others gliding apart where it
// would land, put down on a spring; near the list's ends (or the page's, for a list in
// a page) the edge drive scrolls under it. The keys carry too: space takes the focused
// card up, the arrows move it, space or return puts it down, esc puts it back; the
// arrows walk the cards otherwise, return opens one, delete deletes it. The whole list
// is one Tab stop. ui/carry is the engine; this is a list's face on it.
//
// A card's look is `card`'s: its hue by the flare rule (a kind's), its parts written
// by `renderItem` (`CardFooter` from card, `CardKind`, `CardAuthor`, `CardCount` from
// item-card). Plain, the
// list is a ground of the fill ladder and its cards the page's; glass, the list is a
// pane of glass and its cards stand on it inset (no glass on glass). A press that never
// moved opens a card: whole (`detail`, the card grown out of its place into one of its
// own), or wherever `onOpen` goes.
//
// CONTROLLED: `onMove` hands the move over and the consumer writes its items; a move
// `allows` refuses (its answer the reason) opens no gap, the carried card wears the bad
// tone with the reason under it while it is over that place, and let go there it flies
// home and leans toward where it was asked to go.
import * as React from "react"
import { cn } from "@/lib/utils"
import { Card } from "@/registry/base-nova/ui/card"
import {
CarryLayer,
HOLDABLE,
KEYED_LIFT,
useCarry,
} from "@/registry/base-nova/ui/carry"
import { CardView, useCardView } from "@/registry/base-nova/ui/item-card"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
/** A move asked of `allows` or made: the item, where it was and where it lands
* (indices in the list as it is, the landing one counted without the item). */
export type DragListMove<T> = { item: T; from: number; to: number }
/** What carries a card (its handle: the focus, the press), round its card: its
* corners the card's, so the keys' lift and its ring follow them. */
export const HANDLE = cn(
HOLDABLE,
KEYED_LIFT,
"cursor-grab rounded-[0.75rem] outline-none focus-visible:ring-2 focus-visible:ring-ring",
)
/** A lane's ground, plain: the region the cards stand in. */
const LANE = "rounded-xl bg-fill-group p-2"
export function DragList<T>({
items,
itemId,
label,
name,
renderItem,
onMove,
allows,
tint,
detail,
onOpen,
onDelete,
material,
className,
}: {
items: readonly T[]
itemId: (item: T) => string
/** The list's accessible name. */
label: string
/** What an item is called: said as it moves, and its title opened whole. */
name: (item: T) => string
/** What a card holds: its parts. */
renderItem: (item: T) => React.ReactNode
onMove: (move: DragListMove<T>) => void
/** Whether a move is allowed: `true`, or the reason it is not ("ship stays last"),
* which the carried card says while over that place, in the bad tone, and leans
* home with when let go there (Ink's InkDragList refusal). A refusal always says
* why: one with no words left the card floating with nothing said. */
allows?: (move: DragListMove<T>) => true | string
/** A card's hue, a CSS colour (its kind's). */
tint?: (item: T) => string | undefined
/** What a card says opened whole: a press that never moved, or return, opens it. */
detail?: (item: T) => React.ReactNode
/** Without `detail`: where a press that never moved, or return, goes. */
onOpen?: (item: T) => void
/** Delete or backspace on the focused card; the consumer takes it out. */
onDelete?: (item: T) => void
material?: Material
className?: string
}) {
const m = useMaterial(material)
const ids = React.useMemo(() => [items.map(itemId)], [items, itemId])
const byId = React.useMemo(
() => new Map(items.map((item) => [itemId(item), item])),
[items, itemId],
)
const of = (id: string) => byId.get(id) as T
const view = useCardView<string>()
const carry = useCarry<string>({
lanes: ids,
refuses: allows
? (id, from, to) => {
const answer = allows({
item: of(id),
from: from.index,
to: to.index,
})
if (answer !== true && (typeof answer !== "string" || answer === ""))
throw new Error(
`DragList: allows answers true or the reason a move is refused, got ${JSON.stringify(answer)}`,
)
return answer === true ? false : answer
}
: undefined,
onMove: (id, from, to) =>
onMove({ item: of(id), from: from.index, to: to.index }),
name: (id) => name(of(id)),
onOpen: detail ? (id) => view.show(id) : onOpen && ((id) => onOpen(of(id))),
onDelete: onDelete && ((id) => onDelete(of(id))),
})
const opened = view.opened === null ? null : byId.get(view.opened)
const list = (
<ul ref={carry.lane(0)} aria-label={label} className="flex flex-col gap-2">
{items.map((item) => {
const id = itemId(item)
return (
<li key={id} {...carry.item(id)} className={HANDLE}>
<Card
hue={tint?.(item)}
padding="compact"
interactive
material={m}
style={view.source(id)}
>
{renderItem(item)}
</Card>
</li>
)
})}
</ul>
)
return (
<div data-slot="drag-list" className={className}>
{m === "glass" ? (
<Card material="glass" bodyClassName="p-2">
{list}
</Card>
) : (
<div className={LANE}>{list}</div>
)}
<CarryLayer
carry={carry}
className="rounded-[0.75rem]"
render={(id) => (
<Card
hue={tint?.(of(id))}
padding="compact"
interactive
material={m}
lifted
>
{renderItem(of(id))}
</Card>
)}
/>
{detail && opened !== undefined && opened !== null && (
<CardView
view={view}
title={name(opened)}
hue={tint?.(opened)}
material={m}
>
{detail(opened)}
</CardView>
)}
</div>
)
}