GrTransfer
Берут, когда состав набирают из справочника.
Обзор
Когда брать
- состав набирают из справочника — участники проекта, права роли, получатели рассылки: видно и то, что уже взято, и то, что осталось;
- выбранное само по себе список — правая панель показывает состав целиком, а не «выбрано 12»;
- порядок отобранного значим — колонки отчёта, поля выгрузки: правая панель переставляется, и её порядок уезжает в модель;
- набирают десятками — поиск в каждой панели и перенос всего показанного превращают сорок кликов в два;
- выбор пересматривают — вернуть лишнее налево стоит ровно столько же, сколько добавить.
Когда взять другое
| Нужно | Берите |
|---|---|
| Вариантов до десятка, и все видны сразу | GrCheckboxGroup |
| Места мало, выбранное можно не показывать целиком | GrSelect / GrAutocomplete |
| Список один, меняется только его порядок | GrSortableList |
| Варианты вложены уровнями | GrTree / GrTreeSelect |
| Строки таблицы отмечают ради массового действия | GrDataTable |
| Справочника нет, значения вводят свои | GrInputTag |
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrTransfer :items="items" v-model="value" />Примеры 3 у каждого — живое демо и его код
Ревьюеры сервиса
Выбранное нужно читать целиком: «кто ревьюит» — это сама правая колонка, а не «выбрано 2».
item-labelназывает поле для показа и поиска,item-disabledзапирает тех, кто в отпуске, а слотitemдобавляет лицо и команду. Значение — ключи правой колонки, на ней жеrequiredиinvalid; пока идёт сохранение, весь контролdisabled.Members0 из 6MOMina OrtizPaymentsGWGrace WuPlatformJWJonas WeberOn leave this weekLMLucas MartinPaymentsSLSara LindqvistSupportOHOmar HaddadOn leave this weekReviewers0 из 2DKDaniel KimBillingATAiko TanakaBillingСтроки под итогом счёта
Правая колонка — последовательность, а не множество:
sortable(включён по умолчанию) переставляет её Alt со стрелками,draggableдобавляет перетаскивание указателем, а порядок и есть значение. Слотheaderформулирует счётчики под задачу. Шаблон, по которому уже ушли счета, —readonly: строки можно отметить и прочесть, но ничего не двигается.Invoice template · footer linesAvailable5 morePurchase orderPayment termsBilling contactCustomer notesReverse-charge noticePrinted, in order3 of 4 lines fitDue dateIBANVAT numberDue date · IBAN · VAT numberПолучатели из справочника на две тысячи
@searchотправляет запрос на сервер, аloadingговорит, что ответ в пути, — пустая панель пишет «Searching the directory…», а не «ничего не найдено». Сотни строк остаются лёгкими благодаряvirtualвнутриmax-height.filterищет и по почте, и по имени, а слотemptyотличает «нет совпадений» от «здесь пусто».October product update · recipients2 chosen from 2,000 contacts Directory0 из 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 порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
itemsобязательный | TItem[] | — | Каталог целиком: обе панели — одна его раскладка. |
modelValueобязательныйv-model | GrTransferKey[] | — | Ключи правой панели **в её порядке**: порядок и есть значение. |
itemKey | GrTransferItemKey<TItem> | undefined | undefined | Ключ элемента: имя поля или функция. По умолчанию поле `id`; без ключа выдаётся временный, и `modelValue` с ним не сойдётся. |
itemLabel | GrTransferItemLabel<TItem> | undefined | undefined | Подпись строки: имя поля или функция. По умолчанию поле `label`; по ней же ищет поиск. |
itemDisabled | GrTransferItemFlag<TItem> | undefined | undefined | Запрещённая строка: имя поля или функция. По умолчанию поле `disabled`; такая строка не отмечается и не переносится. |
sourceTitle | string | undefined | undefined | Имя левой панели. Это доступное имя её `listbox`, а не украшение. |
targetTitle | string | undefined | undefined | Имя правой панели. |
searchable | boolean | undefined | true | Поле поиска в каждой панели. |
filter | ((item: TItem, query: string) => boolean) | undefined | undefined | Свой матчер: встроенный ищет по `itemLabel`, а строка может показывать больше. |
loading | boolean | undefined | false | Каталог в пути: пустая панель говорит «ищем», а не «ничего не найдено». Проп общий на обе панели, а не свой у каждой: `items` — один каталог, и обе панели его виды, так что пока он едет, пусты обе. Подгрузку запускает потребитель по событию `search`, поэтому и флаг держит он. |
loadingText | string | undefined | undefined | Текст индикатора. Не задан — из локали (`gr.transfer.loading`). |
sortable | boolean | undefined | true | Правую панель можно переставлять. |
draggable | boolean | undefined | undefined | Перетаскивание строк указателем. Усиление поверх кнопок, а не контракт. |
maxHeight | string | number | undefined | undefined | Высота панелей: обе одинаковы и прокручиваются внутри себя. |
virtual | boolean | undefined | false | Держать в DOM только окно строк вокруг вьюпорта. Нужен `maxHeight`: без него панель не прокручивается, окна нет и виртуализировать нечего. Перенос от этого не страдает — он идёт по модели, а не по разметке. А вот перетаскивание указателем ограничено отрисованными строками: навести указатель на то, чего нет на экране, всё равно нечем. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Кегль строк, шапок, поиска и кнопок по шкале контролов. По умолчанию `md`. |
disabled | boolean | undefined | false | Недоступен целиком: ни выбора, ни переноса, ни поиска. Складывается по «или» с полем и формой. |
readonly | boolean | undefined | false | Только для чтения: строки отмечаются, но перенос и перестановка выключены. |
invalid | boolean | undefined | false | Ошибка значения: `aria-invalid` на правой панели. Складывается по «или» с `GrFormField`. |
required | boolean | undefined | false | Обязательное поле: `aria-required` на правой панели — значение и есть её состав. |
ariaLabel | string | undefined | undefined | Доступное имя всей группы; панели называют их заголовки. |
Слоты
| Slot | Type | Описание |
|---|---|---|
item |
| Своя строка. Слот доменный: у соседей `item` — строка списка, здесь тоже. |
header |
| Своя шапка панели: имя, счётчик, отметка «выбрать всё». |
actions |
| Своя колонка кнопок между панелями. |
empty | { side: GrTransferSide; filtered: boolean; } | Пустая панель. `filtered` — пусто из-за поиска, а не по сути. |
loading | { side: GrTransferSide; } | Панель под загрузкой — вместо текста «ищем». |
События
| Event | Сигнатура | Описание |
|---|---|---|
update:modelValue | (e: "update:modelValue", value: GrTransferKey[]): void | Новый состав правой панели в её порядке — после переноса или перестановки. |
change | (e: "change", value: GrTransferKey[]): void | Состав изменился — тот же payload, что у `update:modelValue`. |
transfer | (e: "transfer", keys: GrTransferKey[], direction: GrTransferDirection): void | Строки переехали: их ключи и направление — кнопками, двойным кликом, с клавиатуры или перетаскиванием. Приходит после `update:modelValue`. |
search | (e: "search", query: string, side: GrTransferSide): void | Изменился запрос в поиске панели `side` — сигнал подгрузить каталог при удалённом поиске. |
focus | (e: "focus", event: FocusEvent): void | Фокус вошёл в компонент. Переходы между панелями и кнопками событием не считаются. |
blur | (e: "blur", event: FocusEvent): void | Фокус ушёл из компонента целиком. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrTransferConfigurableProps, GrTransferProps, GrTransferEmits } from '@feugene/granularity/components/GrTransfer'Файлdist/types/components/GrTransfer/config.d.ts
const grTransferConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTransfer">;Файлdist/types/components/GrTransfer/defaults.d.ts
Пропы GrTransfer, настраиваемые глобально через componentDefaults.
interface GrTransferConfigurableProps {
size: GrComponentSize;
/**
* Перетаскивание — усиление поверх кнопок, а не контракт: приложение вправе
* выключить его на всё поддерево, и клавиатурный путь от этого не пострадает.
*/
draggable: boolean;
}Файлdist/types/components/GrTransfer/GrTransfer.vue.d.ts
interface GrTransferProps<T extends Record<string, unknown> = Record<string, unknown>> {
/** Каталог целиком: обе панели — одна его раскладка. */
items: T[];
/** Ключи правой панели **в её порядке**: порядок и есть значение. */
modelValue: GrTransferKey[];
/**
* Ключ элемента: имя поля или функция. По умолчанию поле `id`; без ключа выдаётся временный,
* и `modelValue` с ним не сойдётся.
*/
itemKey?: GrTransferItemKey<T>;
/** Подпись строки: имя поля или функция. По умолчанию поле `label`; по ней же ищет поиск. */
itemLabel?: GrTransferItemLabel<T>;
/**
* Запрещённая строка: имя поля или функция. По умолчанию поле `disabled`; такая строка
* не отмечается и не переносится.
*/
itemDisabled?: GrTransferItemFlag<T>;
/** Имя левой панели. Это доступное имя её `listbox`, а не украшение. */
sourceTitle?: string;
/** Имя правой панели. */
targetTitle?: string;
/** Поле поиска в каждой панели. */
searchable?: boolean;
/** Свой матчер: встроенный ищет по `itemLabel`, а строка может показывать больше. */
filter?: (item: T, query: string) => boolean;interface GrTransferEmits {
/** Новый состав правой панели в её порядке — после переноса или перестановки. */
(e: 'update:modelValue', value: GrTransferKey[]): void;
/** Состав изменился — тот же payload, что у `update:modelValue`. */
(e: 'change', value: GrTransferKey[]): void;
/**
* Строки переехали: их ключи и направление — кнопками, двойным кликом, с клавиатуры или
* перетаскиванием. Приходит после `update:modelValue`.
*/
(e: 'transfer', keys: GrTransferKey[], direction: GrTransferDirection): void;
/** Изменился запрос в поиске панели `side` — сигнал подгрузить каталог при удалённом поиске. */
(e: 'search', query: string, side: GrTransferSide): void;
/** Фокус вошёл в компонент. Переходы между панелями и кнопками событием не считаются. */
(e: 'focus', event: FocusEvent): void;
/** Фокус ушёл из компонента целиком. */
(e: 'blur', event: FocusEvent): void;
}Файлdist/types/components/GrTransfer/grTransferStyles.d.ts
type GrTransferSize = GrComponentSize;Файлdist/types/components/GrTransfer/safelist.d.ts
Пуст: составных классов у переноса нет. Функции grTransfer*Class только
соединяют через пробел целые литералы grTransferStyles.ts — базы, мапы
размеров и состояний, — и granum извлекает их из чанков компонента сам,
включая общие.
const grTransferSafelist: string[];Файлdist/types/components/GrTransfer/transferModel.d.ts
Порядок и состав панелей: чистая арифметика над ключами, без DOM и Vue.
Вынесена отдельно по той же причине, что и dragSortGeometry: ошибка здесь
проявляется не исключением, а «строка встала не туда» или «выбор исчез после
перезагрузки каталога» — поймать это можно только тестом на числах и
массивах.
type GrTransferKey = string | number;type GrTransferSide = 'source' | 'target';type GrTransferDirection = 'toTarget' | 'toSource';Файлdist/types/components/GrTransfer/index.d.ts
type GrTransferInstance = ComponentExposed<typeof GrTransferComponent>;Доступность
Паттерн APG
listbox ×2 (roving tabindex)
| Клавиша | Действие |
|---|---|
| — | Каждая панель — одна остановка Tab с roving tabindex; между панелями фокус ходит только Tab. ↓/↑ и Home/End ведут фокус, выбор не трогая; кольцо **не замкнуто** — иначе диапазоны теряют смысл. Space переключает отметку и только это |
| — | Shift+стрелка и Shift+Space растягивают диапазон от якоря |
| — | Ctrl/Cmd+A берёт всё показанное и снимает повторным нажатием. Enter переносит отмеченное, а без отметок — строку под фокусом. В правой панели Alt+↓/↑ и Alt+Home/End двигают **всё выделение** сразу; под активным поиском перестановка выключена и говорит об этом. ↑ из первой строки уводит в поле поиска своей панели. Esc отменяет только идущее перетаскивание |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Значение — ключи правой панели, а не элементы
items даёт каталог, modelValue — ключи отобранного в порядке правой
панели. Обе панели это одна раскладка одного каталога: два массива-модели дали
бы два источника правды и вопрос «что делать, когда ключ в обоих».
Отсюда же следует, что перестановка правой панели и есть правка значения: потребитель хранит один массив, а не множество плюс сортировку.
Компонент не мутирует вход: каждая правка уезжает новым массивом, поэтому сравнение «было — стало» и история в сторе потребителя продолжают работать.
Ключ, которого нет в items, сохраняется
Каталог часто приезжает позже сохранённого выбора. Ключ из modelValue, которому
в items ничего не соответствует, остаётся в значении и не рисуется — иначе
первый же рендер уничтожил бы выбор, который на сервере цел. В dev об этом
предупреждает консоль, и только когда items уже непуст.
Элемент без itemKey получает временный ключ, чтобы выбор одной строки не
пометил все. Такой ключ не переживает перезагрузку данных — это тоже
предупреждение, а не решение: задайте itemKey.
Выделение принадлежит компоненту, значение — потребителю
Отметить строку и перенести её — разные действия, и первое наружу не уходит.
Клик выделяет одну строку, Ctrl/Cmd добавляет по одной, Shift берёт
диапазон от якоря. Якорь Shift не двигает: иначе диапазон нельзя было бы
растянуть повторным нажатием.
После переноса выделение не сбрасывается, а переезжает в панель-приёмник:
Alt+стрелка сразу ставит перенесённое на место, а возврат — одно нажатие.
Поиск сужает показ, а не значение
Фильтр в панели не трогает ни выделение, ни модель. Строка, отмеченная и затем скрытая запросом, остаётся отмеченной и переносится вместе со всеми — иначе ввод в поле поиска молча отменял бы уже сделанную работу.
Чтобы это не удивляло, счётчик в шапке считает по всей панели, а не по видимой её части, и перенос объявляется числом.
Диапазон по Shift считается по видимому порядку: «отсюда досюда» обязано
значить то же самое, что человек видит.
Справочник с сервера
Событие search отдаёт запрос и сторону, items подставляет потребитель — а
loading говорит, что ответ ещё в пути:
<GrTransfer v-model="members" :items="items" :loading="pending" @search="fetch" />Без флага пустая панель во время запроса утверждала «ничего не найдено» — хотя совпадений ещё не искали.
Показывается это в трёх местах, и каждое закрывает свой случай:
- пустая панель говорит «ищем» вместо «ничего не найдено». Состояния взаимоисключающи;
- панель со строками их не теряет: они настоящие, и подменять их «ищем»
нельзя. Сигнал там даёт спиннер в поле поиска (
aria-busyотGrInput); - живой регион объявляет загрузку: смена одного пустого текста на другой сама себя не объявляет, и пользователь скринридера получил бы тишину.
Текст меняет loadingText, разметку — слот #loading (получает side).
Флаг общий на обе панели, а не свой у каждой: items — один каталог, и обе
панели его виды, так что пока он едет, пусты обе.
Под загрузкой гаснет dev-предупреждение о ключах modelValue, которых нет в
items: неполный каталог во время запроса — норма. Иначе серверный поиск, сужая
каталог, ронял бы предупреждение на каждое нажатие клавиши.
Кнопки переноса не блокируются: они действуют на значение, а оно известно и во время запроса.
Пока включён поиск, порядок не меняется
Alt+стрелка в отфильтрованной правой панели не работает и говорит об этом.
«Сдвинуть вниз» над отфильтрованным списком либо перепрыгивает невидимые строки,
либо даёт порядок, который пользователь не мог предсказать; и то и другое хуже
отказа.
Space отмечает, Enter переносит
Это то же правило, по которому живёт весь пакет: Enter и Space не
взаимозаменяемы. Отсюда и решение не встраивать GrSortableList в правую
панель — там Space вводит режим «строка взята», и в списке с мультивыбором обе
клавиши оказались бы заняты дважды.
Перестановка поэтому не режим, а команда: Alt+стрелка двигает всё текущее
выделение сразу, чего «взять и положить» не умеет в принципе. Она же вынесена
кнопками в шапку правой панели — сочетание её ускоряет, но само по себе
неоткрываемо, а на macOS почти все мета-клавиши заняты системой.
Кнопки эти горизонтальные, а сочетание вертикальное, и расхождение намеренное.
Порядок правой панели — последовательность, а не положение на экране: он
задаёт колонки отчёта и поля выгрузки, которые читаются слева направо. Отдать
сочетанию ту же ось нельзя: в Chrome и Firefox под Windows и Linux
Alt+←/→ — это «назад/вперёд» в истории браузера. В Safari навигация висит
на Cmd+стрелках, но пакет едет ко всем, и одной платформы тут мало. Расхождение с
GrTree, где строку двигает Shift+стрелка, намеренное: здесь Shift занят
диапазоном — это контракт списка с мультивыбором.
Перенос видно на каждом шаге
У жеста три состояния, и каждое имеет свой вид.
Взять. Строка предлагает себя курсором grab ещё до нажатия, а как только
жест начался, курсор становится grabbing — и не только над строкой, а по всему
компоненту: рука занята везде, а не в одной точке.
Нести. За курсором едет предпросмотр: подпись первой строки и число остальных. Он и есть ответ на вопрос «где сейчас элемент» — без него строка оставалась на месте с бледной подложкой, и жест выглядел так, будто ничего не происходит. Несколько строк не перечисляются: список из десяти имён под курсором закрыл бы то самое место, куда человек целится. На покинутом месте остаётся пунктирная рамка — видно, откуда взяли и куда можно вернуть.
На время жеста выделение текста гасится на всём документе, а не только на самом компоненте: выделение начинается внутри него, но тянется по всей странице, и человек, ведя строку, попутно подсвечивал бы чужие заголовки и абзацы. Прежнее значение возвращается по отпусканию — потребитель мог задать своё.
Положить. Панель-приёмник подсвечивается и рамкой, и подложкой: одной рамки мало, на широком экране она за пределами взгляда, занятого курсором. Место вставки показывает линия у края соседней строки.
Приехавшие строки коротко подсвечиваются. Статичный итог перенос и так
показывает — счётчики меняются, — но само движение ничем не выдано: строки
просто возникают. Под prefers-reduced-motion подсветку гасит глобальный кламп,
и остаётся тот же статичный итог.
Список, который продолжается за краем, гасит свой край. Полоса прокрутки панели на части систем невидима, и без затухания обрезанная посередине строка читается как дефект отрисовки, а не как «прокрути».
Виртуализация панелей
virtual вместе с max-height держит в DOM только окно строк вокруг видимой
части панели: справочник на тысячи прав — ровно тот случай, ради которого берут
transfer, а не мультиселект.
Перенос от этого не страдает: он идёт по модели, а не по разметке, — кнопки переносят и то, что сейчас за краем окна. Ограничено только перетаскивание указателем: уронить строку можно на отрисованную, но навести указатель на то, чего нет на экране, всё равно нечем.
Клавиатура работает как прежде: перед переводом фокуса панель прокручивает
нужную строку в окно и только следующим тиком отдаёт ей фокус — иначе фокус
остался бы на месте, потому что элемента вне окна не существует. А если
сфокусированную строку унесло колесом мыши, фокус уходит на сам список:
обработчик клавиш висит там, и упади фокус на body, клавиатура панели умерла бы
до клика мышью.
Размер набора и место в нём диктору сообщают aria-setsize и aria-posinset —
без них он объявил бы «первая из двенадцати» посреди двухтысячного каталога.
Своя шапка не уносит имя панели
Заголовок панели — это её доступное имя, и #header заменяет его целиком. Чтобы
подмена не оставляла панель безымянной, имя при своей шапке приходит из
sourceTitle/targetTitle напрямую, а не ссылкой на разметку, которой больше
нет. Проверять это глазами бесполезно: на экране всё выглядит правильно, а
теряется только то, что читает скринридер.
Кнопки переноса не бывают выключенными
Когда переносить нечего, кнопка помечается aria-disabled и остаётся в
таб-порядке. Нативный disabled уронил бы фокус в тело документа ровно после
последнего клика — и заодно лишил бы скринридер возможности прочитать, что
кнопка вообще есть.
Границы
- виртуализации панелей нет — цель переноса обязана быть отрисована. Панель на тысячу строк требует своего решения у потребителя;
- дерева внутри панели нет — элементы плоские;
- асинхронной подгрузки справочника нет: событие
searchотдаёт запрос, а что с ним делать, решает потребитель, меняяitems.
Пропы этого примера