GrSortableList
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 handle —
handleOnlyleaves the text selectable and the row clickable.
When to take something else
| Need | Take |
|---|---|
| The order is fixed | GrList |
| The elements are nested | GrTree with draggable |
| The order of the columns of a table | GrDataTable |
| Widgets on a two-dimensional grid | GrDashboard |
| The rows are only selected, not moved | GrDataTable |
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
| Key | Action |
|---|---|
Tab | one stop for the whole list |
↑ / ↓ (in the horizontal one — ← / →) | move the focus between rows |
Space / Enter | pick a row up · put it down |
↑ / ↓ in the picked-up state | move the row itself |
Esc | cancel the move |
Home / End | to 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;maxHeightturns the list into a scroller and switches on auto-scrolling at the edges during a move;varianttravels into theGrCardunder the list, as inGrList;disabledforbids 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
GrListwithvirtual. - 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…
<GrSortableList />Install
npm i @feugene/granularityImport
import { GrSortableList } from '@feugene/granularity/components/GrSortableList'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequired | T[] | — | The set in its current order. `v-model`: a new array goes out, and the input is not mutated. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | The key of an item: the name of a field or a function. Without it — the index. |
disabled | boolean | undefined | false | The list is read only: neither by the pointer nor from the keyboard. |
orientation | GrSortableOrientation | undefined | "vertical" | The axis of the move. `horizontal` is a row with wrapping by the width. |
variant | GrCardVariant | undefined | undefined | The surface under the list — the variant of the card. |
divided | boolean | undefined | true | Separators between the rows. |
handleOnly | boolean | undefined | true | Dragging is possible only by the handle. Off and the whole row drags. |
maxHeight | string | number | undefined | undefined | The height of the visible part: the list becomes a scroller with auto-scrolling during a move. |
emptyText | string | undefined | undefined | The text of the empty state. The `#empty` slot is stronger. |
ariaLabel | string | undefined | undefined | The name of the list for a screen reader. Unset — it is taken from the locale. |
Slots
| Slot | Type | Description |
|---|---|---|
item | { item: T; index: number; dragging: boolean; grabbed: boolean; } | — |
handle | { item: T; index: number; disabled: boolean; } | — |
empty | any | — |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [T[]] | — |
move | [number, number] | — |
change | [T[]] | — |
Examples 3
Basic
Порядок: 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>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, двигать — стрелками влево и вправо.
<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)