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

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.