GrTransfer

  • ядро
  • Формы
  • API · 21 props
  • ⌨ клавиши

Берут, когда состав набирают из справочника.

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

Обзор

Когда брать

  • состав набирают из справочника — участники проекта, права роли, получатели рассылки: видно и то, что уже взято, и то, что осталось;
  • выбранное само по себе список — правая панель показывает состав целиком, а не «выбрано 12»;
  • порядок отобранного значим — колонки отчёта, поля выгрузки: правая панель переставляется, и её порядок уезжает в модель;
  • набирают десятками — поиск в каждой панели и перенос всего показанного превращают сорок кликов в два;
  • выбор пересматривают — вернуть лишнее налево стоит ровно столько же, сколько добавить.

Когда взять другое

НужноБерите
Вариантов до десятка, и все видны сразуGrCheckboxGroup
Места мало, выбранное можно не показывать целикомGrSelect / GrAutocomplete
Список один, меняется только его порядокGrSortableList
Варианты вложены уровнямиGrTree / GrTreeSelect
Строки таблицы отмечают ради массового действияGrDataTable
Справочника нет, значения вводят своиGrInputTag

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
itemsобязательныйTItem[]—Каталог целиком: обе панели — одна его раскладка.
modelValueобязательныйv-modelGrTransferKey[]—Ключи правой панели **в её порядке**: порядок и есть значение.
itemKeyGrTransferItemKey<TItem> | undefinedundefinedКлюч элемента: имя поля или функция. По умолчанию поле `id`; без ключа выдаётся временный, и `modelValue` с ним не сойдётся.
itemLabelGrTransferItemLabel<TItem> | undefinedundefinedПодпись строки: имя поля или функция. По умолчанию поле `label`; по ней же ищет поиск.
itemDisabledGrTransferItemFlag<TItem> | undefinedundefinedЗапрещённая строка: имя поля или функция. По умолчанию поле `disabled`; такая строка не отмечается и не переносится.
sourceTitlestring | undefinedundefinedИмя левой панели. Это доступное имя её `listbox`, а не украшение.
targetTitlestring | undefinedundefinedИмя правой панели.
searchableboolean | undefinedtrueПоле поиска в каждой панели.
filter((item: TItem, query: string) => boolean) | undefinedundefinedСвой матчер: встроенный ищет по `itemLabel`, а строка может показывать больше.
loadingboolean | undefinedfalseКаталог в пути: пустая панель говорит «ищем», а не «ничего не найдено». Проп общий на обе панели, а не свой у каждой: `items` — один каталог, и обе панели его виды, так что пока он едет, пусты обе. Подгрузку запускает потребитель по событию `search`, поэтому и флаг держит он.
loadingTextstring | undefinedundefinedТекст индикатора. Не задан — из локали (`gr.transfer.loading`).
sortableboolean | undefinedtrueПравую панель можно переставлять.
draggableboolean | undefinedundefinedПеретаскивание строк указателем. Усиление поверх кнопок, а не контракт.
maxHeightstring | number | undefinedundefinedВысота панелей: обе одинаковы и прокручиваются внутри себя.

Слоты

SlotTypeОписание
item
{ item: TItem; side: GrTransferSide; index: number; selected{ item: TItem; side: GrTransferSide; index: number; selected: boolean; disabled: boolean; }
Своя строка. Слот доменный: у соседей `item` — строка списка, здесь тоже.
header
{ side: GrTransferSide; title: string; selected: number; tot{ side: GrTransferSide; title: string; selected: number; total: number; shown: number; }
Своя шапка панели: имя, счётчик, отметка «выбрать всё».
actions
{ toTarget: () => void; toSource: () => void; canToTarget: b{ toTarget: () => void; toSource: () => void; canToTarget: boolean; canToSource: boolean; }
Своя колонка кнопок между панелями.
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

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

Файлdist/types/components/GrTransfer/defaults.d.ts

GrTransferConfigurablePropsinterface

Пропы GrTransfer, настраиваемые глобально через componentDefaults.

interface GrTransferConfigurableProps {
    size: GrComponentSize;
    /**
     * Перетаскивание — усиление поверх кнопок, а не контракт: приложение вправе
     * выключить его на всё поддерево, и клавиатурный путь от этого не пострадает.
     */
    draggable: boolean;
}

Файлdist/types/components/GrTransfer/GrTransfer.vue.d.ts

GrTransferPropsinterface
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;
GrTransferEmitsinterface
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

GrTransferSizetype
type GrTransferSize = GrComponentSize;

Файлdist/types/components/GrTransfer/safelist.d.ts

grTransferSafelistconst

Пуст: составных классов у переноса нет. Функции grTransfer*Class только соединяют через пробел целые литералы grTransferStyles.ts — базы, мапы размеров и состояний, — и granum извлекает их из чанков компонента сам, включая общие.

const grTransferSafelist: string[];

Файлdist/types/components/GrTransfer/transferModel.d.ts

GrTransferKeytype

Порядок и состав панелей: чистая арифметика над ключами, без DOM и Vue. Вынесена отдельно по той же причине, что и dragSortGeometry: ошибка здесь проявляется не исключением, а «строка встала не туда» или «выбор исчез после перезагрузки каталога» — поймать это можно только тестом на числах и массивах.

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

Файлdist/types/components/GrTransfer/index.d.ts

GrTransferInstancetype
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 отменяет только идущее перетаскивание

Использование из прозы библиотеки

Значение — ключи правой панели, а не элементы

items даёт каталог, modelValue — ключи отобранного в порядке правой панели. Обе панели это одна раскладка одного каталога: два массива-модели дали бы два источника правды и вопрос «что делать, когда ключ в обоих».

Отсюда же следует, что перестановка правой панели и есть правка значения: потребитель хранит один массив, а не множество плюс сортировку.

Компонент не мутирует вход: каждая правка уезжает новым массивом, поэтому сравнение «было — стало» и история в сторе потребителя продолжают работать.

Ключ, которого нет в items, сохраняется

Каталог часто приезжает позже сохранённого выбора. Ключ из modelValue, которому в items ничего не соответствует, остаётся в значении и не рисуется — иначе первый же рендер уничтожил бы выбор, который на сервере цел. В dev об этом предупреждает консоль, и только когда items уже непуст.

Элемент без itemKey получает временный ключ, чтобы выбор одной строки не пометил все. Такой ключ не переживает перезагрузку данных — это тоже предупреждение, а не решение: задайте itemKey.

Выделение принадлежит компоненту, значение — потребителю

Отметить строку и перенести её — разные действия, и первое наружу не уходит. Клик выделяет одну строку, Ctrl/Cmd добавляет по одной, Shift берёт диапазон от якоря. Якорь Shift не двигает: иначе диапазон нельзя было бы растянуть повторным нажатием.

После переноса выделение не сбрасывается, а переезжает в панель-приёмник: Alt+стрелка сразу ставит перенесённое на место, а возврат — одно нажатие.

Поиск сужает показ, а не значение

Фильтр в панели не трогает ни выделение, ни модель. Строка, отмеченная и затем скрытая запросом, остаётся отмеченной и переносится вместе со всеми — иначе ввод в поле поиска молча отменял бы уже сделанную работу.

Чтобы это не удивляло, счётчик в шапке считает по всей панели, а не по видимой её части, и перенос объявляется числом.

Диапазон по Shift считается по видимому порядку: «отсюда досюда» обязано значить то же самое, что человек видит.

Справочник с сервера

Событие search отдаёт запрос и сторону, items подставляет потребитель — а loading говорит, что ответ ещё в пути:

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