Reorder
Drag list rows by a handle to reorder them.
Drag list rows into a new order by their grip handle. Pressing the handle lifts the row, its siblings slide to preview the drop slot, and the list auto-scrolls when you drag near its edges. On release the component emits { from, to } — you apply the move to your own array (the “auto” completion model, contrast Ionic’s manual complete()) and let it re-render. Dragging starts only from the handle, so vertical list scrolling stays natural.
Installation
npx touchcn add reorder
Usage
<tcn-reorder (reorder)="onReorder($event)">
@for (item of items(); track item) {
<tcn-reorder-item>
<div class="row">{{ item }}</div>
<tcn-reorder-handle />
</tcn-reorder-item>
}
</tcn-reorder>import { TcnReorder, TcnReorderItem, TcnReorderHandle } from '@/components/ui/reorder';
<TcnReorder onReorder={({ from, to }) => setItems((items) => move(items, from, to))}>
{items.map((item) => (
<TcnReorderItem key={item}>
<div className="row">{item}</div>
<TcnReorderHandle />
</TcnReorderItem>
))}
</TcnReorder><script setup lang="ts">
import { TcnReorder, TcnReorderItem, TcnReorderHandle } from '@/components/ui/reorder';
</script>
<template>
<TcnReorder @reorder="({ from, to }) => (items = move(items, from, to))">
<TcnReorderItem v-for="item in items" :key="item">
<div class="row">{{ item }}</div>
<TcnReorderHandle />
</TcnReorderItem>
</TcnReorder>
</template>Apply the emitted move to your own data, for example:
function move<T>(items: T[], from: number, to: number): T[] {
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
}
All gesture behaviour — pointer tracking, the translateY writes, hit-testing, sibling shifting, auto-scroll and the keyboard reorder — lives in the engine (TcnReorderDirective / useReorder); the copied components own only markup. Long-press-anywhere to drag is out of scope (dragging is handle-only by design); a future engine addition can layer it on without changing this markup.
Accessibility
Each handle is a focusable role="button". The keyboard reorder follows the WAI listbox-reorder pattern at a baseline depth: Space/Enter toggles pick-up (reflected via aria-pressed), the arrow keys move the grabbed row one slot at a time and focus follows it, and Esc cancels. Screen-reader announcements are limited to the handle’s aria-pressed state — richer live-region narration is not yet implemented.
Props
Reorder
| Prop | Type | Default | Description |
|---|---|---|---|
reorder / onReorder |
(e: { from, to }) => void |
— | Fired on commit; apply the move to your own array. |
disabled |
boolean |
false |
Disable both the drag and keyboard reorder. |
Reorder Handle
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
'Drag to reorder' |
Accessible label for the grip. |
Source · Angular
export * from './tcn-reorder';import { Component, input } from '@angular/core';
import { TcnReorderDirective } from '@touchcn/angular/reorder';
export type { TcnReorderEvent } from '@touchcn/angular/reorder';
/**
* Drag-to-reorder list group. Wraps a set of `tcn-reorder-item` rows and, via
* the host `TcnReorderDirective`, lets each row be dragged from its
* `tcn-reorder-handle` grip to a new position — lifting the pressed row,
* shifting its siblings to preview the drop slot and auto-scrolling near the
* edges. On release it emits `reorder` with `{ from, to }`; apply the move to
* your own array (the "auto" model) and let it re-render.
*
* All gesture behaviour — pointer tracking, the `translateY` writes, hit-testing,
* auto-scroll and the keyboard reorder — lives in the engine directive; this
* component owns only markup. Drag starts from the handle only, so list
* scrolling stays natural; long-press-anywhere is out of scope (a future engine
* addition can layer it on without changing this markup).
*/
@Component({
selector: 'tcn-reorder',
hostDirectives: [{ directive: TcnReorderDirective, inputs: ['disabled'], outputs: ['reorder'] }],
template: '<ng-content />',
host: { class: 'tcn-reorder block', role: 'list' },
})
export class TcnReorder {}
/** A single reorderable row. Place a `tcn-reorder-handle` inside as the drag grip. */
@Component({
selector: 'tcn-reorder-item',
template: '<ng-content />',
host: { class: 'tcn-reorder-item', role: 'listitem' },
})
export class TcnReorderItem {}
/**
* The drag grip for a `tcn-reorder-item` — a focusable trailing handle showing a
* platform grip icon (iOS lines / MD3 dots). Dragging starts only from here.
* Focus it and press Space/Enter to pick the row up, then Arrow Up/Down to move
* it and Space/Enter (or Escape to cancel) to drop.
*/
@Component({
selector: 'tcn-reorder-handle',
template: `
<svg class="if-ios" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 8h16M4 16h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<svg class="if-md" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="9" cy="6" r="1.4" fill="currentColor" />
<circle cx="15" cy="6" r="1.4" fill="currentColor" />
<circle cx="9" cy="12" r="1.4" fill="currentColor" />
<circle cx="15" cy="12" r="1.4" fill="currentColor" />
<circle cx="9" cy="18" r="1.4" fill="currentColor" />
<circle cx="15" cy="18" r="1.4" fill="currentColor" />
</svg>
`,
host: {
class: 'tcn-reorder-handle',
tabindex: '0',
role: 'button',
'aria-pressed': 'false',
'[attr.aria-label]': 'label()',
},
})
export class TcnReorderHandle {
readonly label = input('Drag to reorder');
}Source · React
export * from './tcn-reorder';import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { useReorder } from '@touchcn/react';
import type { TcnReorderEvent } from '@touchcn/react';
export type { TcnReorderEvent } from '@touchcn/react';
export interface TcnReorderProps {
/** Apply the move to your own array, then let it re-render (the "auto" model). */
onReorder: (event: TcnReorderEvent) => void;
/** Disables the gesture and the keyboard alternative entirely. */
disabled?: boolean;
children?: ReactNode;
className?: string;
}
/**
* Drag-to-reorder list group, mirroring the Angular component. Wraps a set of
* `TcnReorderItem` rows and, via the engine `useReorder` hook, lets each row be
* dragged from its `TcnReorderHandle` grip to a new position — lifting the
* pressed row, shifting siblings to preview the drop slot and auto-scrolling
* near the edges. On release it calls `onReorder` with `{ from, to }`; apply the
* move to your own array (the "auto" model) and let React re-render.
*
* Emits the same `tcn-*` classes and `data-*` attributes as the Angular
* component. Drag starts from the handle only, so list scrolling stays natural;
* long-press-anywhere is out of scope.
*/
export function TcnReorder({ onReorder, disabled, children, className }: TcnReorderProps) {
const { groupRef, dragging } = useReorder({ onReorder, disabled });
return (
<div ref={groupRef} role="list" className={cn('tcn-reorder block', className)} data-dragging={dragging || undefined}>
{children}
</div>
);
}
export interface TcnReorderItemProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
}
/** A single reorderable row. Place a `TcnReorderHandle` inside as the drag grip. */
export function TcnReorderItem({ className, children, ...props }: TcnReorderItemProps) {
return (
<div role="listitem" className={cn('tcn-reorder-item', className)} {...props}>
{children}
</div>
);
}
export interface TcnReorderHandleProps extends ComponentPropsWithoutRef<'div'> {
/** Accessible label for the grip handle. */
label?: string;
}
/**
* The drag grip for a `TcnReorderItem` — a focusable trailing handle showing a
* platform grip icon (iOS lines / MD3 dots). Dragging starts only from here.
* Focus it and press Space/Enter to pick the row up, then Arrow Up/Down to move
* it and Space/Enter (or Escape to cancel) to drop.
*/
export function TcnReorderHandle({ label = 'Drag to reorder', className, ...props }: TcnReorderHandleProps) {
return (
<div
role="button"
tabIndex={0}
aria-pressed={false}
aria-label={label}
className={cn('tcn-reorder-handle', className)}
{...props}
>
<svg className="if-ios" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 8h16M4 16h16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<svg className="if-md" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="9" cy="6" r="1.4" fill="currentColor" />
<circle cx="15" cy="6" r="1.4" fill="currentColor" />
<circle cx="9" cy="12" r="1.4" fill="currentColor" />
<circle cx="15" cy="12" r="1.4" fill="currentColor" />
<circle cx="9" cy="18" r="1.4" fill="currentColor" />
<circle cx="15" cy="18" r="1.4" fill="currentColor" />
</svg>
</div>
);
}Source · Vue
<script setup lang="ts">
import { useReorder } from '@touchcn/vue';
import type { TcnReorderEvent } from '@touchcn/vue';
/**
* Drag-to-reorder list group, mirroring the Angular / React component. Wraps a
* set of `TcnReorderItem` rows and, via the engine `useReorder` composable, lets
* each row be dragged from its `TcnReorderHandle` grip to a new position —
* lifting the pressed row, shifting siblings to preview the drop slot and
* auto-scrolling near the edges. On release it emits `reorder` with
* `{ from, to }`; apply the move to your own array (the "auto" model) and let Vue
* re-render.
*
* Emits the same `tcn-*` classes and `data-*` attributes across frameworks. Drag
* starts from the handle only, so list scrolling stays natural; long-press-
* anywhere is out of scope.
*/
const props = withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false });
const emit = defineEmits<{ reorder: [event: TcnReorderEvent] }>();
const { setGroup, dragging } = useReorder({
onReorder: (event) => emit('reorder', event),
disabled: () => props.disabled,
});
</script>
<template>
<div :ref="setGroup" role="list" class="tcn-reorder block" :data-dragging="dragging || undefined">
<slot />
</div>
</template><script setup lang="ts">
/**
* The drag grip for a `TcnReorderItem` — a focusable trailing handle showing a
* platform grip icon (iOS lines / MD3 dots). Dragging starts only from here.
* Focus it and press Space/Enter to pick the row up, then Arrow Up/Down to move
* it and Space/Enter (or Escape to cancel) to drop.
*/
withDefaults(defineProps<{ label?: string }>(), { label: 'Drag to reorder' });
</script>
<template>
<div role="button" :tabindex="0" :aria-pressed="false" :aria-label="label" class="tcn-reorder-handle">
<svg class="if-ios" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 8h16M4 16h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<svg class="if-md" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="9" cy="6" r="1.4" fill="currentColor" />
<circle cx="15" cy="6" r="1.4" fill="currentColor" />
<circle cx="9" cy="12" r="1.4" fill="currentColor" />
<circle cx="15" cy="12" r="1.4" fill="currentColor" />
<circle cx="9" cy="18" r="1.4" fill="currentColor" />
<circle cx="15" cy="18" r="1.4" fill="currentColor" />
</svg>
</div>
</template><script setup lang="ts">
/** A single reorderable row. Place a `TcnReorderHandle` inside as the drag grip. */
</script>
<template>
<div role="listitem" class="tcn-reorder-item"><slot /></div>
</template>export { default as TcnReorder } from './TcnReorder.vue';
export { default as TcnReorderItem } from './TcnReorderItem.vue';
export { default as TcnReorderHandle } from './TcnReorderHandle.vue';
export type { TcnReorderEvent } from '@touchcn/vue';