GrSortableList

Пакет: @feugene/granularityядроГруппа: Данные

Берут, когда порядок задаёт пользователь.

Когда брать

  • порядок задаёт пользователь — приоритеты задач, поля отчёта, шаги маршрута, колонки конструктора;
  • порядок сохраняется — модель меняется на отпускании, и её остаётся записать;
  • перенос нужен с клавиатуры — ровно это и есть причина, по которой компонент живёт в дизайн-системе;
  • тянуть надо за ручку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

PropTypeпо умолчаниюОписание
modelValueобязательныйT[]Набор в текущем порядке. `v-model`: наружу уходит новый массив, вход не мутируется.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedКлюч элемента: имя поля или функция. Без него — индекс.
disabledboolean | undefinedfalseСписок только для чтения: ни указателем, ни с клавиатуры.
orientationGrSortableOrientation | undefined"vertical"Ось переноса. `horizontal` — ряд с переносом по ширине.
variantGrCardVariant | undefinedundefinedПоверхность под списком — вариант карточки.
dividedboolean | undefinedtrueРазделители между строками.
handleOnlyboolean | undefinedtrueТянуть можно только за ручку. Выключено — тянется вся строка.
maxHeightstring | number | undefinedundefinedВысота видимой части: список становится скроллером с автопрокруткой при переносе.
emptyTextstring | undefinedundefinedТекст пустого состояния. Слот `#empty` сильнее.
ariaLabelstring | undefinedundefinedИмя списка для скринридера. Не задано — берётся из локали.

Slots

SlotTypeОписание
item{ item: T; index: number; dragging: boolean; grabbed: boolean; }
handle{ item: T; index: number; disabled: boolean; }
emptyany

Events

EventTypeОписание
update:modelValue[T[]]
move[number, number]
change[T[]]

Примеры 3

Порядок шагов

v-model — массив в текущем порядке; наружу уходит новый массив, входной не мутируется. Слот #item рисует строку, ключ берётся из item-key.

1 Бриф и требованияПродукт
2 МакетДизайн
3 СборкаРазработка
4 Ревью и приёмкаQA

Порядок: brief, design, build, review — тяните за ручку или доведите фокус до строки и нажмите Space, стрелки, Space.

Basic
<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 отдаёт пару индексов — удобно, когда порядок хранится на сервере.

Поле отчёта № 1
Поле отчёта № 2
Поле отчёта № 3
Поле отчёта № 4
Поле отчёта № 5
Поле отчёта № 6
Поле отчёта № 7
Поле отчёта № 8
Поле отчёта № 9
Поле отчёта № 10
Поле отчёта № 11
Поле отчёта № 12
Поле отчёта № 13
Поле отчёта № 14

Последняя перестановка: . У верхнего и нижнего края список прокручивается сам, пока держите строку.

Scroll
<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, двигать — стрелками влево и вправо.

Horizontal
<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 — к краям; в горизонтальном списке ось стрелок — /

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты