Display

Two List Transfer

Transfer between two lists where the receiver never collapses into a strip: an empty list keeps its height and an explaining caption, and every row has a transfer button.

  • sortable
  • transfer
  • dual-list
  • a11y

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/sortable-004?lang=en

Available fields4

  • Артикул
  • Поставщик
  • Себестоимость
  • Дата поставки

In the report2

  • Название
  • Остаток
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "sortable-004" (Two List Transfer) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/sortable-004.json

Registry item: https://vibeui.ru/r/sortable-004.json
Installs to: components/vibeui/sortable-004.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
Transfer between two lists where the receiver never collapses into a strip: an empty list keeps its height and an explaining caption, and every row has a transfer button.

Two panels with transfer between them: mouse dragging, an arrow button from the keyboard, counters and an explanation in an empty panel. Zero dependencies, one file.

## 3. How to use it
import { Sortable004 } from "@/components/vibeui/sortable-004"

<Sortable004 source={["SKU", "Supplier"]} target={["Name"]} />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-sortable-004-* palette — do not swap it for your theme tokens
- the minimum panel height: a collapsed target is a target you cannot hit with a mouse
- the caption in an empty panel instead of blankness — it explains what to do next
- the arrow button on every row: dragging is unavailable from a keyboard
- the button label naming both sides of the transfer — a bare "→" says nothing
- the role=status live region reporting the size of both lists after a transfer

## 6. You may change
- the source and target arrays
- sourceTitle and targetTitle — the panel headings
- the onChange handler and the accent through the accent prop
- the block width and the minimum panel height

## 7. Rules
- Order inside a panel does not change: a row is appended to the end of the receiving list.
- The row key is its text: duplicate labels across the two panels are not allowed.
- State lives inside the component: persisting it is the caller's job.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/sortable-004.json
https://vibeui.ru/r/sortable-004.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-sortable-004-*. Клиентский: "use client" ради состояния обоих списков. Панели описаны одним массивом и рисуются общей разметкой, поэтому обе стороны ведут себя одинаково. Минимальная высота панели задана явно, поэтому пустой список остаётся видимой целью для мыши. Кнопка со стрелкой у каждой строки называет обе стороны переноса, живая область role=status сообщает результат и остатки в обоих списках.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties, DragEvent } from "react"

export type Sortable004Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  sourceTitle?: string
  targetTitle?: string
  source?: string[]
  target?: string[]
  onChange?: (lists: { source: string[]; target: string[] }) => void
  accent?: string
}

// Идея компонента: перенос между двумя списками. Главная ошибка такого блока —
// когда список-приёмник пуст и превращается в невидимую полоску: тогда мышью
// некуда целиться, а с клавиатуры непонятно, куда переносят. Поэтому у пустого
// списка есть своя высота и объясняющая надпись, а обе колонки всегда одного
// роста. Перенос мышью — нативный drag на любую точку списка; с клавиатуры —
// кнопка со стрелкой у каждой строки, и она же называет обе стороны переноса.
// Результат и остатки объявляются в живой области.
const STYLES = `
:where([data-vibeui-block="sortable-004"]){
--vibeui-sortable-004-bg:oklch(1 0 0);
--vibeui-sortable-004-panel:oklch(0.985 0.002 265);
--vibeui-sortable-004-fg:oklch(0.24 0.014 265);
--vibeui-sortable-004-muted:oklch(0.56 0.014 265);
--vibeui-sortable-004-border:oklch(0.9 0.006 265);
--vibeui-sortable-004-accent:oklch(0.55 0.2 262);
--vibeui-sortable-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="sortable-004"]{
position:relative;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0.625rem;
width:100%;max-width:30rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-sortable-004-bg);
border:1px solid var(--vibeui-sortable-004-border);border-radius:0.875rem;
font-family:var(--vibeui-sortable-004-font);color:var(--vibeui-sortable-004-fg);
}
[data-vibeui-block="sortable-004"] *{box-sizing:border-box}
/* Обе колонки одного роста: приёмник не должен схлопываться в полоску. */
[data-vibeui-block="sortable-004"] [data-part="panel"]{
display:flex;flex-direction:column;gap:0.5rem;min-width:0;min-height:9.5rem;
padding:0.5rem;border-radius:0.75rem;
background:var(--vibeui-sortable-004-panel);
border:1px dashed var(--vibeui-sortable-004-border);
}
[data-vibeui-block="sortable-004"] [data-part="panel"][data-over="true"]{
border-color:var(--vibeui-sortable-004-accent);border-style:solid;
background:color-mix(in oklab,var(--vibeui-sortable-004-accent) 6%,var(--vibeui-sortable-004-panel));
}
[data-vibeui-block="sortable-004"] [data-part="head"]{
margin:0;display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
font-size:0.75rem;font-weight:650;
}
[data-vibeui-block="sortable-004"] [data-part="count"]{
color:var(--vibeui-sortable-004-muted);font-size:0.6875rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="sortable-004"] ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:0.3125rem}
[data-vibeui-block="sortable-004"] li{
display:flex;align-items:center;gap:0.375rem;cursor:grab;
padding:0.3125rem 0.3125rem 0.3125rem 0.5rem;border-radius:0.5rem;
background:var(--vibeui-sortable-004-bg);
border:1px solid var(--vibeui-sortable-004-border);
font-size:0.75rem;line-height:1.3;
}
[data-vibeui-block="sortable-004"] li[data-dragging="true"]{opacity:.45}
[data-vibeui-block="sortable-004"] [data-part="text"]{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Кнопка переноса — единственный путь с клавиатуры, drag с неё недоступен. */
[data-vibeui-block="sortable-004"] [data-part="send"]{
flex:none;appearance:none;cursor:pointer;
width:1.375rem;height:1.375rem;border-radius:0.375rem;
border:1px solid var(--vibeui-sortable-004-border);
background:var(--vibeui-sortable-004-panel);color:var(--vibeui-sortable-004-muted);
font:inherit;font-size:0.6875rem;line-height:1;
}
[data-vibeui-block="sortable-004"] [data-part="send"]:hover{color:var(--vibeui-sortable-004-accent)}
[data-vibeui-block="sortable-004"] [data-part="send"]:focus-visible{outline:2px solid var(--vibeui-sortable-004-accent);outline-offset:1px}
[data-vibeui-block="sortable-004"] [data-part="empty"]{
margin:auto 0;padding:0.5rem;text-align:center;
color:var(--vibeui-sortable-004-muted);font-size:0.6875rem;line-height:1.35;
}
[data-vibeui-block="sortable-004"] [data-part="live"]{
position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
clip-path:inset(50%);white-space:nowrap;border:0;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="sortable-004"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_SOURCE = [
  "Артикул",
  "Поставщик",
  "Себестоимость",
  "Дата поставки",
]

const DEFAULT_TARGET = ["Название", "Остаток"]

/**
 * Перенос между двумя списками: мышью — drag, с клавиатуры — кнопка со стрелкой.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Sortable004({
  sourceTitle = "Доступные поля",
  targetTitle = "В отчёте",
  source = DEFAULT_SOURCE,
  target = DEFAULT_TARGET,
  onChange,
  accent,
  className,
  style,
  ...props
}: Sortable004Props) {
  const [left, setLeft] = useState(source)
  const [right, setRight] = useState(target)
  const [dragged, setDragged] = useState<string | null>(null)
  const [over, setOver] = useState<"left" | "right" | null>(null)
  const [announcement, setAnnouncement] = useState("")

  const send = (row: string, to: "left" | "right") => {
    const nextLeft =
      to === "left"
        ? left.includes(row)
          ? left
          : [...left, row]
        : left.filter((entry) => entry !== row)
    const nextRight =
      to === "right"
        ? right.includes(row)
          ? right
          : [...right, row]
        : right.filter((entry) => entry !== row)

    if (nextLeft.length === left.length && nextRight.length === right.length) {
      return
    }

    setLeft(nextLeft)
    setRight(nextRight)
    onChange?.({ source: nextLeft, target: nextRight })
    setAnnouncement(
      `«${row}» перенесено в «${to === "left" ? sourceTitle : targetTitle}». ${sourceTitle}: ${nextLeft.length}, ${targetTitle}: ${nextRight.length}.`,
    )
  }

  const drop = (event: DragEvent<HTMLElement>, to: "left" | "right") => {
    event.preventDefault()
    setOver(null)
    if (dragged) send(dragged, to)
    setDragged(null)
  }

  const palette = {
    ...(accent ? { "--vibeui-sortable-004-accent": accent } : null),
    ...style,
  } as CSSProperties

  const panels = [
    { side: "left" as const, title: sourceTitle, rows: left, arrow: "→" },
    { side: "right" as const, title: targetTitle, rows: right, arrow: "←" },
  ]

  return (
    <>
      <style href="vibeui-sortable-004" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="sortable-004"
        className={className}
        style={palette}
      >
        {panels.map((panel) => (
          <section
            key={panel.side}
            data-part="panel"
            data-over={panel.side === over}
            aria-label={`${panel.title}: ${panel.rows.length}`}
            onDragOver={(event) => {
              event.preventDefault()
              setOver(panel.side)
            }}
            onDragLeave={() => setOver(null)}
            onDrop={(event) => drop(event, panel.side)}
          >
            <p data-part="head">
              {panel.title}
              <span data-part="count">{panel.rows.length}</span>
            </p>
            {panel.rows.length === 0 ? (
              <p data-part="empty">
                Пусто. Перетащите поле сюда или нажмите стрелку в соседнем
                списке.
              </p>
            ) : (
              <ul>
                {panel.rows.map((row) => (
                  <li
                    key={row}
                    draggable
                    data-dragging={row === dragged}
                    onDragStart={() => setDragged(row)}
                    onDragEnd={() => {
                      setDragged(null)
                      setOver(null)
                    }}
                  >
                    <span data-part="text">{row}</span>
                    <button
                      type="button"
                      data-part="send"
                      aria-label={`Перенести «${row}» из «${panel.title}» в «${panel.side === "left" ? targetTitle : sourceTitle}»`}
                      onClick={() =>
                        send(row, panel.side === "left" ? "right" : "left")
                      }
                    >
                      {panel.arrow}
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </section>
        ))}
        <span data-part="live" role="status" aria-live="polite">
          {announcement}
        </span>
      </div>
    </>
  )
}