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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/transfer-list/TransferList.astro
---
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);
      }
      if (status) status.textContent = moving.map((option) => option.text).join(", ");
      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"));
    });

    source.addEventListener("dblclick", () => move(source, target, false));
    target.addEventListener("dblclick", () => move(target, source, false));

    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
---
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
---
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;
  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.