{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "transfer",
  "title": "Transfer",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/transfer/transfer.component.ts",
      "content": "import {\n  Component,\n  EventEmitter,\n  Input,\n  OnChanges,\n  Output,\n  TemplateRef,\n  booleanAttribute,\n  forwardRef,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component'\nimport { UiInputComponent } from '@/ui/input/input.component'\nimport { UiScrollAreaComponent } from '@/ui/scroll-area/scroll-area.component'\nimport { UiRenderTemplateDirective } from '@/ui/popper/popper'\n\nexport interface TransferItem {\n  key: string\n  label: string\n  description?: string\n  disabled?: boolean\n}\n\nexport type TransferSide = 'left' | 'right'\n\ninterface TransferDragPayload {\n  keys: string[]\n  fromSide: TransferSide\n}\n\nconst defaultFilter = (q: string, item: TransferItem) => item.label.toLowerCase().includes(q.toLowerCase())\n\n/**\n * Angular port of UIPKGE Transfer, 1:1 with the React component: two TransferList columns\n * (header with select-all checkbox + \"selected/total\", optional search, a role=listbox of\n * items, optional pagination and footer) around a TransferOperation strip of move buttons.\n * Keyboard: arrows / Home / End move between rows, Enter / Space toggle, Ctrl/Cmd+Enter or\n * Alt+Arrow transfer. `draggable` enables HTML5 drag between lists and reordering inside the\n * target; `selectable=false` hides checkboxes for desktop click / Cmd / Shift selection.\n * `targetKeys` / `defaultTargetKeys` / `targetKeysChange` work controlled or uncontrolled.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-transfer, [ui-transfer]',\n  standalone: true,\n  // The parts are declared below this class.\n  imports: [forwardRef(() => UiTransferListComponent), forwardRef(() => UiTransferOperationComponent)],\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'transfer',\n    '[class]': 'hostClass',\n  },\n  template: `\n    <div class=\"min-w-0 flex-1\">\n      <div\n        ui-transfer-list\n        side=\"left\"\n        [title]=\"titles[0]\"\n        [items]=\"sourceItems\"\n        [selected]=\"selectedLeft()\"\n        (selectedChange)=\"onLeftSelected($event)\"\n        (search)=\"search.emit({ direction: 'left', query: $event })\"\n        [footer]=\"footerLeft\"\n      ></div>\n    </div>\n    <div\n      ui-transfer-operation\n      [canMoveRight]=\"selectedLeft().length > 0 && !disabled\"\n      [canMoveLeft]=\"selectedRight().length > 0 && !disabled\"\n      [oneWay]=\"oneWay\"\n      (moveRight)=\"moveRight()\"\n      (moveLeft)=\"moveLeft()\"\n    ></div>\n    <div class=\"min-w-0 flex-1\">\n      <div\n        ui-transfer-list\n        side=\"right\"\n        [title]=\"titles[1]\"\n        [items]=\"targetItems\"\n        [selected]=\"selectedRight()\"\n        (selectedChange)=\"onRightSelected($event)\"\n        (search)=\"search.emit({ direction: 'right', query: $event })\"\n        [footer]=\"footerRight\"\n      ></div>\n    </div>\n  `,\n})\nexport class UiTransferComponent {\n  /** Controlled target keys (pair with `targetKeysChange`). Leave unset for uncontrolled use. */\n  @Input() targetKeys?: string[]\n  /** Uncontrolled initial target keys. */\n  @Input() defaultTargetKeys?: string[]\n  @Input() dataSource: TransferItem[] = []\n  @Input() titles: [string, string] = ['Source', 'Target']\n  @Input({ transform: booleanAttribute }) showSearch = false\n  @Input() filterFn?: (query: string, item: TransferItem) => boolean\n  @Input() height: number | string = 320\n  @Input() pagination: boolean | { pageSize: number } = false\n  @Input({ transform: booleanAttribute }) oneWay = false\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) draggable = false\n  @Input({ transform: booleanAttribute }) selectable = true\n  @Input('class') className?: string\n  /** Footer rendered under the left list (React `footerLeft` node). */\n  @Input() footerLeft?: TemplateRef<unknown> | null\n  /** Footer rendered under the right list (React `footerRight` node). */\n  @Input() footerRight?: TemplateRef<unknown> | null\n\n  /** React `onTargetKeysChange`. */\n  @Output() targetKeysChange = new EventEmitter<string[]>()\n  /**\n   * React `onChange(keys, direction, moved)`. Not named `change`: the native change event of\n   * the search inputs bubbles to this host and would reach a `(change)` listener too.\n   */\n  @Output() transferChange = new EventEmitter<{ keys: string[]; direction: TransferSide; moved: string[] }>()\n  /** React `onSearch`. */\n  @Output() search = new EventEmitter<{ direction: TransferSide; query: string }>()\n  /** React `onSelectChange`. */\n  @Output() selectChange = new EventEmitter<{ left: string[]; right: string[] }>()\n\n  readonly selectedLeft = signal<string[]>([])\n  readonly selectedRight = signal<string[]>([])\n  readonly dragPayload = signal<TransferDragPayload | null>(null)\n  private readonly internalKeys = signal<string[] | null>(null)\n\n  get hostClass(): string {\n    return cn('flex items-stretch gap-3', this.className)\n  }\n\n  get resolvedTargetKeys(): string[] {\n    if (this.targetKeys !== undefined) return this.targetKeys\n    return this.internalKeys() ?? this.defaultTargetKeys ?? []\n  }\n\n  private setTargetKeys(next: string[]): void {\n    if (this.targetKeys === undefined) this.internalKeys.set(next)\n    this.targetKeysChange.emit(next)\n  }\n\n  get filter(): (query: string, item: TransferItem) => boolean {\n    return this.filterFn ?? defaultFilter\n  }\n\n  get pageSize(): number | null {\n    if (this.pagination === false) return null\n    if (this.pagination === true) return 10\n    return this.pagination.pageSize\n  }\n\n  get sourceItems(): TransferItem[] {\n    const keys = this.resolvedTargetKeys\n    return this.dataSource.filter((i) => !keys.includes(i.key))\n  }\n\n  /** When draggable, target order follows targetKeys exactly so reorder persists. */\n  get targetItems(): TransferItem[] {\n    const keys = this.resolvedTargetKeys\n    if (this.draggable) {\n      const map = new Map(this.dataSource.map((i) => [i.key, i]))\n      return keys.map((k) => map.get(k)).filter((i): i is TransferItem => !!i)\n    }\n    return this.dataSource.filter((i) => keys.includes(i.key))\n  }\n\n  startDrag(payload: TransferDragPayload): void {\n    this.dragPayload.set(payload)\n  }\n\n  endDrag(): void {\n    this.dragPayload.set(null)\n  }\n\n  drop(toSide: TransferSide, beforeKey: string | null): void {\n    const payload = this.dragPayload()\n    this.dragPayload.set(null)\n    if (!payload || this.disabled) return\n    const map = new Map(this.dataSource.map((i) => [i.key, i]))\n    const keys = payload.keys.filter((k) => {\n      const item = map.get(k)\n      return item && !item.disabled\n    })\n    if (keys.length === 0) return\n\n    // left -> left: reorder source not supported (parent owns dataSource order). No-op.\n    if (payload.fromSide === 'left' && toSide === 'left') return\n\n    // right -> left: remove from targetKeys (skip when oneWay).\n    if (payload.fromSide === 'right' && toSide === 'left') {\n      if (this.oneWay) return\n      const removeSet = new Set(keys)\n      const next = this.resolvedTargetKeys.filter((k) => !removeSet.has(k))\n      const nextRight = this.selectedRight().filter((k) => !removeSet.has(k))\n      this.selectedRight.set(nextRight)\n      this.setTargetKeys(next)\n      this.transferChange.emit({ keys: next, direction: 'left', moved: keys })\n      this.selectChange.emit({ left: this.selectedLeft(), right: nextRight })\n      return\n    }\n\n    // -> right: insert (cross-list move) or reorder (within-target).\n    const movingSet = new Set(keys)\n    const without = this.resolvedTargetKeys.filter((k) => !movingSet.has(k))\n    let insertAt = without.length\n    if (beforeKey != null) {\n      const idx = without.indexOf(beforeKey)\n      if (idx >= 0) insertAt = idx\n    }\n    const next = [...without.slice(0, insertAt), ...keys, ...without.slice(insertAt)]\n    if (payload.fromSide === 'left') {\n      const nextLeft = this.selectedLeft().filter((k) => !movingSet.has(k))\n      this.selectedLeft.set(nextLeft)\n      this.setTargetKeys(next)\n      this.transferChange.emit({ keys: next, direction: 'right', moved: keys })\n      this.selectChange.emit({ left: nextLeft, right: this.selectedRight() })\n    } else {\n      // right -> right: pure reorder, no change event (target set unchanged).\n      this.setTargetKeys(next)\n    }\n  }\n\n  onLeftSelected(keys: string[]): void {\n    this.selectedLeft.set(keys)\n    this.selectChange.emit({ left: keys, right: this.selectedRight() })\n  }\n\n  onRightSelected(keys: string[]): void {\n    this.selectedRight.set(keys)\n    this.selectChange.emit({ left: this.selectedLeft(), right: keys })\n  }\n\n  /** Move left -> right. Optional keys override the current left selection. */\n  moveRight(keys?: string[]): void {\n    if (this.disabled) return\n    const moved = keys?.length ? [...keys] : [...this.selectedLeft()]\n    if (moved.length === 0) return\n    const current = this.resolvedTargetKeys\n    const next = [...current, ...moved.filter((k) => !current.includes(k))]\n    const nextLeft = this.selectedLeft().filter((k) => !moved.includes(k))\n    this.selectedLeft.set(nextLeft)\n    this.setTargetKeys(next)\n    this.transferChange.emit({ keys: next, direction: 'right', moved })\n    this.selectChange.emit({ left: nextLeft, right: this.selectedRight() })\n  }\n\n  /** Move right -> left. Optional keys override the current right selection. */\n  moveLeft(keys?: string[]): void {\n    if (this.disabled || this.oneWay) return\n    const moved = keys?.length ? [...keys] : [...this.selectedRight()]\n    if (moved.length === 0) return\n    const remove = new Set(moved)\n    const next = this.resolvedTargetKeys.filter((k) => !remove.has(k))\n    const nextRight = this.selectedRight().filter((k) => !remove.has(k))\n    this.selectedRight.set(nextRight)\n    this.setTargetKeys(next)\n    this.transferChange.emit({ keys: next, direction: 'left', moved })\n    this.selectChange.emit({ left: this.selectedLeft(), right: nextRight })\n  }\n}\n\n/** The centre strip of move buttons (React `TransferOperation`). */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-transfer-operation, [ui-transfer-operation]',\n  standalone: true,\n  imports: [UiButtonComponent],\n  host: { class: 'flex flex-col items-center justify-center gap-2 px-2' },\n  template: `\n    <button\n      ui-button\n      size=\"icon-sm\"\n      variant=\"outline\"\n      [disabled]=\"!canMoveRight\"\n      aria-label=\"Move selected to right\"\n      (click)=\"moveRight.emit()\"\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        class=\"lucide lucide-chevron-right\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"m9 18 6-6-6-6\" />\n      </svg>\n    </button>\n    @if (!oneWay) {\n      <button\n        ui-button\n        size=\"icon-sm\"\n        variant=\"outline\"\n        [disabled]=\"!canMoveLeft\"\n        aria-label=\"Move selected to left\"\n        (click)=\"moveLeft.emit()\"\n      >\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          class=\"lucide lucide-chevron-left\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"m15 18-6-6 6-6\" />\n        </svg>\n      </button>\n    }\n  `,\n})\nexport class UiTransferOperationComponent {\n  @Input({ transform: booleanAttribute }) canMoveRight = false\n  @Input({ transform: booleanAttribute }) canMoveLeft = false\n  @Input({ transform: booleanAttribute }) oneWay = false\n  @Output() moveRight = new EventEmitter<void>()\n  @Output() moveLeft = new EventEmitter<void>()\n}\n\n/** One column of a Transfer (React `TransferList`); must sit inside `ui-transfer`. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-transfer-list, [ui-transfer-list]',\n  standalone: true,\n  imports: [UiCheckboxComponent, UiInputComponent, UiScrollAreaComponent, UiRenderTemplateDirective],\n  host: { '[class]': 'hostClass' },\n  template: `\n    <div class=\"bg-muted/40 flex items-center justify-between gap-2 border-b px-3 py-2\">\n      <div class=\"flex min-w-0 items-center gap-2\">\n        @if (ctx.selectable) {\n          <ui-checkbox\n            [attr.aria-label]=\"'Select all in ' + title\"\n            [checked]=\"masterIndeterminate ? 'indeterminate' : masterChecked\"\n            [disabled]=\"ctx.disabled || visibleEnabledKeys.length === 0\"\n            (checkedChange)=\"toggleAll($event === true)\"\n          />\n        }\n        <span class=\"truncate text-sm font-medium\">{{ title }}</span>\n      </div>\n      <span class=\"text-muted-foreground text-xs tabular-nums\"> {{ selected.length }}/{{ items.length }} </span>\n    </div>\n\n    @if (ctx.showSearch) {\n      <div class=\"border-b p-2\">\n        <div class=\"relative\">\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"lucide lucide-search text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"m21 21-4.34-4.34\" />\n            <circle cx=\"11\" cy=\"11\" r=\"8\" />\n          </svg>\n          <ui-input\n            [value]=\"query()\"\n            placeholder=\"Search\"\n            [aria-label]=\"'Search ' + title\"\n            class=\"h-8 pl-8\"\n            (valueChange)=\"handleSearch($any($event))\"\n          />\n        </div>\n      </div>\n    }\n\n    <ui-scroll-area\n      [style.height]=\"heightStyle\"\n      class=\"flex-1\"\n      (dragover)=\"onListDragOver($event)\"\n      (drop)=\"onListDrop($event)\"\n      (dragleave)=\"onListDragLeave($event)\"\n    >\n      <ul role=\"listbox\" [attr.aria-label]=\"title\" aria-multiselectable=\"true\" class=\"py-1\">\n        @for (item of visible; track item.key) {\n          <li\n            role=\"option\"\n            [attr.aria-selected]=\"isSelected(item)\"\n            [attr.aria-disabled]=\"item.disabled || null\"\n            [attr.tabindex]=\"item.disabled || ctx.disabled ? -1 : 0\"\n            [attr.draggable]=\"ctx.draggable && !item.disabled && !ctx.disabled\"\n            [class]=\"itemClass(item)\"\n            (click)=\"onRowClick($event, item)\"\n            (keydown)=\"onRowKeydown($event, item)\"\n            (dragstart)=\"onItemDragStart($event, item)\"\n            (dragend)=\"onItemDragEnd()\"\n            (dragover)=\"onItemDragOver($event, item)\"\n            (drop)=\"onItemDrop($event, item)\"\n          >\n            @if (indicatorFor(item) === 'before') {\n              <span\n                class=\"bg-primary pointer-events-none absolute -top-px right-2 left-2 h-0.5 rounded-full\"\n                aria-hidden=\"true\"\n              ></span>\n            }\n            @if (indicatorFor(item) === 'after') {\n              <span\n                class=\"bg-primary pointer-events-none absolute right-2 -bottom-px left-2 h-0.5 rounded-full\"\n                aria-hidden=\"true\"\n              ></span>\n            }\n            @if (ctx.selectable) {\n              <ui-checkbox\n                [attr.aria-label]=\"'Select ' + (item.label || item.key)\"\n                [checked]=\"isSelected(item)\"\n                [disabled]=\"!!item.disabled || ctx.disabled\"\n                (checkedChange)=\"toggleItem(item, $event === true)\"\n                (click)=\"$event.stopPropagation()\"\n              />\n            }\n            <div class=\"min-w-0 flex-1\">\n              <div class=\"truncate\">{{ item.label }}</div>\n              @if (item.description) {\n                <div class=\"text-muted-foreground truncate text-xs\">{{ item.description }}</div>\n              }\n            </div>\n            @if (ctx.draggable && !item.disabled) {\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"24\"\n                height=\"24\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                class=\"lucide lucide-grip-vertical text-muted-foreground/60 mt-0.5 size-3.5 shrink-0\"\n                aria-hidden=\"true\"\n              >\n                <circle cx=\"9\" cy=\"12\" r=\"1\" />\n                <circle cx=\"9\" cy=\"5\" r=\"1\" />\n                <circle cx=\"9\" cy=\"19\" r=\"1\" />\n                <circle cx=\"15\" cy=\"12\" r=\"1\" />\n                <circle cx=\"15\" cy=\"5\" r=\"1\" />\n                <circle cx=\"15\" cy=\"19\" r=\"1\" />\n              </svg>\n            }\n          </li>\n        }\n        @if (visible.length === 0) {\n          <li class=\"text-muted-foreground px-3 py-6 text-center text-sm\">No items</li>\n        }\n      </ul>\n    </ui-scroll-area>\n\n    @if (ctx.pageSize && totalPages > 1) {\n      <div class=\"flex items-center justify-center gap-2 border-t p-2 text-xs\">\n        <button\n          type=\"button\"\n          class=\"hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n          [disabled]=\"page() <= 1\"\n          [attr.aria-label]=\"'Previous page of ' + title\"\n          (click)=\"prevPage()\"\n        >\n          Prev\n        </button>\n        <span class=\"tabular-nums\" aria-live=\"polite\">{{ page() }} / {{ totalPages }}</span>\n        <button\n          type=\"button\"\n          class=\"hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n          [disabled]=\"page() >= totalPages\"\n          [attr.aria-label]=\"'Next page of ' + title\"\n          (click)=\"nextPage()\"\n        >\n          Next\n        </button>\n      </div>\n    }\n\n    @if (footer) {\n      <div class=\"border-t p-2\"><ng-container [uiRenderTemplate]=\"footer\" /></div>\n    }\n  `,\n})\nexport class UiTransferListComponent implements OnChanges {\n  readonly ctx = inject(UiTransferComponent)\n  @Input() side: TransferSide = 'left'\n  @Input() title = ''\n  @Input() items: TransferItem[] = []\n  @Input() selected: string[] = []\n  @Input() footer?: TemplateRef<unknown> | null\n  /** React `onSelectedChange`. */\n  @Output() selectedChange = new EventEmitter<string[]>()\n  /** React `onSearch`. */\n  @Output() search = new EventEmitter<string>()\n\n  readonly query = signal('')\n  readonly page = signal(1)\n  private lastAnchor: string | null = null\n  readonly dropIndicator = signal<{ key: string; position: 'before' | 'after' } | null>(null)\n  readonly draggingKeys = signal<Set<string>>(new Set())\n\n  ngOnChanges(): void {\n    // React clamps the page in an effect when the list shrinks.\n    if (this.page() > this.totalPages) this.page.set(this.totalPages)\n  }\n\n  get hostClass(): string {\n    const p = this.ctx.dragPayload()\n    return cn(\n      'bg-card flex flex-col overflow-hidden rounded-md border transition-colors',\n      p && this.isDropTarget && 'ring-ring/40 ring-1',\n    )\n  }\n\n  get filtered(): TransferItem[] {\n    const q = this.query()\n    if (!q) return this.items\n    return this.items.filter((i) => this.ctx.filter(q, i))\n  }\n\n  private get effectivePageSize(): number {\n    return this.ctx.pageSize ?? Math.max(1, this.filtered.length)\n  }\n\n  get totalPages(): number {\n    return Math.max(1, Math.ceil(this.filtered.length / this.effectivePageSize))\n  }\n\n  get visible(): TransferItem[] {\n    if (!this.ctx.pageSize) return this.filtered\n    const start = (Math.min(this.page(), this.totalPages) - 1) * this.effectivePageSize\n    return this.filtered.slice(start, start + this.effectivePageSize)\n  }\n\n  get visibleEnabledKeys(): string[] {\n    return this.visible.filter((i) => !i.disabled).map((i) => i.key)\n  }\n\n  isSelected(item: TransferItem): boolean {\n    return this.selected.includes(item.key)\n  }\n\n  get masterChecked(): boolean {\n    const keys = this.visibleEnabledKeys\n    return keys.length > 0 && keys.every((k) => this.selected.includes(k))\n  }\n\n  get masterIndeterminate(): boolean {\n    const keys = this.visibleEnabledKeys\n    const count = keys.filter((k) => this.selected.includes(k)).length\n    return count > 0 && count < keys.length\n  }\n\n  get heightStyle(): string {\n    const h = this.ctx.height\n    return typeof h === 'number' ? h + 'px' : h\n  }\n\n  itemClass(item: TransferItem): string {\n    return cn(\n      'hover:bg-accent focus-visible:ring-ring relative flex min-h-11 cursor-pointer items-start gap-2 px-3 py-3 text-sm select-none focus-visible:ring-2 focus-visible:outline-none',\n      item.disabled && 'cursor-not-allowed opacity-50',\n      this.draggingKeys().has(item.key) && 'opacity-40',\n      !this.ctx.selectable && this.isSelected(item) && 'bg-accent',\n    )\n  }\n\n  indicatorFor(item: TransferItem): 'before' | 'after' | null {\n    const d = this.dropIndicator()\n    return d && d.key === item.key && this.side === 'right' ? d.position : null\n  }\n\n  toggleAll(checked: boolean): void {\n    let next = [...this.selected]\n    const keys = this.visibleEnabledKeys\n    if (checked) {\n      for (const k of keys) if (!next.includes(k)) next.push(k)\n    } else {\n      next = next.filter((k) => !keys.includes(k))\n    }\n    this.selectedChange.emit(next)\n  }\n\n  toggleItem(item: TransferItem, checked: boolean): void {\n    if (item.disabled || this.ctx.disabled) return\n    let next = [...this.selected]\n    if (checked) {\n      if (!next.includes(item.key)) next.push(item.key)\n    } else {\n      next = next.filter((k) => k !== item.key)\n    }\n    this.selectedChange.emit(next)\n    this.lastAnchor = item.key\n  }\n\n  onRowClick(e: MouseEvent, item: TransferItem): void {\n    if (item.disabled || this.ctx.disabled) return\n    // With checkbox visible, click toggles (matches checkbox UX).\n    if (this.ctx.selectable) {\n      this.toggleItem(item, !this.isSelected(item))\n      return\n    }\n    // No checkbox: desktop list pattern -- plain=replace, cmd/ctrl=toggle, shift=range.\n    const enabledKeys = this.visibleEnabledKeys\n    if (e.shiftKey && this.lastAnchor && enabledKeys.includes(this.lastAnchor)) {\n      const start = enabledKeys.indexOf(this.lastAnchor)\n      const end = enabledKeys.indexOf(item.key)\n      const [lo, hi] = start < end ? [start, end] : [end, start]\n      this.selectedChange.emit(enabledKeys.slice(lo, hi + 1))\n      return\n    }\n    if (e.metaKey || e.ctrlKey) {\n      const next = this.isSelected(item) ? this.selected.filter((k) => k !== item.key) : [...this.selected, item.key]\n      this.selectedChange.emit(next)\n      this.lastAnchor = item.key\n      return\n    }\n    this.selectedChange.emit([item.key])\n    this.lastAnchor = item.key\n  }\n\n  private optionRows(from: HTMLElement): HTMLElement[] {\n    const list = from.closest('[role=\"listbox\"]')\n    if (!list) return []\n    return Array.from(list.querySelectorAll<HTMLElement>('[role=\"option\"]:not([aria-disabled=\"true\"])'))\n  }\n\n  /** Keys to transfer: the current multi-selection, or just the focused item. */\n  private keysToTransfer(item: TransferItem): string[] {\n    if (this.isSelected(item) && this.selected.length > 0) {\n      return this.selected.filter((k) => {\n        const i = this.items.find((x) => x.key === k)\n        return i && !i.disabled\n      })\n    }\n    return item.disabled ? [] : [item.key]\n  }\n\n  onRowKeydown(e: KeyboardEvent, item: TransferItem): void {\n    const target = e.currentTarget as HTMLElement\n\n    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n      e.preventDefault()\n      const rows = this.optionRows(target)\n      const idx = rows.indexOf(target)\n      if (idx < 0) return\n      rows[e.key === 'ArrowDown' ? idx + 1 : idx - 1]?.focus()\n      return\n    }\n    if (e.key === 'Home') {\n      e.preventDefault()\n      this.optionRows(target)[0]?.focus()\n      return\n    }\n    if (e.key === 'End') {\n      e.preventDefault()\n      const rows = this.optionRows(target)\n      rows[rows.length - 1]?.focus()\n      return\n    }\n\n    // Ctrl/Cmd+Enter transfers selected items (or the focused item if none selected).\n    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {\n      e.preventDefault()\n      if (item.disabled || this.ctx.disabled) return\n      const keys = this.keysToTransfer(item)\n      if (!keys.length) return\n      if (this.side === 'left') this.ctx.moveRight(keys)\n      else if (!this.ctx.oneWay) this.ctx.moveLeft(keys)\n      return\n    }\n\n    // Alt+Arrow transfers without reaching for the button strip.\n    if (e.key === 'ArrowRight' && e.altKey && this.side === 'left') {\n      e.preventDefault()\n      if (item.disabled || this.ctx.disabled) return\n      const keys = this.keysToTransfer(item)\n      if (keys.length) this.ctx.moveRight(keys)\n      return\n    }\n    if (e.key === 'ArrowLeft' && e.altKey && this.side === 'right' && !this.ctx.oneWay) {\n      e.preventDefault()\n      if (item.disabled || this.ctx.disabled) return\n      const keys = this.keysToTransfer(item)\n      if (keys.length) this.ctx.moveLeft(keys)\n      return\n    }\n\n    if (e.key !== 'Enter' && e.key !== ' ') return\n    e.preventDefault()\n    if (item.disabled || this.ctx.disabled) return\n    if (this.ctx.selectable) {\n      this.toggleItem(item, !this.isSelected(item))\n      return\n    }\n    // Keyboard without modifiers: toggle single-item selection (desktop replace).\n    this.selectedChange.emit(this.isSelected(item) && this.selected.length === 1 ? [] : [item.key])\n    this.lastAnchor = item.key\n  }\n\n  handleSearch(v: string): void {\n    this.query.set(v)\n    this.page.set(1)\n    this.search.emit(v)\n  }\n\n  prevPage(): void {\n    if (this.page() > 1) this.page.update((p) => p - 1)\n  }\n\n  nextPage(): void {\n    if (this.page() < this.totalPages) this.page.update((p) => p + 1)\n  }\n\n  // ----- DnD -----\n\n  get isDropTarget(): boolean {\n    const p = this.ctx.dragPayload()\n    if (!p) return false\n    // left list rejects drops when oneWay; left -> left is a no-op (parent owns dataSource order)\n    if (this.side === 'left' && this.ctx.oneWay && p.fromSide === 'right') return false\n    if (this.side === 'left' && p.fromSide === 'left') return false\n    return true\n  }\n\n  onItemDragStart(e: DragEvent, item: TransferItem): void {\n    if (!this.ctx.draggable || item.disabled || this.ctx.disabled) {\n      e.preventDefault()\n      return\n    }\n    // Dragging a selected row drags the whole selection; otherwise just this row.\n    const keys = this.isSelected(item)\n      ? this.selected.filter((k) => {\n          const i = this.items.find((x) => x.key === k)\n          return i && !i.disabled\n        })\n      : [item.key]\n    this.draggingKeys.set(new Set(keys))\n    this.ctx.startDrag({ keys, fromSide: this.side })\n    if (e.dataTransfer) {\n      e.dataTransfer.effectAllowed = 'move'\n      // Required by Firefox to actually start the drag.\n      try {\n        e.dataTransfer.setData('text/plain', keys.join(','))\n      } catch {\n        /* noop */\n      }\n    }\n  }\n\n  onItemDragEnd(): void {\n    this.draggingKeys.set(new Set())\n    this.dropIndicator.set(null)\n    this.ctx.endDrag()\n  }\n\n  onItemDragOver(e: DragEvent, item: TransferItem): void {\n    if (!this.isDropTarget) return\n    e.preventDefault()\n    if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'\n    // left list: no insertion indicator, drop just removes from target\n    if (this.side !== 'right') return\n    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()\n    const after = e.clientY > rect.top + rect.height / 2\n    this.dropIndicator.set({ key: item.key, position: after ? 'after' : 'before' })\n  }\n\n  onItemDrop(e: DragEvent, item: TransferItem): void {\n    if (!this.isDropTarget) return\n    e.preventDefault()\n    e.stopPropagation()\n    if (this.side === 'right') {\n      const after = this.dropIndicator()?.position === 'after'\n      const idx = this.items.findIndex((x) => x.key === item.key)\n      const beforeKey = after ? (this.items[idx + 1]?.key ?? null) : item.key\n      this.ctx.drop('right', beforeKey)\n    } else {\n      this.ctx.drop('left', null)\n    }\n    this.dropIndicator.set(null)\n  }\n\n  onListDragOver(e: DragEvent): void {\n    if (!this.isDropTarget) return\n    e.preventDefault()\n    if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'\n  }\n\n  onListDrop(e: DragEvent): void {\n    if (!this.isDropTarget) return\n    e.preventDefault()\n    // Empty zone or below all items -> append (right) / remove (left).\n    this.ctx.drop(this.side, null)\n    this.dropIndicator.set(null)\n  }\n\n  onListDragLeave(e: DragEvent): void {\n    // Clear the indicator only when leaving the list container, not when crossing rows.\n    const related = e.relatedTarget as Node | null\n    const current = e.currentTarget as Node\n    if (!related || !current.contains(related)) this.dropIndicator.set(null)\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/transfer/transfer.component.ts"
    },
    {
      "path": "packages/registry-angular/components/transfer/index.ts",
      "content": "export {\n  UiTransferComponent,\n  UiTransferListComponent,\n  UiTransferOperationComponent,\n  type TransferItem,\n  type TransferSide,\n} from './transfer.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/transfer/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/input.json",
    "https://uipkge.dev/r/angular/checkbox.json",
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/scroll-area.json",
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "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"
  ]
}