GrTransfer
Moves items between two lists — pick from a directory on the left, order what you picked on the right.
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
| Need | Take |
|---|---|
| Up to a dozen options, all visible at once | GrCheckboxGroup |
| Space is scarce, the selection need not be shown in full | GrSelect / GrAutocomplete |
| There is one list and only its order changes | GrSortableList |
| The options are nested in levels | GrTree / GrTreeSelect |
| Rows of a table are ticked for a bulk action | GrDataTable |
| There is no directory, the values are typed in | GrInputTag |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrTransfer :items="items" v-model="value" />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-labelnames the field to show and to search by,item-disabledlocks people on leave, and theitemslot adds a face and a team. The value is the keys of the right column, andrequiredwithinvalidsit on it; while saving the whole control isdisabled.Members0 of 6MOMina OrtizPaymentsGWGrace WuPlatformJWJonas WeberOn leave this weekLMLucas MartinPaymentsSLSara LindqvistSupportOHOmar HaddadOn leave this weekReviewers0 of 2DKDaniel KimBillingATAiko TanakaBillingLines under the invoice totals
The right column is a sequence, not a set:
sortable(on by default) reorders it with Alt and the arrows,draggableadds the pointer on top, and the order is the value. Theheaderslot words the counters for the job. A template already used by sent invoices isreadonly— rows can be marked and read, nothing moves.Invoice template · footer linesAvailable5 morePurchase orderPayment termsBilling contactCustomer notesReverse-charge noticePrinted, in order3 of 4 lines fitDue dateIBANVAT numberDue date · IBAN · VAT numberRecipients from a directory of two thousand
@searchsends the query to the server andloadingsays the answer is on its way, so an empty panel reads «Searching the directory…» instead of «nothing found». Hundreds of rows stay light withvirtualinsidemax-height.filtermatches the email as well as the name, and theemptyslot tells «no match» from «nothing here».October product update · recipients2 chosen from 2,000 contacts Directory0 of 398Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]Ana Yılmaz[email protected]Ben Larsen[email protected]Chloé Novakchloé[email protected]Diego Schmidt[email protected]Elif Costa[email protected]Finn García[email protected]Greta Sato[email protected]Hugo Byrne[email protected]Inés Ribeiroiné[email protected]Jakob Laurent[email protected]Kaito Moreau[email protected]Lena Weber[email protected]Marco Yılmaz[email protected]Nora Larsen[email protected]Oskar Novak[email protected]Priya Schmidt[email protected]Ana Costa[email protected]Ben García[email protected]Chloé Satochloé[email protected]Diego Byrne[email protected]Elif Ribeiro[email protected]Finn Laurent[email protected]Greta Moreau[email protected]Hugo Weber[email protected]Inés Yılmaziné[email protected]Jakob Larsen[email protected]Kaito Novak[email protected]Lena Schmidt[email protected]Marco Costa[email protected]Nora García[email protected]Oskar Sato[email protected]Priya Byrne[email protected]Ana Ribeiro[email protected]Ben Laurent[email protected]Chloé Moreauchloé[email protected]Diego Weber[email protected]Elif Yılmaz[email protected]Finn Larsen[email protected]Greta Novak[email protected]Hugo Schmidt[email protected]Inés Costainé[email protected]Jakob García[email protected]Kaito Sato[email protected]Lena Byrne[email protected]Marco Ribeiro[email protected]Nora Laurent[email protected]Oskar Moreau[email protected]Priya Weber[email protected]
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
itemsrequired | TItem[] | — | The catalogue as a whole: both panels are one layout of it. |
modelValuerequiredv-model | GrTransferKey[] | — | The keys of the right panel **in its order**: the order is the value. |
itemKey | GrTransferItemKey<TItem> | undefined | undefined | The 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. |
itemLabel | GrTransferItemLabel<TItem> | undefined | undefined | The label of a row: the name of a field or a function. The `label` field by default; the search looks by it too. |
itemDisabled | GrTransferItemFlag<TItem> | undefined | undefined | A forbidden row: the name of a field or a function. The `disabled` field by default; such a row is neither checked nor transferred. |
sourceTitle | string | undefined | undefined | The name of the left panel. It is the accessible name of its `listbox` rather than an ornament. |
targetTitle | string | undefined | undefined | The name of the right panel. |
searchable | boolean | undefined | true | A search field in each panel. |
filter | ((item: TItem, query: string) => boolean) | undefined | undefined | A matcher of your own: the built-in one searches by `itemLabel`, while a row may show more. |
loading | boolean | undefined | false | The 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. |
loadingText | string | undefined | undefined | The text of the indicator. Not set — from the locale (`gr.transfer.loading`). |
sortable | boolean | undefined | true | The right panel can be reordered. |
draggable | boolean | undefined | undefined | Dragging the rows with the pointer. A reinforcement on top of the buttons rather than the contract. |
maxHeight | string | number | undefined | undefined | The height of the panels: both are the same and scroll inside themselves. |
virtual | boolean | undefined | false | Keep in the DOM only a window of rows around the viewport. Needs `maxHeight`: without it the panel does not scroll, there is no window and nothing to virtualise. Transferring does not suffer from it — it goes by the model rather than by the markup. Dragging with the pointer, however, is limited to the rendered rows: there is still nothing to point at what is not on the screen. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The type size of the rows, the headers, the search and the buttons by the controls scale. `md` by default. |
disabled | boolean | undefined | false | Entirely unavailable: no selection, no transfer, no search. It adds up by "or" with the field and the form. |
readonly | boolean | undefined | false | Read only: rows can be checked, but transferring and reordering are switched off. |
invalid | boolean | undefined | false | A value error: `aria-invalid` on the right panel. It adds up by "or" with `GrFormField`. |
required | boolean | undefined | false | A mandatory field: `aria-required` on the right panel — the value is precisely its contents. |
ariaLabel | string | undefined | undefined | The accessible name of the whole group; the panels are named by their headings. |
Slots
| Slot | Type | Description |
|---|---|---|
item |
| A row of your own. The slot is domain-specific: for the neighbours `item` is a row of the list, here too. |
header |
| A panel header of your own: the name, the counter, the "select all" mark. |
actions |
| 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
| Event | Signature | Description |
|---|---|---|
update:modelValue | (e: "update:modelValue", value: GrTransferKey[]): void | The new contents of the right panel in its order — after a transfer or a reordering. |
change | (e: "change", value: GrTransferKey[]): void | The contents have changed — the same payload as in `update:modelValue`. |
transfer | (e: "transfer", keys: GrTransferKey[], direction: GrTransferDirection): void | Rows 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): void | The search query of the `side` panel has changed — a signal to load the catalogue with a remote search. |
focus | (e: "focus", event: FocusEvent): void | Focus entered the component. Moving between the panels and the buttons does not count as an event. |
blur | (e: "blur", event: FocusEvent): void | Focus 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 type { GrTransferConfigurableProps, GrTransferProps, GrTransferEmits } from '@feugene/granularity/components/GrTransfer'Filedist/types/components/GrTransfer/config.d.ts
const grTransferConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTransfer">;Filedist/types/components/GrTransfer/defaults.d.ts
interface GrTransferConfigurableProps {
size: GrComponentSize;
draggable: boolean;
}Filedist/types/components/GrTransfer/GrTransfer.vue.d.ts
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;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
type GrTransferSize = GrComponentSize;Filedist/types/components/GrTransfer/safelist.d.ts
const grTransferSafelist: string[];Filedist/types/components/GrTransfer/transferModel.d.ts
type GrTransferKey = string | number;type GrTransferSide = 'source' | 'target';type GrTransferDirection = 'toTarget' | 'toSource';Filedist/types/components/GrTransfer/index.d.ts
type GrTransferInstance = ComponentExposed<typeof GrTransferComponent>;Accessibility
APG pattern
listbox ×2 (roving tabindex)
Full contract of the package: docs/keyboard.md ↗
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:
<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-busyfromGrInput); - 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
searchevent gives away the query, and what to do with it is decided by the consumer by changingitems.
Props in this example