GrTransfer

  • core
  • forms
  • API · 21 props
  • ⌨ keys

Moves items between two lists — pick from a directory on the left, order what you picked on the right.

import { GrTransfer } from '@feugene/granularity/components/GrTransfer'

Overview

When to take it

  • the set is assembled from a directory — the members of a project, the permissions of a role, the recipients of a mailing: both what is already taken and what is left are visible;
  • the selection is a list in its own right — the right panel shows the whole set rather than “12 selected”;
  • the order of the picked items matters — the columns of a report, the fields of an export: the right panel is reordered, and its order goes into the model;
  • items are picked by the dozen — the search in each panel and the transfer of everything shown turn forty clicks into two;
  • the selection is revised — returning an extra item to the left costs exactly as much as adding one.

When to take something else

NeedTake
Up to a dozen options, all visible at onceGrCheckboxGroup
Space is scarce, the selection need not be shown in fullGrSelect / GrAutocomplete
There is one list and only its order changesGrSortableList
The options are nested in levelsGrTree / GrTreeSelect
Rows of a table are ticked for a bulk actionGrDataTable
There is no directory, the values are typed inGrInputTag

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • Reviewers of a service

    The chosen set has to be read as a whole: «who reviews» is the right column itself, not «2 selected». item-label names the field to show and to search by, item-disabled locks people on leave, and the item slot adds a face and a team. The value is the keys of the right column, and required with invalid sit on it; while saving the whole control is disabled.

    Members0 of 6

    MOMina OrtizPayments
    GWGrace WuPlatform
    JWJonas WeberOn leave this week
    LMLucas MartinPayments
    SLSara LindqvistSupport
    OHOmar HaddadOn leave this week
    Reviewers0 of 2

    DKDaniel KimBilling
    ATAiko TanakaBilling
  • Lines under the invoice totals

    The right column is a sequence, not a set: sortable (on by default) reorders it with Alt and the arrows, draggable adds the pointer on top, and the order is the value. The header slot words the counters for the job. A template already used by sent invoices is readonly — rows can be marked and read, nothing moves.

    Invoice template · footer lines
    Available5 more

    Purchase order
    Payment terms
    Billing contact
    Customer notes
    Reverse-charge notice
    Printed, in order3 of 4 lines fit

    Due date
    IBAN
    VAT number
    Due date · IBAN · VAT number
  • Recipients from a directory of two thousand

    @search sends the query to the server and loading says the answer is on its way, so an empty panel reads «Searching the directory…» instead of «nothing found». Hundreds of rows stay light with virtual inside max-height. filter matches the email as well as the name, and the empty slot tells «no match» from «nothing here».

    October product update · recipients2 chosen from 2,000 contacts
    Directory0 of 398

    Ana Ribeiroana.0@customers.example
    Ben Laurentben.1@customers.example
    Chloé Moreauchloé.2@customers.example
    Elif Yılmazelif.4@customers.example
    Finn Larsenfinn.5@customers.example
    Greta Novakgreta.6@customers.example
    Hugo Schmidthugo.7@customers.example
    Inés Costainés.8@customers.example
    Jakob Garcíajakob.9@customers.example
    Kaito Satokaito.10@customers.example
    Lena Byrnelena.11@customers.example
    Marco Ribeiromarco.12@customers.example
    Nora Laurentnora.13@customers.example
    Oskar Moreauoskar.14@customers.example
    Priya Weberpriya.15@customers.example
    Ana Yılmazana.16@customers.example
    Chloé Novakchloé.18@customers.example
    Diego Schmidtdiego.19@customers.example
    Elif Costaelif.20@customers.example
    Finn Garcíafinn.21@customers.example
    Greta Satogreta.22@customers.example
    Hugo Byrnehugo.23@customers.example
    Inés Ribeiroinés.24@customers.example
    Jakob Laurentjakob.25@customers.example
    Kaito Moreaukaito.26@customers.example
    Lena Weberlena.27@customers.example
    Marco Yılmazmarco.28@customers.example
    Nora Larsennora.29@customers.example
    Oskar Novakoskar.30@customers.example
    Priya Schmidtpriya.31@customers.example
    Ana Costaana.32@customers.example
    Ben Garcíaben.33@customers.example
    Chloé Satochloé.34@customers.example
    Diego Byrnediego.35@customers.example
    Elif Ribeiroelif.36@customers.example
    Finn Laurentfinn.37@customers.example
    Greta Moreaugreta.38@customers.example
    Hugo Weberhugo.39@customers.example
    Inés Yılmazinés.40@customers.example
    Jakob Larsenjakob.41@customers.example
    Kaito Novakkaito.42@customers.example
    Lena Schmidtlena.43@customers.example
    Marco Costamarco.44@customers.example
    Nora Garcíanora.45@customers.example
    Oskar Satooskar.46@customers.example
    Priya Byrnepriya.47@customers.example
    Ana Ribeiroana.48@customers.example
    Ben Laurentben.49@customers.example
    Chloé Moreauchloé.50@customers.example
    Diego Weberdiego.51@customers.example
    Elif Yılmazelif.52@customers.example
    Finn Larsenfinn.53@customers.example
    Greta Novakgreta.54@customers.example
    Hugo Schmidthugo.55@customers.example
    Inés Costainés.56@customers.example
    Jakob Garcíajakob.57@customers.example
    Kaito Satokaito.58@customers.example
    Lena Byrnelena.59@customers.example
    Marco Ribeiromarco.60@customers.example
    Nora Laurentnora.61@customers.example
    Oskar Moreauoskar.62@customers.example
    Priya Weberpriya.63@customers.example
    Ana Yılmazana.64@customers.example
    Ben Larsenben.65@customers.example
    Chloé Novakchloé.66@customers.example
    Diego Schmidtdiego.67@customers.example
    Elif Costaelif.68@customers.example
    Finn Garcíafinn.69@customers.example
    Greta Satogreta.70@customers.example
    Hugo Byrnehugo.71@customers.example
    Inés Ribeiroinés.72@customers.example
    Jakob Laurentjakob.73@customers.example
    Kaito Moreaukaito.74@customers.example
    Lena Weberlena.75@customers.example
    Marco Yılmazmarco.76@customers.example
    Nora Larsennora.77@customers.example
    Oskar Novakoskar.78@customers.example
    Priya Schmidtpriya.79@customers.example
    Ana Costaana.80@customers.example
    Ben Garcíaben.81@customers.example
    Chloé Satochloé.82@customers.example
    Diego Byrnediego.83@customers.example
    Elif Ribeiroelif.84@customers.example
    Finn Laurentfinn.85@customers.example
    Greta Moreaugreta.86@customers.example
    Hugo Weberhugo.87@customers.example
    Inés Yılmazinés.88@customers.example
    Jakob Larsenjakob.89@customers.example
    Kaito Novakkaito.90@customers.example
    Lena Schmidtlena.91@customers.example
    Marco Costamarco.92@customers.example
    Nora Garcíanora.93@customers.example
    Oskar Satooskar.94@customers.example
    Priya Byrnepriya.95@customers.example
    Ana Ribeiroana.96@customers.example
    Ben Laurentben.97@customers.example
    Chloé Moreauchloé.98@customers.example
    Diego Weberdiego.99@customers.example
    Elif Yılmazelif.100@customers.example
    Finn Larsenfinn.101@customers.example
    Greta Novakgreta.102@customers.example
    Hugo Schmidthugo.103@customers.example
    Inés Costainés.104@customers.example
    Jakob Garcíajakob.105@customers.example
    Kaito Satokaito.106@customers.example
    Lena Byrnelena.107@customers.example
    Marco Ribeiromarco.108@customers.example
    Nora Laurentnora.109@customers.example
    Oskar Moreauoskar.110@customers.example
    Priya Weberpriya.111@customers.example
    Ana Yılmazana.112@customers.example
    Ben Larsenben.113@customers.example
    Chloé Novakchloé.114@customers.example
    Diego Schmidtdiego.115@customers.example
    Elif Costaelif.116@customers.example
    Finn Garcíafinn.117@customers.example
    Greta Satogreta.118@customers.example
    Hugo Byrnehugo.119@customers.example
    Inés Ribeiroinés.120@customers.example
    Jakob Laurentjakob.121@customers.example
    Kaito Moreaukaito.122@customers.example
    Lena Weberlena.123@customers.example
    Marco Yılmazmarco.124@customers.example
    Nora Larsennora.125@customers.example
    Oskar Novakoskar.126@customers.example
    Priya Schmidtpriya.127@customers.example
    Ana Costaana.128@customers.example
    Ben Garcíaben.129@customers.example
    Chloé Satochloé.130@customers.example
    Diego Byrnediego.131@customers.example
    Elif Ribeiroelif.132@customers.example
    Finn Laurentfinn.133@customers.example
    Greta Moreaugreta.134@customers.example
    Hugo Weberhugo.135@customers.example
    Inés Yılmazinés.136@customers.example
    Jakob Larsenjakob.137@customers.example
    Kaito Novakkaito.138@customers.example
    Lena Schmidtlena.139@customers.example
    Marco Costamarco.140@customers.example
    Nora Garcíanora.141@customers.example
    Oskar Satooskar.142@customers.example
    Priya Byrnepriya.143@customers.example
    Ana Ribeiroana.144@customers.example
    Ben Laurentben.145@customers.example
    Chloé Moreauchloé.146@customers.example
    Diego Weberdiego.147@customers.example
    Elif Yılmazelif.148@customers.example
    Finn Larsenfinn.149@customers.example
    Greta Novakgreta.150@customers.example
    Hugo Schmidthugo.151@customers.example
    Inés Costainés.152@customers.example
    Jakob Garcíajakob.153@customers.example
    Kaito Satokaito.154@customers.example
    Lena Byrnelena.155@customers.example
    Marco Ribeiromarco.156@customers.example
    Nora Laurentnora.157@customers.example
    Oskar Moreauoskar.158@customers.example
    Priya Weberpriya.159@customers.example
    Ana Yılmazana.160@customers.example
    Ben Larsenben.161@customers.example
    Chloé Novakchloé.162@customers.example
    Diego Schmidtdiego.163@customers.example
    Elif Costaelif.164@customers.example
    Finn Garcíafinn.165@customers.example
    Greta Satogreta.166@customers.example
    Hugo Byrnehugo.167@customers.example
    Inés Ribeiroinés.168@customers.example
    Jakob Laurentjakob.169@customers.example
    Kaito Moreaukaito.170@customers.example
    Lena Weberlena.171@customers.example
    Marco Yılmazmarco.172@customers.example
    Nora Larsennora.173@customers.example
    Oskar Novakoskar.174@customers.example
    Priya Schmidtpriya.175@customers.example
    Ana Costaana.176@customers.example
    Ben Garcíaben.177@customers.example
    Chloé Satochloé.178@customers.example
    Diego Byrnediego.179@customers.example
    Elif Ribeiroelif.180@customers.example
    Finn Laurentfinn.181@customers.example
    Greta Moreaugreta.182@customers.example
    Hugo Weberhugo.183@customers.example
    Inés Yılmazinés.184@customers.example
    Jakob Larsenjakob.185@customers.example
    Kaito Novakkaito.186@customers.example
    Lena Schmidtlena.187@customers.example
    Marco Costamarco.188@customers.example
    Nora Garcíanora.189@customers.example
    Oskar Satooskar.190@customers.example
    Priya Byrnepriya.191@customers.example
    Ana Ribeiroana.192@customers.example
    Ben Laurentben.193@customers.example
    Chloé Moreauchloé.194@customers.example
    Diego Weberdiego.195@customers.example
    Elif Yılmazelif.196@customers.example
    Finn Larsenfinn.197@customers.example
    Greta Novakgreta.198@customers.example
    Hugo Schmidthugo.199@customers.example
    Inés Costainés.200@customers.example
    Jakob Garcíajakob.201@customers.example
    Kaito Satokaito.202@customers.example
    Lena Byrnelena.203@customers.example
    Marco Ribeiromarco.204@customers.example
    Nora Laurentnora.205@customers.example
    Oskar Moreauoskar.206@customers.example
    Priya Weberpriya.207@customers.example
    Ana Yılmazana.208@customers.example
    Ben Larsenben.209@customers.example
    Chloé Novakchloé.210@customers.example
    Diego Schmidtdiego.211@customers.example
    Elif Costaelif.212@customers.example
    Finn Garcíafinn.213@customers.example
    Greta Satogreta.214@customers.example
    Hugo Byrnehugo.215@customers.example
    Inés Ribeiroinés.216@customers.example
    Jakob Laurentjakob.217@customers.example
    Kaito Moreaukaito.218@customers.example
    Lena Weberlena.219@customers.example
    Marco Yılmazmarco.220@customers.example
    Nora Larsennora.221@customers.example
    Oskar Novakoskar.222@customers.example
    Priya Schmidtpriya.223@customers.example
    Ana Costaana.224@customers.example
    Ben Garcíaben.225@customers.example
    Chloé Satochloé.226@customers.example
    Diego Byrnediego.227@customers.example
    Elif Ribeiroelif.228@customers.example
    Finn Laurentfinn.229@customers.example
    Greta Moreaugreta.230@customers.example
    Hugo Weberhugo.231@customers.example
    Inés Yılmazinés.232@customers.example
    Jakob Larsenjakob.233@customers.example
    Kaito Novakkaito.234@customers.example
    Lena Schmidtlena.235@customers.example
    Marco Costamarco.236@customers.example
    Nora Garcíanora.237@customers.example
    Oskar Satooskar.238@customers.example
    Priya Byrnepriya.239@customers.example
    Ana Ribeiroana.240@customers.example
    Ben Laurentben.241@customers.example
    Chloé Moreauchloé.242@customers.example
    Diego Weberdiego.243@customers.example
    Elif Yılmazelif.244@customers.example
    Finn Larsenfinn.245@customers.example
    Greta Novakgreta.246@customers.example
    Hugo Schmidthugo.247@customers.example
    Inés Costainés.248@customers.example
    Jakob Garcíajakob.249@customers.example
    Kaito Satokaito.250@customers.example
    Lena Byrnelena.251@customers.example
    Marco Ribeiromarco.252@customers.example
    Nora Laurentnora.253@customers.example
    Oskar Moreauoskar.254@customers.example
    Priya Weberpriya.255@customers.example
    Ana Yılmazana.256@customers.example
    Ben Larsenben.257@customers.example
    Chloé Novakchloé.258@customers.example
    Diego Schmidtdiego.259@customers.example
    Elif Costaelif.260@customers.example
    Finn Garcíafinn.261@customers.example
    Greta Satogreta.262@customers.example
    Hugo Byrnehugo.263@customers.example
    Inés Ribeiroinés.264@customers.example
    Jakob Laurentjakob.265@customers.example
    Kaito Moreaukaito.266@customers.example
    Lena Weberlena.267@customers.example
    Marco Yılmazmarco.268@customers.example
    Nora Larsennora.269@customers.example
    Oskar Novakoskar.270@customers.example
    Priya Schmidtpriya.271@customers.example
    Ana Costaana.272@customers.example
    Ben Garcíaben.273@customers.example
    Chloé Satochloé.274@customers.example
    Diego Byrnediego.275@customers.example
    Elif Ribeiroelif.276@customers.example
    Finn Laurentfinn.277@customers.example
    Greta Moreaugreta.278@customers.example
    Hugo Weberhugo.279@customers.example
    Inés Yılmazinés.280@customers.example
    Jakob Larsenjakob.281@customers.example
    Kaito Novakkaito.282@customers.example
    Lena Schmidtlena.283@customers.example
    Marco Costamarco.284@customers.example
    Nora Garcíanora.285@customers.example
    Oskar Satooskar.286@customers.example
    Priya Byrnepriya.287@customers.example
    Ana Ribeiroana.288@customers.example
    Ben Laurentben.289@customers.example
    Chloé Moreauchloé.290@customers.example
    Diego Weberdiego.291@customers.example
    Elif Yılmazelif.292@customers.example
    Finn Larsenfinn.293@customers.example
    Greta Novakgreta.294@customers.example
    Hugo Schmidthugo.295@customers.example
    Inés Costainés.296@customers.example
    Jakob Garcíajakob.297@customers.example
    Kaito Satokaito.298@customers.example
    Lena Byrnelena.299@customers.example
    Marco Ribeiromarco.300@customers.example
    Nora Laurentnora.301@customers.example
    Oskar Moreauoskar.302@customers.example
    Priya Weberpriya.303@customers.example
    Ana Yılmazana.304@customers.example
    Ben Larsenben.305@customers.example
    Chloé Novakchloé.306@customers.example
    Diego Schmidtdiego.307@customers.example
    Elif Costaelif.308@customers.example
    Finn Garcíafinn.309@customers.example
    Greta Satogreta.310@customers.example
    Hugo Byrnehugo.311@customers.example
    Inés Ribeiroinés.312@customers.example
    Jakob Laurentjakob.313@customers.example
    Kaito Moreaukaito.314@customers.example
    Lena Weberlena.315@customers.example
    Marco Yılmazmarco.316@customers.example
    Nora Larsennora.317@customers.example
    Oskar Novakoskar.318@customers.example
    Priya Schmidtpriya.319@customers.example
    Ana Costaana.320@customers.example
    Ben Garcíaben.321@customers.example
    Chloé Satochloé.322@customers.example
    Diego Byrnediego.323@customers.example
    Elif Ribeiroelif.324@customers.example
    Finn Laurentfinn.325@customers.example
    Greta Moreaugreta.326@customers.example
    Hugo Weberhugo.327@customers.example
    Inés Yılmazinés.328@customers.example
    Jakob Larsenjakob.329@customers.example
    Kaito Novakkaito.330@customers.example
    Lena Schmidtlena.331@customers.example
    Marco Costamarco.332@customers.example
    Nora Garcíanora.333@customers.example
    Oskar Satooskar.334@customers.example
    Priya Byrnepriya.335@customers.example
    Ana Ribeiroana.336@customers.example
    Ben Laurentben.337@customers.example
    Chloé Moreauchloé.338@customers.example
    Diego Weberdiego.339@customers.example
    Elif Yılmazelif.340@customers.example
    Finn Larsenfinn.341@customers.example
    Greta Novakgreta.342@customers.example
    Hugo Schmidthugo.343@customers.example
    Inés Costainés.344@customers.example
    Jakob Garcíajakob.345@customers.example
    Kaito Satokaito.346@customers.example
    Lena Byrnelena.347@customers.example
    Marco Ribeiromarco.348@customers.example
    Nora Laurentnora.349@customers.example
    Oskar Moreauoskar.350@customers.example
    Priya Weberpriya.351@customers.example
    Ana Yılmazana.352@customers.example
    Ben Larsenben.353@customers.example
    Chloé Novakchloé.354@customers.example
    Diego Schmidtdiego.355@customers.example
    Elif Costaelif.356@customers.example
    Finn Garcíafinn.357@customers.example
    Greta Satogreta.358@customers.example
    Hugo Byrnehugo.359@customers.example
    Inés Ribeiroinés.360@customers.example
    Jakob Laurentjakob.361@customers.example
    Kaito Moreaukaito.362@customers.example
    Lena Weberlena.363@customers.example
    Marco Yılmazmarco.364@customers.example
    Nora Larsennora.365@customers.example
    Oskar Novakoskar.366@customers.example
    Priya Schmidtpriya.367@customers.example
    Ana Costaana.368@customers.example
    Ben Garcíaben.369@customers.example
    Chloé Satochloé.370@customers.example
    Diego Byrnediego.371@customers.example
    Elif Ribeiroelif.372@customers.example
    Finn Laurentfinn.373@customers.example
    Greta Moreaugreta.374@customers.example
    Hugo Weberhugo.375@customers.example
    Inés Yılmazinés.376@customers.example
    Jakob Larsenjakob.377@customers.example
    Kaito Novakkaito.378@customers.example
    Lena Schmidtlena.379@customers.example
    Marco Costamarco.380@customers.example
    Nora Garcíanora.381@customers.example
    Oskar Satooskar.382@customers.example
    Priya Byrnepriya.383@customers.example
    Ana Ribeiroana.384@customers.example
    Ben Laurentben.385@customers.example
    Chloé Moreauchloé.386@customers.example
    Diego Weberdiego.387@customers.example
    Elif Yılmazelif.388@customers.example
    Finn Larsenfinn.389@customers.example
    Greta Novakgreta.390@customers.example
    Hugo Schmidthugo.391@customers.example
    Inés Costainés.392@customers.example
    Jakob Garcíajakob.393@customers.example
    Kaito Satokaito.394@customers.example
    Lena Byrnelena.395@customers.example
    Marco Ribeiromarco.396@customers.example
    Nora Laurentnora.397@customers.example
    Oskar Moreauoskar.398@customers.example
    Priya Weberpriya.399@customers.example
    Recipients0 of 2

    Diego Weberdiego.3@customers.example
    Ben Larsenben.17@customers.example

API generated from the package · cannot drift

Props

PropTypedefaultDescription
itemsrequiredTItem[]—The catalogue as a whole: both panels are one layout of it.
modelValuerequiredv-modelGrTransferKey[]—The keys of the right panel **in its order**: the order is the value.
itemKeyGrTransferItemKey<TItem> | undefinedundefinedThe key of an item: the name of a field or a function. The `id` field by default; without a key a temporary one is issued, and `modelValue` will not match it.
itemLabelGrTransferItemLabel<TItem> | undefinedundefinedThe label of a row: the name of a field or a function. The `label` field by default; the search looks by it too.
itemDisabledGrTransferItemFlag<TItem> | undefinedundefinedA forbidden row: the name of a field or a function. The `disabled` field by default; such a row is neither checked nor transferred.
sourceTitlestring | undefinedundefinedThe name of the left panel. It is the accessible name of its `listbox` rather than an ornament.
targetTitlestring | undefinedundefinedThe name of the right panel.
searchableboolean | undefinedtrueA search field in each panel.
filter((item: TItem, query: string) => boolean) | undefinedundefinedA matcher of your own: the built-in one searches by `itemLabel`, while a row may show more.
loadingboolean | undefinedfalseThe catalogue is on its way: an empty panel says "searching" rather than "nothing found". The prop is shared by both panels rather than owned by each: `items` is one catalogue, and both panels are views of it, so while it is travelling both are empty. The loading is started by the consumer on the `search` event, so the flag is held by them too.
loadingTextstring | undefinedundefinedThe text of the indicator. Not set — from the locale (`gr.transfer.loading`).
sortableboolean | undefinedtrueThe right panel can be reordered.
draggableboolean | undefinedundefinedDragging the rows with the pointer. A reinforcement on top of the buttons rather than the contract.
maxHeightstring | number | undefinedundefinedThe height of the panels: both are the same and scroll inside themselves.

Slots

SlotTypeDescription
item
{ item: TItem; side: GrTransferSide; index: number; selected{ item: TItem; side: GrTransferSide; index: number; selected: boolean; disabled: boolean; }
A row of your own. The slot is domain-specific: for the neighbours `item` is a row of the list, here too.
header
{ side: GrTransferSide; title: string; selected: number; tot{ side: GrTransferSide; title: string; selected: number; total: number; shown: number; }
A panel header of your own: the name, the counter, the "select all" mark.
actions
{ toTarget: () => void; toSource: () => void; canToTarget: b{ toTarget: () => void; toSource: () => void; canToTarget: boolean; canToSource: boolean; }
A column of buttons of your own between the panels.
empty{ side: GrTransferSide; filtered: boolean; }An empty panel. `filtered` — empty because of the search rather than in essence.
loading{ side: GrTransferSide; }A panel under loading — instead of the "searching" text.

Events

EventSignatureDescription
update:modelValue(e: "update:modelValue", value: GrTransferKey[]): voidThe new contents of the right panel in its order — after a transfer or a reordering.
change(e: "change", value: GrTransferKey[]): voidThe contents have changed — the same payload as in `update:modelValue`.
transfer(e: "transfer", keys: GrTransferKey[], direction: GrTransferDirection): voidRows have moved: their keys and the direction — by the buttons, a double click, the keyboard or dragging. Arrives after `update:modelValue`.
search(e: "search", query: string, side: GrTransferSide): voidThe search query of the `side` panel has changed — a signal to load the catalogue with a remote search.
focus(e: "focus", event: FocusEvent): voidFocus entered the component. Moving between the panels and the buttons does not count as an event.
blur(e: "blur", event: FocusEvent): voidFocus left the component entirely.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrTransferConfigurableProps, GrTransferProps, GrTransferEmits } from '@feugene/granularity/components/GrTransfer'

Filedist/types/components/GrTransfer/config.d.ts

grTransferConfigconst
const grTransferConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTransfer">;

Filedist/types/components/GrTransfer/defaults.d.ts

GrTransferConfigurablePropsinterface
interface GrTransferConfigurableProps {
    size: GrComponentSize;
    draggable: boolean;
}

Filedist/types/components/GrTransfer/GrTransfer.vue.d.ts

GrTransferPropsinterface
interface GrTransferProps<T extends Record<string, unknown> = Record<string, unknown>> {
    items: T[];
    modelValue: GrTransferKey[];
    itemKey?: GrTransferItemKey<T>;
    itemLabel?: GrTransferItemLabel<T>;
    itemDisabled?: GrTransferItemFlag<T>;
    sourceTitle?: string;
    targetTitle?: string;
    searchable?: boolean;
    filter?: (item: T, query: string) => boolean;
GrTransferEmitsinterface
interface GrTransferEmits {
    (e: 'update:modelValue', value: GrTransferKey[]): void;
    (e: 'change', value: GrTransferKey[]): void;
    (e: 'transfer', keys: GrTransferKey[], direction: GrTransferDirection): void;
    (e: 'search', query: string, side: GrTransferSide): void;
    (e: 'focus', event: FocusEvent): void;
    (e: 'blur', event: FocusEvent): void;
}

Filedist/types/components/GrTransfer/grTransferStyles.d.ts

GrTransferSizetype
type GrTransferSize = GrComponentSize;

Filedist/types/components/GrTransfer/safelist.d.ts

grTransferSafelistconst
const grTransferSafelist: string[];

Filedist/types/components/GrTransfer/transferModel.d.ts

GrTransferKeytype
type GrTransferKey = string | number;
GrTransferSidetype
type GrTransferSide = 'source' | 'target';
GrTransferDirectiontype
type GrTransferDirection = 'toTarget' | 'toSource';

Filedist/types/components/GrTransfer/index.d.ts

GrTransferInstancetype
type GrTransferInstance = ComponentExposed<typeof GrTransferComponent>;

Accessibility

APG pattern

listbox ×2 (roving tabindex)

Usage from the library prose

The value is the keys of the right panel, not the items

items gives the catalogue, modelValue — the keys of the picked items in the order of the right panel. Both panels are one layout of one catalogue: two model arrays would give two sources of truth and the question “what to do when a key is in both”.

It follows from the same that reordering the right panel is an edit of the value: the consumer stores one array rather than a set plus a sort order.

The component does not mutate the input: every edit goes out as a new array, so a “before — after” comparison and the history in the consumer’s store keep working.

A key that is not in items is kept

The catalogue often arrives later than the saved selection. A key from modelValue that nothing in items corresponds to stays in the value and is not drawn — otherwise the very first render would destroy a selection that is intact on the server. In dev the console warns about it, and only once items is already non-empty.

An item without an itemKey gets a temporary key so that selecting one row does not mark them all. Such a key does not survive a reload of the data — that is a warning too, not a solution: set itemKey.

The highlight belongs to the component, the value to the consumer

Marking a row and transferring it are different actions, and the first does not leave the component. A click highlights one row, Ctrl/Cmd adds one at a time, Shift takes a range from the anchor. Shift does not move the anchor: otherwise the range could not be stretched with a repeated press.

After a transfer the highlight is not reset but moves to the receiving panel: Alt+arrow puts the transferred rows into place at once, and returning them is a single press.

The search narrows what is shown, not the value

The filter in a panel touches neither the highlight nor the model. A row that was marked and then hidden by the query stays marked and is transferred together with the rest — otherwise typing into the search field would silently undo work already done.

So that this does not surprise, the counter in the header counts over the whole panel rather than over its visible part, and a transfer is announced with a number.

A Shift range is computed over the visible order: “from here to there” must mean the same thing the person sees.

A directory from the server

The search event gives away the query and the side, items is supplied by the consumer — and loading says the response is still on its way:

vue
<GrTransfer v-model="members" :items="items" :loading="pending" @search="fetch" />

Without the flag an empty panel during a request claimed “nothing found” — although no matches had been looked for yet.

It shows in three places, and each covers a case of its own:

  • an empty panel says “searching” instead of “nothing found”. The states are mutually exclusive;
  • a panel with rows does not lose them: they are real, and replacing them with “searching” is not allowed. There the signal is given by a spinner in the search field (aria-busy from GrInput);
  • the live region announces the loading: swapping one empty text for another does not announce itself, and a screen reader user would get silence.

The text is changed by loadingText, the markup — by the #loading slot (it receives side).

The flag is shared by both panels rather than one per panel: items is one catalogue, and both panels are its views, so while it is on its way both are empty.

Under loading the dev warning about modelValue keys that are not in items is silenced: an incomplete catalogue during a request is the norm. Otherwise a server search, narrowing the catalogue, would trigger the warning on every keystroke.

The transfer buttons are not blocked: they act on the value, and that is known during a request as well.

While the search is on, the order does not change

Alt+arrow in a filtered right panel does not work and says so. “Move down” over a filtered list either jumps over invisible rows or gives an order the user could not have predicted; either is worse than a refusal.

Space marks, Enter transfers

This is the same rule the whole package lives by: Enter and Space are not interchangeable. Hence also the decision not to embed GrSortableList in the right panel — there Space enters the “row taken” mode, and in a list with multiple selection both keys would end up taken twice.

Reordering is therefore not a mode but a command: Alt+arrow moves the whole current highlight at once, which “take and put” cannot do in principle. It is also exposed as buttons in the header of the right panel — the shortcut speeds it up, but by itself it is undiscoverable, and on macOS almost every meta key is taken by the system.

Those buttons are horizontal while the shortcut is vertical, and the divergence is deliberate. The order of the right panel is a sequence, not a position on the screen: it sets the columns of a report and the fields of an export, which are read left to right. Giving the shortcut the same axis is not allowed: in Chrome and Firefox under Windows and Linux Alt+←/→ is “back/forward” in the browser history. In Safari the navigation hangs on Cmd+arrows, but the package ships to everyone, and one platform is not enough here. The divergence from GrTree, where a row is moved by Shift+arrow, is deliberate: here Shift is taken by the range — that is the contract of a list with multiple selection.

The transfer is visible at every step

The gesture has three states, and each has a look of its own.

Take. A row offers itself with a grab cursor before the press, and as soon as the gesture has begun the cursor becomes grabbing — and not only over the row but over the whole component: the hand is busy everywhere, not at one point.

Carry. A preview travels with the cursor: the label of the first row and the number of the rest. It is the answer to the question “where is the item now” — without it the row stayed in place with a pale surface, and the gesture looked as if nothing was happening. Several rows are not listed: a list of ten names under the cursor would cover the very spot the person is aiming at. A dashed frame remains at the vacated place — it shows where the rows were taken from and where they can be returned.

For the duration of the gesture text selection is disabled on the whole document rather than only on the component itself: the selection starts inside it but stretches across the whole page, and a person leading a row would highlight other people’s headings and paragraphs on the way. The previous value is restored on release — the consumer may have set their own.

Put. The receiving panel is highlighted with both a frame and a surface: a frame alone is not enough, on a wide screen it is outside the gaze occupied by the cursor. The insertion point is shown by a line at the edge of the neighbouring row.

The rows that have arrived are highlighted briefly. The static result the transfer shows anyway — the counters change — but the movement itself is not given away by anything: the rows simply appear. Under prefers-reduced-motion the highlight is suppressed by the global clamp, and the same static result remains.

A list that continues past its edge fades its edge. The scrollbar of a panel is invisible on some systems, and without the fade a row cut off in the middle reads as a rendering defect rather than as “scroll”.

Virtualisation of the panels

virtual together with max-height keeps in the DOM only the window of rows around the visible part of the panel: a directory of thousands of permissions is exactly the case a transfer is taken for instead of a multiselect.

The transfer does not suffer from it: it goes by the model rather than by the markup — the buttons transfer what is currently past the edge of the window as well. Only dragging with the pointer is limited: a row can be dropped onto a rendered one, but there is still nothing to point at what is not on the screen.

The keyboard works as before: before moving the focus the panel scrolls the required row into the window and only on the next tick gives it the focus — otherwise the focus would stay put, because an element outside the window does not exist. And if the focused row was carried away by the mouse wheel, the focus goes to the list itself: the key handler hangs there, and had the focus dropped onto body, the keyboard of the panel would have died until a mouse click.

The size of the set and the place in it are reported to a screen reader by aria-setsize and aria-posinset — without them it would announce “the first of twelve” in the middle of a catalogue of two thousand.

A custom header does not take the name of the panel away

The heading of a panel is its accessible name, and #header replaces it entirely. So that the replacement does not leave the panel nameless, with a custom header the name comes from sourceTitle/targetTitle directly rather than as a reference to markup that no longer exists. Checking that by eye is useless: on the screen everything looks right, and only what a screen reader reads is lost.

The transfer buttons are never disabled

When there is nothing to transfer, the button is marked aria-disabled and stays in the tab order. A native disabled would drop the focus into the body of the document right after the last click — and along the way would deprive a screen reader of the possibility to read that the button exists at all.

The limits
  • there is no virtualisation of the panels — the target of a transfer must be rendered. A panel of a thousand rows requires a solution of the consumer’s own;
  • there is no tree inside a panel — the items are flat;
  • there is no asynchronous loading of the directory: the search event gives away the query, and what to do with it is decided by the consumer by changing items.