Sortierbare Liste
Dinge in Reihenfolge bringen, per Ziehen, Tastatur oder Knöpfen. Die anderen gleiten zur Seite, jede Bewegung wird für Screenreader angesagt.
Eine von 4 Components in Listen, Bäume und Timelines
Variants
Details
Was Sortierbare Liste mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Listen, Bäume und Timelines
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/sortable-list.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 13 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SortableListProps aus components/ui/sortable-list.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSortableListVariantlistcardsgridcolumns
itemsoptionalSortableItem[]list, cards, grid: the items in their start order
columnsoptionalSortableColumn[]columns: two or more lists to move between
onChangeoptional(order: string[] | Record<string, string[]>) => voidCalled after every drop with the new order (columns: every list)
labeloptionalstringAccessible name of the list
buttonsoptionalbooleancards: show up and down buttons as well, for touch and pointer users
Eingebaute Texte
Diese Texte schreibt Sortierbare Liste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 13 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| grip | Englisch Move | Deutsch Verschieben |
| picked | Englisch picked up. Use the arrow keys to move, Space to drop, Escape to cancel. | Deutsch aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab. |
| moved | Englisch moved to position | Deutsch jetzt an Position |
| of | Englisch of | Deutsch von |
| dropped | Englisch dropped at position | Deutsch abgelegt an Position |
| cancelled | Englisch Move cancelled. | Deutsch Verschieben abgebrochen. |
| instructions | Englisch Press Space to pick up. Arrow keys move, Space drops, Escape cancels. | Deutsch Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab. |
| up | Englisch Move up | Deutsch Nach oben |
| down | Englisch Move down | Deutsch Nach unten |
| cover | Englisch Cover | Deutsch Titelbild |
| to | Englisch to | Deutsch nach |
| moveTo | Englisch Move to | Deutsch Verschieben nach |
| empty | Englisch Drop items here | Deutsch Hierher ziehen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Sortierbare Liste reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Tab und Leertaste.
- Setzt aria-describedby, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 467 Zeilen
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Put things in order by dragging, by keyboard or with the arrow buttons.
* Pointer: hold the grip and drag, the others make room with a short glide.
* Keyboard: Space or Enter on the grip picks the item up, the arrows move it
* (left and right between columns), Space drops it, Escape puts it back.
* Every move is announced in a live region, so a screen reader follows.
* Four looks: a plain list, ranked cards, a grid of pictures with a cover,
* and two columns to move items between.
*/
const TEXTS = {
en: {
grip: 'Move', picked: 'picked up. Use the arrow keys to move, Space to drop, Escape to cancel.', moved: 'moved to position', of: 'of', dropped: 'dropped at position', cancelled: 'Move cancelled.',
instructions: 'Press Space to pick up. Arrow keys move, Space drops, Escape cancels.', up: 'Move up', down: 'Move down', cover: 'Cover', to: 'to', moveTo: 'Move to', empty: 'Drop items here',
},
de: {
grip: 'Verschieben', picked: 'aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.', moved: 'jetzt an Position', of: 'von', dropped: 'abgelegt an Position', cancelled: 'Verschieben abgebrochen.',
instructions: 'Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.', up: 'Nach oben', down: 'Nach unten', cover: 'Titelbild', to: 'nach', moveTo: 'Verschieben nach', empty: 'Hierher ziehen',
},
};
type Texts = (typeof TEXTS)['en'];
export interface SortableItem {
id: string;
label: string;
description?: string;
icon?: IconName;
/** Quiet text at the end, e.g. a count or a duration */
meta?: string;
/** grid: the picture of the tile */
media?: React.ReactNode;
}
export interface SortableColumn {
id: string;
title: string;
items: SortableItem[];
}
export type SortableListVariant = 'list' | 'cards' | 'grid' | 'columns';
export interface SortableListProps {
variant?: SortableListVariant;
/** list, cards, grid: the items in their start order */
items?: SortableItem[];
/** columns: two or more lists to move between */
columns?: SortableColumn[];
/** Called after every drop with the new order (columns: every list) */
onChange?: (order: string[] | Record<string, string[]>) => void;
/** Accessible name of the list */
label?: string;
/** cards: show up and down buttons as well, for touch and pointer users */
buttons?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/** The order lives as ids, the items themselves always come fresh from the props */
type Lists = Record<string, string[]>;
/** Glides elements from their old to their new place after a reorder (FLIP) */
function useFlip(root: React.RefObject<HTMLElement | null>, key: string, skip: string | null) {
const last = useRef(new Map<string, DOMRect>());
useLayoutEffect(() => {
const el = root.current;
if (!el) return;
const nodes = Array.from(el.querySelectorAll<HTMLElement>('[data-flip]'));
const reduce = prefersReducedMotion();
for (const n of nodes) {
const id = n.dataset.flip!;
const now = n.getBoundingClientRect();
const was = last.current.get(id);
last.current.set(id, now);
if (!was || reduce || id === skip || typeof n.animate !== 'function') continue;
const dx = was.left - now.left;
const dy = was.top - now.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
n.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration: 280, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}
}, [root, key, skip]);
}
const GRIP =
'grid shrink-0 cursor-grab touch-none place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color] duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] active:cursor-grabbing';
/**
* Put items in order by drag, keyboard or buttons, with live announcements.
* <SortableList label="Menu order" items={items} onChange={setOrder} />
* <SortableList variant="cards" buttons items={features} />
* <SortableList variant="grid" items={photos} />
* <SortableList variant="columns" columns={[{ id: 'shown', title: 'Shown', items }, { id: 'hidden', title: 'Hidden', items: [] }]} />
*/
export function SortableList({ variant = 'list', items = [], columns, onChange, label, buttons = variant === 'cards', labels, className }: SortableListProps) {
const t = useTexts(TEXTS, labels);
const base = useId();
const source: Lists = variant === 'columns' && columns ? Object.fromEntries(columns.map((c) => [c.id, c.items.map((i) => i.id)])) : { main: items.map((i) => i.id) };
const [lists, setLists] = useState<Lists>(source);
const byId = useMemo(() => new Map([...items, ...(columns ?? []).flatMap((c) => c.items)].map((i) => [i.id, i])), [items, columns]);
const itemsOf = (k: string) => (lists[k] ?? []).map((id) => byId.get(id)).filter(Boolean) as SortableItem[];
// New or removed items in the props: keep the order the person made, add new ones at the end
const sourceKey = JSON.stringify(source);
useEffect(() => {
setLists((cur) => {
const known = new Set(Object.values(source).flat());
const next: Lists = {};
for (const k of Object.keys(source)) next[k] = (cur[k] ?? []).filter((id) => known.has(id));
const placed = new Set(Object.values(next).flat());
for (const k of Object.keys(source)) for (const id of source[k]) if (!placed.has(id)) next[k].push(id);
return JSON.stringify(next) === JSON.stringify(cur) ? cur : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey]);
const order = variant === 'columns' && columns ? columns.map((c) => c.id) : ['main'];
const titles: Record<string, string> = Object.fromEntries((columns ?? []).map((c) => [c.id, c.title]));
const [grabbed, setGrabbed] = useState<{ id: string; from: Lists; mode: 'key' | 'pointer' } | null>(null);
const [said, setSaid] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ id: string; pointerId: number; x: number; y: number; el: HTMLElement; startRect: DOMRect } | null>(null);
const [offset, setOffset] = useState({ x: 0, y: 0 });
// The latest lists for pointer moves that arrive before the next render
const listsRef = useRef(lists);
listsRef.current = lists;
// React moves the row in the DOM on a reorder, which blurs the grip: that blur is not a cancel
const reordering = useRef(false);
const flipKey = order.map((k) => (lists[k] ?? []).join(',')).join('|');
useFlip(rootRef, flipKey, grabbed?.mode === 'pointer' ? grabbed.id : null);
const where = (l: Lists, id: string) => {
for (const k of Object.keys(l)) {
const i = l[k].indexOf(id);
if (i >= 0) return { list: k, index: i };
}
return null;
};
const labelOf = (id: string) => byId.get(id)?.label ?? '';
const commit = (next: Lists) => {
if (variant === 'columns') onChange?.({ ...next });
else onChange?.([...next.main]);
};
const move = useCallback((l: Lists, id: string, toList: string, toIndex: number): Lists => {
const at = where(l, id);
if (!at) return l;
const item = l[at.list][at.index];
const next: Lists = { ...l, [at.list]: l[at.list].filter((x) => x !== id) };
const target = [...(next[toList] ?? [])];
target.splice(Math.max(0, Math.min(toIndex, target.length)), 0, item);
next[toList] = target;
return next;
}, []);
const announce = (id: string, l: Lists, word: string) => {
const at = where(l, id);
if (!at) return;
const inList = variant === 'columns' ? ` ${t.to} ${titles[at.list]}` : '';
setSaid(`${labelOf(id)} ${word} ${at.index + 1} ${t.of} ${l[at.list].length}${inList}.`);
};
/* ---------- keyboard ---------- */
const onGripKey = (e: React.KeyboardEvent, id: string) => {
const k = e.key;
if (!grabbed || grabbed.id !== id) {
if (k === ' ' || k === 'Enter') {
e.preventDefault();
setGrabbed({ id, from: lists, mode: 'key' });
setSaid(`${labelOf(id)} ${t.picked}`);
}
return;
}
const at = where(lists, id)!;
const cols = variant === 'grid' ? gridColumns() : 1;
let next: Lists | null = null;
if (k === 'ArrowUp' || (k === 'ArrowLeft' && variant !== 'columns')) next = move(lists, id, at.list, at.index - (k === 'ArrowUp' && variant === 'grid' ? cols : 1));
else if (k === 'ArrowDown' || (k === 'ArrowRight' && variant !== 'columns')) next = move(lists, id, at.list, at.index + (k === 'ArrowDown' && variant === 'grid' ? cols : 1));
else if ((k === 'ArrowLeft' || k === 'ArrowRight') && variant === 'columns') {
const li = order.indexOf(at.list) + (k === 'ArrowRight' ? 1 : -1);
if (li >= 0 && li < order.length) next = move(lists, id, order[li], at.index);
} else if (k === 'Home') next = move(lists, id, at.list, 0);
else if (k === 'End') next = move(lists, id, at.list, lists[at.list].length);
else if (k === ' ' || k === 'Enter') {
e.preventDefault();
setGrabbed(null);
announce(id, lists, t.dropped);
commit(lists);
return;
} else if (k === 'Escape' || k === 'Tab') {
if (k === 'Escape') e.preventDefault();
setLists(grabbed.from);
setGrabbed(null);
setSaid(t.cancelled);
return;
}
if (next) {
e.preventDefault();
reordering.current = true;
setLists(next);
announce(id, next, t.moved);
// The grip keeps the focus after the item moved
requestAnimationFrame(() => {
document.getElementById(`${base}-grip-${id}`)?.focus();
reordering.current = false;
});
}
};
const gridColumns = () => {
const el = rootRef.current?.querySelector<HTMLElement>('[data-grid]');
return el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1;
};
/* ---------- pointer ---------- */
const onGripDown = (e: React.PointerEvent, id: string) => {
if (e.button !== 0) return;
const el = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-flip]');
if (!el) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
drag.current = { id, pointerId: e.pointerId, x: e.clientX, y: e.clientY, el, startRect: el.getBoundingClientRect() };
setGrabbed({ id, from: lists, mode: 'pointer' });
setOffset({ x: 0, y: 0 });
};
const onGripMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
// The item under the pointer decides the new place; the dragged one follows the pointer
const root = rootRef.current!;
const lists = listsRef.current;
const nodes = Array.from(root.querySelectorAll<HTMLElement>('[data-flip]')).filter((n) => n.dataset.flip !== d.id);
let target: { list: string; index: number } | null = null;
for (const n of nodes) {
const r = n.getBoundingClientRect();
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) continue;
const at = where(lists, n.dataset.flip!)!;
const after = variant === 'grid' ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2;
target = { list: at.list, index: at.index + (after ? 1 : 0) };
break;
}
// Over an empty column, or below the last item of a column
if (!target && variant === 'columns') {
for (const k of order) {
const col = root.querySelector<HTMLElement>(`[data-col="${k}"]`);
const r = col?.getBoundingClientRect();
if (r && e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) target = { list: k, index: lists[k].filter((x) => x !== d.id).length };
}
}
let next = lists;
if (target) {
const at = where(lists, d.id)!;
const idx = target.list === at.list && target.index > at.index ? target.index - 1 : target.index;
if (target.list !== at.list || idx !== at.index) {
next = move(lists, d.id, target.list, idx);
listsRef.current = next;
setLists(next);
}
}
// Translate from where the item sits now in the layout to the pointer
requestAnimationFrame(() => {
const now = root.querySelector<HTMLElement>(`[data-flip="${d.id}"]`);
if (!now || !drag.current) return;
const prevTransform = now.style.transform;
now.style.transform = 'none';
const r = now.getBoundingClientRect();
now.style.transform = prevTransform;
setOffset({ x: d.startRect.left + (e.clientX - d.x) - r.left, y: d.startRect.top + (e.clientY - d.y) - r.top });
});
};
const onGripUp = (e: React.PointerEvent) => {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
drag.current = null;
const now = listsRef.current;
const moved = grabbed && JSON.stringify(grabbed.from) !== JSON.stringify(now);
setGrabbed(null);
setOffset({ x: 0, y: 0 });
if (moved) {
announce(d.id, now, t.dropped);
commit(now);
}
};
const buttonMove = (id: string, dir: -1 | 1) => {
const at = where(lists, id)!;
const next = move(lists, id, at.list, at.index + dir);
setLists(next);
announce(id, next, t.moved);
commit(next);
};
const moveToColumn = (id: string, toList: string) => {
const next = move(lists, id, toList, lists[toList].length);
setLists(next);
announce(id, next, t.moved);
commit(next);
};
/* ---------- one item ---------- */
const grip = (item: SortableItem, cls?: string) => (
<button
type="button"
id={`${base}-grip-${item.id}`}
aria-label={`${t.grip}: ${item.label}`}
aria-describedby={`${base}-help`}
aria-pressed={grabbed?.id === item.id}
onKeyDown={(e) => onGripKey(e, item.id)}
onPointerDown={(e) => onGripDown(e, item.id)}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onPointerCancel={onGripUp}
onBlur={() => !reordering.current && grabbed?.mode === 'key' && grabbed.id === item.id && onGripKey({ key: 'Tab', preventDefault() {} } as React.KeyboardEvent, item.id)}
className={cn(GRIP, cls)}
>
<Icon name="grip" size={16} />
</button>
);
const lifted = (id: string) => grabbed?.id === id;
const liftStyle = (id: string): React.CSSProperties | undefined =>
lifted(id) && grabbed?.mode === 'pointer' ? { transform: `translate(${Math.round(offset.x)}px, ${Math.round(offset.y)}px) scale(1.02)`, zIndex: 20 } : undefined;
const liftClass = (id: string) =>
lifted(id)
? 'relative z-20 shadow-[var(--highlight),var(--shadow-lg)] ring-1 ring-[color-mix(in_oklab,var(--primary)_45%,transparent)]'
: 'relative';
const help = (
<>
<p id={`${base}-help`} className="sr-only">{t.instructions}</p>
<span aria-live="assertive" className="sr-only">{said}</span>
</>
);
/* ---------- grid ---------- */
if (variant === 'grid') {
const list = itemsOf('main');
return (
<div ref={rootRef} className={cn('w-full', className)}>
<ul data-grid aria-label={label} className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{list.map((item, i) => (
<li
key={item.id}
data-flip={item.id}
style={liftStyle(item.id)}
className={cn('group/tile overflow-hidden rounded-xl border border-border bg-card transition-[box-shadow] duration-200', liftClass(item.id), grabbed?.mode !== 'pointer' && 'transition-[box-shadow,transform]')}
>
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
<div className="absolute inset-0 transition-[scale] duration-500 ease-[var(--ease-kit)] group-hover/tile:scale-[1.04] [&>*]:size-full">{item.media}</div>
{i === 0 && (
<span className="absolute start-2 top-2 rounded-md bg-background/90 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-[opacity,scale] duration-300 starting:scale-75 starting:opacity-0">
{t.cover}
</span>
)}
{grip(item, 'absolute end-2 top-2 size-11 bg-background/85 text-foreground shadow-[var(--shadow-sm)] backdrop-blur sm:size-8')}
</div>
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<span className="truncate text-[13px] font-medium text-foreground">{item.label}</span>
<span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{i + 1}</span>
</div>
</li>
))}
</ul>
{help}
</div>
);
}
/* ---------- columns ---------- */
if (variant === 'columns') {
return (
<div ref={rootRef} className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2', className)}>
{order.map((k, ci) => (
<section key={k} aria-labelledby={`${base}-${k}`} className="flex flex-col rounded-xl border border-border bg-card">
<h4 id={`${base}-${k}`} className="flex items-center justify-between border-b border-border px-4 py-3 text-sm font-semibold text-foreground">
{titles[k]}
<span key={itemsOf(k).length} className="rounded-md bg-muted px-1.5 py-0.5 text-[12px] font-medium tabular-nums text-muted-foreground transition-[scale] duration-300 starting:scale-125">
{itemsOf(k).length}
</span>
</h4>
<ul data-col={k} className="flex min-h-[8rem] flex-1 flex-col gap-1.5 p-2">
{itemsOf(k).map((item) => (
<li key={item.id} data-flip={item.id} style={liftStyle(item.id)} className={cn('flex min-h-11 items-center gap-2 rounded-lg border border-border bg-background pe-1.5 ps-1', liftClass(item.id))}>
{grip(item, 'size-11 sm:size-8')}
{item.icon && <Icon name={item.icon} size={16} className="shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{item.label}</span>
{order.length === 2 && (
<button
type="button"
aria-label={`${t.moveTo} ${titles[order[1 - ci]]}: ${item.label}`}
onClick={() => moveToColumn(item.id, order[1 - ci])}
className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:size-8"
>
<Icon name={ci === 0 ? 'arrow-right' : 'arrow-left'} size={15} className="max-sm:rotate-90" />
</button>
)}
</li>
))}
{itemsOf(k).length === 0 && <li className="grid flex-1 place-items-center rounded-lg border border-dashed border-border-strong text-[13px] text-subtle-foreground">{t.empty}</li>}
</ul>
</section>
))}
{help}
</div>
);
}
/* ---------- cards: ranked ---------- */
if (variant === 'cards') {
const list = itemsOf('main');
return (
<div ref={rootRef} className={cn('w-full', className)}>
<ol aria-label={label} className="grid gap-2.5">
{list.map((item, i) => (
<li key={item.id} data-flip={item.id} style={liftStyle(item.id)} className={cn('flex items-center gap-3 rounded-xl border border-border bg-card p-3 pe-2 sm:gap-4 sm:p-4 sm:pe-3', liftClass(item.id))}>
{/* The rank changes with a short roll when the order changes */}
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-sm font-semibold tabular-nums text-primary">
<span key={i} className="transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">{i + 1}</span>
</span>
{item.icon && <Icon name={item.icon} size={18} className="shrink-0 text-muted-foreground max-sm:hidden" />}
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{item.label}</span>
{item.description && <span className="mt-0.5 block text-[13px] text-muted-foreground">{item.description}</span>}
</span>
{buttons && (
<span className="flex shrink-0 flex-col max-sm:hidden">
<button type="button" aria-label={`${t.up}: ${item.label}`} disabled={i === 0} onClick={() => buttonMove(item.id, -1)} className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:opacity-30">
<Icon name="chevron-up" size={15} />
</button>
<button type="button" aria-label={`${t.down}: ${item.label}`} disabled={i === list.length - 1} onClick={() => buttonMove(item.id, 1)} className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:opacity-30">
<Icon name="chevron-down" size={15} />
</button>
</span>
)}
{grip(item, 'size-11 sm:h-14 sm:w-8')}
</li>
))}
</ol>
{help}
</div>
);
}
/* ---------- list ---------- */
const list = itemsOf('main');
return (
<div ref={rootRef} className={cn('w-full', className)}>
<ul aria-label={label} className="rounded-xl border border-border bg-card">
{list.map((item, i) => (
<li
key={item.id}
data-flip={item.id}
style={liftStyle(item.id)}
className={cn('flex min-h-14 items-center gap-3 bg-card pe-4 ps-1.5 first:rounded-t-[inherit] last:rounded-b-[inherit]', i > 0 && 'border-t border-border', liftClass(item.id), lifted(item.id) && 'rounded-lg')}
>
{grip(item, 'size-11 sm:h-10 sm:w-8')}
{item.icon && <Icon name={item.icon} size={17} className="shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1 py-2.5">
<span className="block text-pretty text-sm font-medium text-foreground">{item.label}</span>
{item.description && <span className="line-clamp-2 block text-[13px] text-muted-foreground sm:truncate">{item.description}</span>}
</span>
{item.meta && <span className="shrink-0 text-[13px] tabular-nums text-subtle-foreground">{item.meta}</span>}
</li>
))}
</ul>
{help}
</div>
);
}Häufige Fragen
Wie installiere ich Sortierbare Liste?
Mit aiw add @ai-website/sortable-list. Der Befehl legt die Datei components/ui/sortable-list.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Sortierbare Liste?
4 Varianten, Liste, Rangfolge, Fotoraster und Zwei Spalten. Du wählst sie mit der Prop variant, zum Beispiel variant="grid".
Kann ich die Texte von Sortierbare Liste übersetzen?
Ja. Sortierbare Liste bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Verschieben“, „aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.“ und „jetzt an Position“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Sortierbare Liste einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sortierbare Liste mit aiw_components und installieren sie mit aiw_add @ai-website/sortable-list. Nenn im Prompt die Variante, etwa variant="cards", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Sortierbare Liste ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SortableList-Code zum Kopieren.