GrSortableList
Берут, когда порядок задаёт пользователь.
Когда брать
- порядок задаёт пользователь — приоритеты задач, поля отчёта, шаги маршрута, колонки конструктора;
- порядок сохраняется — модель меняется на отпускании, и её остаётся записать;
- перенос нужен с клавиатуры — ровно это и есть причина, по которой компонент живёт в дизайн-системе;
- тянуть надо за ручку —
handleOnlyоставляет текст выделяемым, а строку — кликабельной.
Когда взять другое
| Нужно | Берите |
|---|---|
| Порядок фиксирован | GrList |
| Элементы вложены | GrTree с draggable |
| Порядок колонок таблицы | GrDataTable |
| Виджеты на двумерной сетке | GrDashboard |
| Строки только выбирают, не двигают | GrDataTable |
Модель данных
v-model — массив в текущем порядке. Наружу уходит новый массив: вход не мутируется, поэтому
сравнение «было — стало» и история в сторе потребителя остаются рабочими. Рядом с
update:modelValue компонент отдаёт move с парой индексов — она удобнее, когда порядок хранится
на сервере и нужно послать одну операцию, а не весь список.
itemKey — имя поля или функция. Без него ключом становится индекс: для статичного набора это
нормально, но при добавлении и удалении элементов приведёт к лишним перерисовкам.
Клавиатура
| Клавиша | Действие |
|---|---|
Tab | одна остановка на весь список |
↑ / ↓ (в горизонтальном — ← / →) | перевести фокус между строками |
Space / Enter | взять строку · положить |
↑ / ↓ во взятом состоянии | двигать саму строку |
Esc | отменить перенос |
Home / End | к первой и последней строке |
Взятие, каждое движение, отпускание и отмена объявляются в живой регион
(useAnnouncer) — без этого клавиатурный перенос происходит вслепую. Уход
фокуса из списка снимает захват: строка не может остаться взятой навсегда.
Ручка
По умолчанию тянется только ручка (handleOnly). Так строка остаётся кликабельной, а внутри неё
можно держать ссылки и кнопки. :handle-only="false" делает перетаскиваемой всю строку — уместно
для коротких списков без интерактива внутри.
Ручка — кнопка вне таб-порядка (tabindex="-1"): один Tab на список принадлежит строке, а с
клавиатуры перенос начинается Space на ней же. Содержимое ручки заменяется слотом #handle.
Пропы, эмиты, слоты
Списка пропов здесь нет — он генерируется из исходников. Что стоит знать сверх сигнатур:
orientation="horizontal"переключает и раскладку, и ось клавиатуры разом;maxHeightпревращает список в скроллер и включает автопрокрутку у краёв при переносе;variantуезжает вGrCardпод списком, как уGrList;disabledзапрещает перенос обоими способами, но оставляет список читаемым;- expose:
move(from, to)— программная перестановка тем же путём, что и перенос,focusItem(index)— фокус на строку.
Границы
- Виртуализации нет. Перенос требует, чтобы цель была отрисована, а окно отсечения этого не
гарантирует. Для длинных списков без сортировки есть
GrListсvirtual. - Между двумя списками не переносит — это
GrTransfer, которого в пакете пока нет.
Механика переноса вынесена в композабл useDragSort — если нужен свой список
со своей разметкой, стройте на нём, а не на этом компоненте.
Playground 5
Загружается…
<GrSortableList />Установка
npm i @feugene/granularityИмпорт
import { GrSortableList } from '@feugene/granularity/components/GrSortableList'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelValueобязательный | T[] | — | Набор в текущем порядке. `v-model`: наружу уходит новый массив, вход не мутируется. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | Ключ элемента: имя поля или функция. Без него — индекс. |
disabled | boolean | undefined | false | Список только для чтения: ни указателем, ни с клавиатуры. |
orientation | GrSortableOrientation | undefined | "vertical" | Ось переноса. `horizontal` — ряд с переносом по ширине. |
variant | GrCardVariant | undefined | undefined | Поверхность под списком — вариант карточки. |
divided | boolean | undefined | true | Разделители между строками. |
handleOnly | boolean | undefined | true | Тянуть можно только за ручку. Выключено — тянется вся строка. |
maxHeight | string | number | undefined | undefined | Высота видимой части: список становится скроллером с автопрокруткой при переносе. |
emptyText | string | undefined | undefined | Текст пустого состояния. Слот `#empty` сильнее. |
ariaLabel | string | undefined | undefined | Имя списка для скринридера. Не задано — берётся из локали. |
Slots
| Slot | Type | Описание |
|---|---|---|
item | { item: T; index: number; dragging: boolean; grabbed: boolean; } | — |
handle | { item: T; index: number; disabled: boolean; } | — |
empty | any | — |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [T[]] | — |
move | [number, number] | — |
change | [T[]] | — |
Примеры 3
Порядок шагов
v-model — массив в текущем порядке; наружу уходит новый массив, входной не мутируется. Слот #item рисует строку, ключ берётся из item-key.
Порядок: brief, design, build, review — тяните за ручку или доведите фокус до строки и нажмите Space, стрелки, Space.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrSortableList } from '@feugene/granularity'
type Step = { id: string, title: string, owner: string }
const steps = ref<Step[]>([
{ id: 'brief', title: 'Бриф и требования', owner: 'Продукт' },
{ id: 'design', title: 'Макет', owner: 'Дизайн' },
{ id: 'build', title: 'Сборка', owner: 'Разработка' },
{ id: 'review', title: 'Ревью и приёмка', owner: 'QA' },
])
</script>
<template>
<div class="grid gap-4">
<GrSortableList v-model="steps" item-key="id">
<template #item="{ item, index }">
<div class="flex items-center justify-between gap-3">
<span>
<GrBadge tone="neutral">{{ index + 1 }}</GrBadge>
{{ item.title }}
</span>
<span class="text-sm text-[var(--gr-muted-fg)]">{{ item.owner }}</span>
</div>
</template>
</GrSortableList>
<p class="text-sm text-[var(--gr-muted-fg)]">
Порядок:
<code>{{ steps.map(step => step.id).join(', ') }}</code>
— тяните за ручку или доведите фокус до строки и нажмите Space, стрелки, Space.
</p>
</div>
</template>Клавиатура равноправна мыши: Space берёт строку, стрелки двигают, Space кладёт, Esc отменяет — каждый шаг объявляется скринридеру.
Длинный список и автопрокрутка
max-height превращает список в скроллер: у краёв он едет сам, пока держите строку. Событие move отдаёт пару индексов — удобно, когда порядок хранится на сервере.
Последняя перестановка: —. У верхнего и нижнего края список прокручивается сам, пока держите строку.
<script setup lang="ts">
import { ref } from 'vue'
import { GrSortableList } from '@feugene/granularity'
type Field = { id: string, title: string }
const fields = ref<Field[]>(Array.from({ length: 14 }, (_, index) => ({
id: `field-${index + 1}`,
title: `Поле отчёта № ${index + 1}`,
})))
const lastMove = ref<string>('—')
</script>
<template>
<div class="grid gap-4">
<GrSortableList
v-model="fields"
item-key="id"
:max-height="220"
@move="(from, to) => (lastMove = `${from} на ${to}`)"
>
<template #item="{ item }">
{{ item.title }}
</template>
</GrSortableList>
<p class="text-sm text-[var(--gr-muted-fg)]">
Последняя перестановка: <code>{{ lastMove }}</code>. У верхнего и нижнего края список
прокручивается сам, пока держите строку.
</p>
</div>
</template>Виртуализации здесь нет намеренно: уронить строку можно только на отрисованную цель.
Горизонтальный ряд и запрет
orientation="horizontal" переключает раскладку и ось клавиатуры разом. disabled оставляет список читаемым, но запрещает перенос обоими способами.
В горизонтальном списке ось клавиатуры тоже горизонтальная: взять — Space, двигать — стрелками влево и вправо.
<script setup lang="ts">
import { ref } from 'vue'
import { GrSortableList } from '@feugene/granularity'
type Column = { id: string, title: string }
const columns = ref<Column[]>([
{ id: 'name', title: 'Название' },
{ id: 'status', title: 'Статус' },
{ id: 'owner', title: 'Ответственный' },
{ id: 'due', title: 'Срок' },
])
const locked = ref(false)
</script>
<template>
<div class="grid gap-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="locked" type="checkbox">
Запретить перестановку
</label>
<GrSortableList
v-model="columns"
item-key="id"
orientation="horizontal"
:divided="false"
:disabled="locked"
aria-label="Порядок колонок"
>
<template #item="{ item }">
{{ item.title }}
</template>
</GrSortableList>
<p class="text-sm text-[var(--gr-muted-fg)]">
В горизонтальном списке ось клавиатуры тоже горизонтальная: взять — Space, двигать — стрелками влево и вправо.
</p>
</div>
</template>Доступность
- Паттерн APG
list (roving tabindex)- Клавиши
↑/↓— по строкам,Space/Enter— взять и положить, стрелки во взятом состоянии — двигать строку,Esc— отменить,Home/End— к краям; в горизонтальном списке ось стрелок —←/→