{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "transfer",
  "title": "Transfer",
  "type": "registry:ui",
  "description": "Dual-list move-between control. Two columns plus a center pair of move buttons. Optional search, pagination, and one-way mode.",
  "categories": [
    "data-display"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/transfer/transfer.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { ChevronLeft, ChevronRight, GripVertical, Search } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport interface TransferItem {\n  key: string\n  label: string\n  description?: string\n  disabled?: boolean\n}\n\nexport type TransferSide = 'left' | 'right'\n\n/**\n * <uip-transfer> — Double-column transfer control with filtering, pagination,\n * drag-and-drop, and one-way mode.\n */\nexport class UipTransfer extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    dataSource: { type: Array, attribute: false },\n    targetKeys: { type: Array, attribute: false },\n    titles: { type: Array },\n    showSearch: { type: Boolean, attribute: 'show-search' },\n    height: { type: String },\n    pagination: { type: Object },\n    oneWay: { type: Boolean, attribute: 'one-way' },\n    disabled: { type: Boolean },\n    draggable: { type: Boolean },\n    selectable: {\n      type: Boolean,\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n    leftSelectedKeys: { state: true },\n    rightSelectedKeys: { state: true },\n    leftSearch: { state: true },\n    rightSearch: { state: true },\n    leftPage: { state: true },\n    rightPage: { state: true },\n  }\n\n  dataSource: TransferItem[] = []\n  targetKeys: string[] = []\n  titles: [string, string] = ['Source', 'Target']\n  showSearch = false\n  height: number | string = 320\n  pagination: boolean | { pageSize: number } = false\n  oneWay = false\n  disabled = false\n  draggable = false\n  selectable = true\n\n  leftSelectedKeys = new Set<string>()\n  rightSelectedKeys = new Set<string>()\n  leftSearch = ''\n  rightSearch = ''\n  leftPage = 1\n  rightPage = 1\n\n  private draggedKeys: string[] | null = null\n  private draggedFrom: TransferSide | null = null\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'transfer')\n  }\n\n  private get pageSize(): number {\n    if (!this.pagination) return 0\n    if (typeof this.pagination === 'object' && this.pagination.pageSize) {\n      return this.pagination.pageSize\n    }\n    return 10\n  }\n\n  private get targetKeySet(): Set<string> {\n    return new Set(this.targetKeys)\n  }\n\n  private get sourceItems(): TransferItem[] {\n    const targetSet = this.targetKeySet\n    return this.dataSource.filter((item) => !targetSet.has(item.key))\n  }\n\n  private get targetItems(): TransferItem[] {\n    const targetSet = this.targetKeySet\n    return this.dataSource.filter((item) => targetSet.has(item.key))\n  }\n\n  private filterItems(items: TransferItem[], query: string): TransferItem[] {\n    if (!query) return items\n    const q = query.toLowerCase()\n    return items.filter(\n      (item) => item.label.toLowerCase().includes(q) || item.description?.toLowerCase().includes(q),\n    )\n  }\n\n  private moveRight() {\n    if (this.disabled) return\n    const moving = Array.from(this.leftSelectedKeys)\n    if (!moving.length) return\n    const nextTargetKeys = [...this.targetKeys, ...moving]\n    this.targetKeys = nextTargetKeys\n    this.leftSelectedKeys = new Set()\n    this.dispatchChanges(nextTargetKeys, 'right', moving)\n  }\n\n  private moveLeft() {\n    if (this.disabled || this.oneWay) return\n    const moving = Array.from(this.rightSelectedKeys)\n    if (!moving.length) return\n    const movingSet = new Set(moving)\n    const nextTargetKeys = this.targetKeys.filter((k) => !movingSet.has(k))\n    this.targetKeys = nextTargetKeys\n    this.rightSelectedKeys = new Set()\n    this.dispatchChanges(nextTargetKeys, 'left', moving)\n  }\n\n  private dispatchChanges(nextTargetKeys: string[], direction: 'left' | 'right', moved: string[]) {\n    this.dispatchEvent(\n      new CustomEvent('target-keys-change', {\n        detail: { targetKeys: nextTargetKeys },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.dispatchEvent(\n      new CustomEvent('change', {\n        detail: { targetKeys: nextTargetKeys, direction, moved },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  private toggleSelect(side: TransferSide, key: string, itemDisabled?: boolean) {\n    if (this.disabled || itemDisabled) return\n    const isLeft = side === 'left'\n    const current = new Set(isLeft ? this.leftSelectedKeys : this.rightSelectedKeys)\n    if (current.has(key)) current.delete(key)\n    else current.add(key)\n\n    if (isLeft) this.leftSelectedKeys = current\n    else this.rightSelectedKeys = current\n\n    this.dispatchEvent(\n      new CustomEvent('select-change', {\n        detail: {\n          left: Array.from(this.leftSelectedKeys),\n          right: Array.from(this.rightSelectedKeys),\n        },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  private toggleSelectAll(side: TransferSide, items: TransferItem[]) {\n    if (this.disabled) return\n    const isLeft = side === 'left'\n    const available = items.filter((i) => !i.disabled)\n    const current = isLeft ? this.leftSelectedKeys : this.rightSelectedKeys\n    const allSelected = available.length > 0 && available.every((i) => current.has(i.key))\n\n    const next = new Set<string>()\n    if (!allSelected) {\n      available.forEach((i) => next.add(i.key))\n    }\n\n    if (isLeft) this.leftSelectedKeys = next\n    else this.rightSelectedKeys = next\n\n    this.requestUpdate()\n  }\n\n  private renderList(side: TransferSide) {\n    const isLeft = side === 'left'\n    const title = this.titles[isLeft ? 0 : 1] ?? (isLeft ? 'Source' : 'Target')\n    const rawItems = isLeft ? this.sourceItems : this.targetItems\n    const search = isLeft ? this.leftSearch : this.rightSearch\n    const selectedKeys = isLeft ? this.leftSelectedKeys : this.rightSelectedKeys\n    const filtered = this.filterItems(rawItems, search)\n\n    const ps = this.pageSize\n    const curPage = isLeft ? this.leftPage : this.rightPage\n    const totalPages = ps > 0 ? Math.max(1, Math.ceil(filtered.length / ps)) : 1\n    const paged = ps > 0 ? filtered.slice((curPage - 1) * ps, curPage * ps) : filtered\n\n    const available = filtered.filter((i) => !i.disabled)\n    const checkedCount = available.filter((i) => selectedKeys.has(i.key)).length\n    const isAllChecked = available.length > 0 && checkedCount === available.length\n    const isIndeterminate = checkedCount > 0 && !isAllChecked\n\n    const heightStyle = typeof this.height === 'number' ? `${this.height}px` : this.height\n\n    return html`\n      <div\n        class=\"border-border bg-card text-card-foreground flex flex-1 flex-col overflow-hidden rounded-lg border shadow-xs\"\n        style=\"height: ${heightStyle};\"\n      >\n        <!-- Header -->\n        <div class=\"border-border bg-muted/40 flex items-center justify-between border-b px-3 py-2 text-xs\">\n          <label class=\"flex items-center gap-2 cursor-pointer select-none\">\n            ${this.selectable\n              ? html`\n                  <input\n                    type=\"checkbox\"\n                    class=\"rounded border-input text-primary focus:ring-ring\"\n                    .checked=${isAllChecked}\n                    .indeterminate=${isIndeterminate}\n                    ?disabled=${this.disabled || available.length === 0}\n                    @change=${() => this.toggleSelectAll(side, filtered)}\n                  />\n                `\n              : nothing}\n            <span class=\"font-medium\">${title}</span>\n          </label>\n          <span class=\"text-muted-foreground tabular-nums\">\n            ${checkedCount > 0 ? `${checkedCount}/` : ''}${filtered.length}\n          </span>\n        </div>\n\n        <!-- Search -->\n        ${this.showSearch\n          ? html`\n              <div class=\"border-border border-b p-2\">\n                <div class=\"relative\">\n                  ${icon(Search, 'search', 'text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2')}\n                  <input\n                    type=\"text\"\n                    class=\"border-input bg-background focus:border-ring focus:ring-ring/30 h-7 w-full rounded-md pr-2 pl-7 text-xs outline-none focus:ring-2\"\n                    placeholder=\"Search...\"\n                    .value=${search}\n                    @input=${(e: Event) => {\n                      const val = (e.target as HTMLInputElement).value\n                      if (isLeft) this.leftSearch = val\n                      else this.rightSearch = val\n                      this.dispatchEvent(\n                        new CustomEvent('search', {\n                          detail: { direction: side, query: val },\n                          bubbles: true,\n                          composed: true,\n                        }),\n                      )\n                    }}\n                  />\n                </div>\n              </div>\n            `\n          : nothing}\n\n        <!-- List items -->\n        <div\n          class=\"flex-1 overflow-y-auto p-1 text-xs\"\n          @dragover=${(e: DragEvent) => {\n            if (this.draggable && this.draggedFrom && this.draggedFrom !== side) {\n              e.preventDefault()\n            }\n          }}\n          @drop=${(e: DragEvent) => {\n            if (this.draggable && this.draggedFrom && this.draggedFrom !== side) {\n              e.preventDefault()\n              if (isLeft) this.moveLeft()\n              else this.moveRight()\n            }\n          }}\n        >\n          ${paged.length === 0\n            ? html`<div class=\"text-muted-foreground flex h-32 items-center justify-center\">No data</div>`\n            : paged.map((item) => {\n                const isSelected = selectedKeys.has(item.key)\n                return html`\n                  <div\n                    class=${cn(\n                      'hover:bg-muted/60 flex items-center gap-2 rounded px-2 py-1.5 transition-colors cursor-pointer select-none',\n                      isSelected && 'bg-primary/10',\n                      item.disabled && 'cursor-not-allowed opacity-50',\n                    )}\n                    draggable=${this.draggable && !item.disabled ? 'true' : 'false'}\n                    @dragstart=${() => {\n                      this.draggedKeys = [item.key]\n                      this.draggedFrom = side\n                    }}\n                    @click=${() => this.toggleSelect(side, item.key, item.disabled)}\n                  >\n                    ${this.draggable\n                      ? html`<span class=\"text-muted-foreground cursor-grab\">${icon(GripVertical, 'grip-vertical', 'size-3.5')}</span>`\n                      : nothing}\n                    ${this.selectable\n                      ? html`\n                          <input\n                            type=\"checkbox\"\n                            class=\"rounded border-input text-primary focus:ring-ring\"\n                            .checked=${isSelected}\n                            ?disabled=${this.disabled || item.disabled}\n                          />\n                        `\n                      : nothing}\n                    <div class=\"min-w-0 flex-1\">\n                      <p class=\"truncate font-medium\">${item.label}</p>\n                      ${item.description\n                        ? html`<p class=\"text-muted-foreground truncate text-[11px]\">${item.description}</p>`\n                        : nothing}\n                    </div>\n                  </div>\n                `\n              })}\n        </div>\n\n        <!-- Footer / Pagination -->\n        ${ps > 0 && totalPages > 1\n          ? html`\n              <div class=\"border-border bg-muted/20 flex items-center justify-between border-t px-3 py-1.5 text-[11px]\">\n                <button\n                  type=\"button\"\n                  class=\"hover:bg-accent focus-visible:ring-ring rounded p-1 disabled:opacity-40\"\n                  ?disabled=${curPage <= 1}\n                  @click=${() => {\n                    if (isLeft) this.leftPage = Math.max(1, curPage - 1)\n                    else this.rightPage = Math.max(1, curPage - 1)\n                  }}\n                >\n                  ${icon(ChevronLeft, 'chevron-left', 'size-3.5')}\n                </button>\n                <span class=\"text-muted-foreground tabular-nums\">${curPage} / ${totalPages}</span>\n                <button\n                  type=\"button\"\n                  class=\"hover:bg-accent focus-visible:ring-ring rounded p-1 disabled:opacity-40\"\n                  ?disabled=${curPage >= totalPages}\n                  @click=${() => {\n                    if (isLeft) this.leftPage = Math.min(totalPages, curPage + 1)\n                    else this.rightPage = Math.min(totalPages, curPage + 1)\n                  }}\n                >\n                  ${icon(ChevronRight, 'chevron-right', 'size-3.5')}\n                </button>\n              </div>\n            `\n          : nothing}\n        <slot name=${isLeft ? 'footer-left' : 'footer-right'}></slot>\n      </div>\n    `\n  }\n\n  render() {\n    const leftHasSelected = this.leftSelectedKeys.size > 0\n    const rightHasSelected = this.rightSelectedKeys.size > 0\n\n    return html`\n      <div part=\"base\" class=\"flex flex-row items-center gap-3 w-full max-w-2xl\">\n        ${this.renderList('left')}\n\n        <!-- Transfer Actions -->\n        <div class=\"flex flex-col gap-1.5 shrink-0\">\n          <button\n            type=\"button\"\n            class=\"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex size-7 items-center justify-center rounded-md text-xs font-medium shadow-xs transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50\"\n            ?disabled=${this.disabled || !leftHasSelected}\n            aria-label=\"Transfer right\"\n            @click=${this.moveRight}\n          >\n            ${icon(ChevronRight, 'chevron-right', 'size-4')}\n          </button>\n          ${!this.oneWay\n            ? html`\n                <button\n                  type=\"button\"\n                  class=\"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex size-7 items-center justify-center rounded-md text-xs font-medium shadow-xs transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50\"\n                  ?disabled=${this.disabled || !rightHasSelected}\n                  aria-label=\"Transfer left\"\n                  @click=${this.moveLeft}\n                >\n                  ${icon(ChevronLeft, 'chevron-left', 'size-4')}\n                </button>\n              `\n            : nothing}\n        </div>\n\n        ${this.renderList('right')}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-transfer') || customElements.define('uip-transfer', UipTransfer)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-transfer': UipTransfer\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/transfer/transfer.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
