GrSortableList

Package: @feugene/granularitycoreGroup: data

Let people reorder a list by dragging — or entirely from the keyboard, announced as they go.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the order is set by the user — the priorities of tasks, the fields of a report, the steps of a route, the columns of a builder;
  • the order is saved — the model changes on release, and all that is left is to write it down;
  • moving is needed from the keyboard — that is exactly the reason the component lives in the design system;
  • it has to be dragged by a handlehandleOnly leaves the text selectable and the row clickable.

When to take something else

NeedTake
The order is fixedGrList
The elements are nestedGrTree with draggable
The order of the columns of a tableGrDataTable
Widgets on a two-dimensional gridGrDashboard
The rows are only selected, not movedGrDataTable

The data model

v-model is an array in its current order. A new array goes out: the input is not mutated, so a “before — after” comparison and the history in the consumer’s store keep working. Beside update:modelValue the component gives away move with a pair of indices — it is more convenient when the order is stored on the server and a single operation has to be sent rather than the whole list.

itemKey is the name of a field or a function. Without it the index becomes the key: for a static set that is fine, but with elements being added and removed it will lead to extra repaints.

The keyboard

KeyAction
Tabone stop for the whole list
/ (in the horizontal one — / )move the focus between rows
Space / Enterpick a row up · put it down
/ in the picked-up statemove the row itself
Esccancel the move
Home / Endto the first and the last row

Picking up, every movement, putting down and cancelling are announced into a live region (useAnnouncer) — without that a keyboard move happens blindly. The focus leaving the list releases the grip: a row cannot stay picked up forever.

The handle

By default only the handle is draggable (handleOnly). That way the row stays clickable, and links and buttons can be kept inside it. :handle-only="false" makes the whole row draggable — which suits short lists with no interactive elements inside.

The handle is a button outside the tab order (tabindex="-1"): the one Tab for the list belongs to the row, and from the keyboard the move starts with Space on that same row. The content of the handle is replaced with the #handle slot.

The props, emits and slots

There is no list of props here — it is generated from the sources. What is worth knowing beyond the signatures:

  • orientation="horizontal" switches both the layout and the axis of the keyboard at once;
  • maxHeight turns the list into a scroller and switches on auto-scrolling at the edges during a move;
  • variant travels into the GrCard under the list, as in GrList;
  • disabled forbids moving both ways but leaves the list readable;
  • expose: move(from, to) — a programmatic rearrangement by the same path as a move, focusItem(index) — the focus onto a row.

Limits

  • There is no virtualisation. A move requires the target to be rendered, and a clipping window does not guarantee that. For long lists without sorting there is GrList with virtual.
  • It does not move between two lists — that is GrTransfer, which is not in the package yet.

The mechanics of moving is separated into the useDragSort composable — if a list of your own with markup of your own is needed, build on it rather than on this component.

Playground 5

Loading…

Code
<GrSortableList />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
modelValuerequiredT[]The set in its current order. `v-model`: a new array goes out, and the input is not mutated.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedThe key of an item: the name of a field or a function. Without it — the index.
disabledboolean | undefinedfalseThe list is read only: neither by the pointer nor from the keyboard.
orientationGrSortableOrientation | undefined"vertical"The axis of the move. `horizontal` is a row with wrapping by the width.
variantGrCardVariant | undefinedundefinedThe surface under the list — the variant of the card.
dividedboolean | undefinedtrueSeparators between the rows.
handleOnlyboolean | undefinedtrueDragging is possible only by the handle. Off and the whole row drags.
maxHeightstring | number | undefinedundefinedThe height of the visible part: the list becomes a scroller with auto-scrolling during a move.
emptyTextstring | undefinedundefinedThe text of the empty state. The `#empty` slot is stronger.
ariaLabelstring | undefinedundefinedThe name of the list for a screen reader. Unset — it is taken from the locale.

Slots

SlotTypeDescription
item{ item: T; index: number; dragging: boolean; grabbed: boolean; }
handle{ item: T; index: number; disabled: boolean; }
emptyany

Events

EventTypeDescription
update:modelValue[T[]]
move[number, number]
change[T[]]

Examples 3

Basic

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>

Scroll

Поле отчёта № 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>

Horizontal

Название
Статус
Ответственный
Срок

В горизонтальном списке ось клавиатуры тоже горизонтальная: взять — 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>

Accessibility

APG pattern
list (roving tabindex)

Full keyboard contract of the package

Component documentationAll components