GrTransfer

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

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

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

Обзор

Когда брать

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

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

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

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

Сцена

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

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

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

  • Ревьюеры сервиса

    Выбранное нужно читать целиком: «кто ревьюит» — это сама правая колонка, а не «выбрано 2». item-label называет поле для показа и поиска, item-disabled запирает тех, кто в отпуске, а слот item добавляет лицо и команду. Значение — ключи правой колонки, на ней же required и invalid; пока идёт сохранение, весь контрол disabled.

    Members0 из 6

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

    DKDaniel KimBilling
    ATAiko TanakaBilling
  • Строки под итогом счёта

    Правая колонка — последовательность, а не множество: sortable (включён по умолчанию) переставляет её Alt со стрелками, draggable добавляет перетаскивание указателем, а порядок и есть значение. Слот header формулирует счётчики под задачу. Шаблон, по которому уже ушли счета, — readonly: строки можно отметить и прочесть, но ничего не двигается.

    Invoice template · footer lines
    Available5 more

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

    Due date
    IBAN
    VAT number
    Due date · IBAN · VAT number
  • Получатели из справочника на две тысячи

    @search отправляет запрос на сервер, а loading говорит, что ответ в пути, — пустая панель пишет «Searching the directory…», а не «ничего не найдено». Сотни строк остаются лёгкими благодаря virtual внутри max-height. filter ищет и по почте, и по имени, а слот empty отличает «нет совпадений» от «здесь пусто».

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

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

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

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.