Skip to main content
astrocraft-ui/ components · 101

Transfer List

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/transfer-list

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/transfer-list --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/transfer-list --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/transfer-list --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/transfer-list --theme tailwind --bridge lumos

Live demo

TransferList

Two native <select multiple>s, so multi-select, type-ahead and the arrow keys are the platform's. Double-click moves one item; the buttons move the selection or everything. What moved is announced by name — the one thing a screen reader user cannot see happen.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`TransferList.astro``transfer-list` `transfer-list-status`———
`TransferListControls.astro``transfer-list-controls` `transfer-list-move`———
`TransferListPane.astro``transfer-list-label` `transfer-list-pane` `transfer-list-select``data-side`: `source` · `target`—`[disabled]`

Source

What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/transfer-list/TransferList.astro
---
// src/components/ui/forms/transfer-list/TransferList.astro — headless primitive (see ../../README.md).
// Two lists and four buttons: choose some items on the left, move them to the right. Both lists are
// native <select multiple>s (see TransferListPane), so every bit of list behavior — multi-select,
// arrows, type-ahead, labelling, form submission — is the platform's, and this file is only the
// moving and the announcement.
//
// MOVING IS MOVING A DOM NODE. `target.append(option)` takes the <option> out of one select and puts
// it in the other; there is no model to keep in step with the view, because the view IS the model.
//
// SUBMISSION: a <select multiple> submits only its SELECTED options, which is wrong here — what the
// user moved across is the answer, whether or not it is still highlighted. So the script selects
// everything in the target pane as the form submits. That runs on `submit`, not on every move, so
// the target does not sit there looking entirely highlighted while you work.
//
// The status region names what moved. It says the labels and nothing else — no sentence built in
// JavaScript, so there is nothing here to translate.
//
//   <TransferList label="Columns">
//     <TransferListPane side="source" label="Available" options={available} />
//     <TransferListControls />
//     <TransferListPane side="target" label="Shown" name="columns" options={shown} />
//   </TransferList>
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & { label?: string };

const { label = "Transfer list", class: className, ...rest } = Astro.props;
---

<div role="group" aria-label={label} data-slot="transfer-list" class={className} {...rest}>
  <slot />
  <p role="status" aria-live="polite" data-slot="transfer-list-status"></p>
</div>

<script>
  import { onReadyOnce } from "../../_once";

  function wire(root: HTMLElement) {
    const source = root.querySelector<HTMLSelectElement>(
      '[data-slot="transfer-list-select"][data-side="source"]',
    );
    const target = root.querySelector<HTMLSelectElement>(
      '[data-slot="transfer-list-select"][data-side="target"]',
    );
    const status = root.querySelector<HTMLElement>('[data-slot="transfer-list-status"]');
    if (!source || !target) return;

    const move = (from: HTMLSelectElement, to: HTMLSelectElement, all: boolean) => {
      const moving = [...from.options].filter(
        (option) => !option.disabled && (all || option.selected),
      );
      if (moving.length === 0) return;
      for (const option of moving) {
        option.selected = false;
        to.append(option);
      }
      // The labels, not a sentence about them: this is the one thing a screen reader user cannot
      // see happen, and a list of names needs no grammar.
      if (status) status.textContent = moving.map((option) => option.text).join(", ");
      // `change` on both, because either list's contents are now different and a consumer watching
      // one of them should not have to know which side the user pressed.
      for (const select of [from, to]) select.dispatchEvent(new Event("change", { bubbles: true }));
    };

    root.addEventListener("click", (event) => {
      const button = (event.target as Element | null)?.closest<HTMLElement>(
        '[data-slot="transfer-list-move"]',
      );
      const action = button?.dataset.action;
      if (!action) return;
      const adding = action.startsWith("add");
      move(adding ? source : target, adding ? target : source, action.endsWith("all"));
    });

    // Double-click is the shortcut everyone tries on a list like this; it moves that one item.
    source.addEventListener("dblclick", () => move(source, target, false));
    target.addEventListener("dblclick", () => move(target, source, false));

    // Everything in the target is the answer, selected or not — see the header.
    root.closest("form")?.addEventListener("submit", () => {
      for (const option of target.options) option.selected = true;
    });
  }

  onReadyOnce('[data-slot="transfer-list"]', wire);
</script>
src/components/ui/forms/transfer-list/TransferListControls.astro
---
// src/components/ui/forms/transfer-list/TransferListControls.astro — TransferList compound part
// (see ../../README.md). The four move buttons, between the two panes.
//
// They are buttons and not drag handles because dragging between two lists is a pointer-only gesture
// with no keyboard equivalent anyone can discover. The buttons are the accessible route, and they
// work for everyone — which is why the library ships these and no drag at all.
//
// Every label is a prop: these are icon buttons, and an icon button with no accessible name is a
// button that announces as "button". Translate them by passing them.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  addLabel?: string;
  removeLabel?: string;
  addAllLabel?: string;
  removeAllLabel?: string;
};

const {
  addLabel = "Add selected",
  removeLabel = "Remove selected",
  addAllLabel = "Add all",
  removeAllLabel = "Remove all",
  class: className,
  ...rest
} = Astro.props;

const BUTTONS = [
  { action: "add", label: addLabel, glyph: "m9 18 6-6-6-6" },
  { action: "remove", label: removeLabel, glyph: "m15 18-6-6 6-6" },
  { action: "add-all", label: addAllLabel, glyph: "m7 18 6-6-6-6M13 18l6-6-6-6" },
  { action: "remove-all", label: removeAllLabel, glyph: "m17 18-6-6 6-6M11 18l-6-6 6-6" },
] as const;
---

<div role="group" data-slot="transfer-list-controls" class={className} {...rest}>
  {
    BUTTONS.map((button) => (
      <button
        type="button"
        aria-label={button.label}
        data-slot="transfer-list-move"
        data-action={button.action}
      >
        <slot name="icon">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d={button.glyph} />
          </svg>
        </slot>
      </button>
    ))
  }
</div>
src/components/ui/forms/transfer-list/TransferListPane.astro
---
// src/components/ui/forms/transfer-list/TransferListPane.astro — TransferList compound part (see ../../README.md).
// One side of the transfer, and it is a NATIVE <select multiple>. That single choice is most of this
// primitive: a multi-select is already a listbox, already multi-selectable with Shift and Ctrl,
// already keyboard-operable with the arrows and type-ahead, already labelled by its <label>, and —
// the part a hand-built div listbox never manages — already a form control that submits.
//
// `size` makes it a list box rather than a dropdown; below about 4 it becomes a scroller too small
// to drag into.
//
// The `target` pane is the one that carries `name`. See TransferList.astro for how everything in it
// gets submitted, selected or not.
import type { HTMLAttributes } from "astro/types";

interface TransferOption {
  value: string;
  label: string;
  disabled?: boolean;
}

type Props = HTMLAttributes<"select"> & {
  side?: "source" | "target";
  options?: readonly TransferOption[];
  label?: string;
  name?: string;
  /** Rows shown at once — what makes it a list rather than a dropdown. */
  size?: number;
};

const {
  side = "source",
  options = [],
  label,
  name,
  size = 8,
  class: className,
  ...rest
} = Astro.props;

const id = `transfer-${side}-${crypto.randomUUID().slice(0, 8)}`;
---

<div data-slot="transfer-list-pane" data-side={side}>
  {
    label && (
      <label for={id} data-slot="transfer-list-label">
        {label}
      </label>
    )
  }
  <select
    id={id}
    multiple
    size={size}
    name={name}
    data-slot="transfer-list-select"
    data-side={side}
    class={className}
    {...rest}
  >
    {
      options.map((option) => (
        <option value={option.value} disabled={option.disabled}>
          {option.label}
        </option>
      ))
    }
  </select>
</div>
src/components/ui/forms/transfer-list/index.ts
import TransferList from "./TransferList.astro";
import TransferListControls from "./TransferListControls.astro";
import TransferListPane from "./TransferListPane.astro";

export { TransferList, TransferListControls, TransferListPane };
export default TransferList;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/transfer-list — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.