Files
WorthPath/apps/web/src/GroupOrderList.tsx
T

148 lines
5.2 KiB
TypeScript

import { useEffect, useId, useRef, useState, type PointerEvent } from 'react';
import { GripVertical } from 'lucide-react';
import { t as tr } from './i18n';
import { moveGroup } from './group-order';
export function GroupOrderList({
groups,
onChange,
busy,
}: {
groups: string[];
onChange: (groups: string[]) => void;
busy: boolean;
}) {
const list = useRef<HTMLOListElement>(null),
help = useId();
const current = useRef({ groups, onChange });
current.current = { groups, onChange };
const drag = useRef<{ group: string; pointer: number; initial: string[]; y: number } | null>(
null,
);
const frame = useRef<number | null>(null);
const [active, setActive] = useState<string | null>(null),
[announcement, setAnnouncement] = useState('');
useEffect(
() => () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
},
[],
);
function move(from: number, to: number) {
const next = moveGroup(current.current.groups, from, to);
if (next === current.current.groups) return;
current.current.groups = next;
current.current.onChange(next);
setAnnouncement((next[to] || tr('未分组')) + ' · ' + (to + 1) + ' / ' + next.length);
}
function track() {
const value = drag.current,
container = list.current;
if (!value || !container) return;
const bounds = container.getBoundingClientRect();
if (value.y < bounds.top + 30) container.scrollTop -= 10;
else if (value.y > bounds.bottom - 30) container.scrollTop += 10;
const rows = Array.from(container.children);
let nearest = 0,
distance = Infinity;
rows.forEach((row, index) => {
const box = row.getBoundingClientRect(),
gap = Math.abs(value.y - (box.top + box.height / 2));
if (gap < distance) {
nearest = index;
distance = gap;
}
});
move(current.current.groups.indexOf(value.group), nearest);
frame.current = requestAnimationFrame(track);
}
function finish(cancel = false) {
const value = drag.current;
if (!value) return;
drag.current = null;
if (frame.current !== null) cancelAnimationFrame(frame.current);
frame.current = null;
if (cancel) {
current.current.groups = value.initial;
current.current.onChange(value.initial);
setAnnouncement(tr('已取消拖拽'));
}
setActive(null);
if (list.current?.hasPointerCapture(value.pointer))
list.current.releasePointerCapture(value.pointer);
}
function start(e: PointerEvent<HTMLButtonElement>, group: string) {
if (busy || !e.isPrimary || e.button !== 0 || drag.current || groups.length < 2) return;
e.preventDefault();
e.currentTarget.focus();
drag.current = { group, pointer: e.pointerId, initial: [...groups], y: e.clientY };
list.current?.setPointerCapture(e.pointerId);
setActive(group);
frame.current = requestAnimationFrame(track);
}
return (
<>
<p id={help} className="muted group-order-help">
{tr('拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。')}
</p>
<ol
ref={list}
className={'group-order-list' + (active !== null ? ' is-dragging' : '')}
aria-label={tr('账户分组顺序')}
onPointerMove={(e) => {
if (drag.current?.pointer === e.pointerId) drag.current.y = e.clientY;
}}
onPointerUp={(e) => {
if (drag.current?.pointer === e.pointerId) finish();
}}
onPointerCancel={(e) => {
if (drag.current?.pointer === e.pointerId) finish(true);
}}
onLostPointerCapture={(e) => {
if (drag.current?.pointer === e.pointerId) finish(true);
}}
>
{groups.map((group, index) => (
<li key={group} className={active === group ? 'drag-active' : undefined}>
<button
type="button"
className="group-drag-handle"
aria-label={tr('拖动分组') + ' ' + (group || tr('未分组'))}
aria-describedby={help}
disabled={busy || groups.length < 2}
onPointerDown={(e) => start(e, group)}
onKeyDown={(e) => {
if (e.key === 'Escape' && drag.current) {
e.preventDefault();
finish(true);
return;
}
if (drag.current || busy) return;
const targets: Record<string, number> = {
ArrowUp: index - 1,
ArrowDown: index + 1,
Home: 0,
End: groups.length - 1,
};
if (e.key in targets) {
e.preventDefault();
move(index, targets[e.key]);
}
}}
>
<GripVertical size={20} aria-hidden="true" />
</button>
<span className="group-order-name">{group || tr('未分组')}</span>
<span className="group-order-index" aria-hidden="true">
{index + 1}
</span>
</li>
))}
</ol>
<span className="sr-only" role="status" aria-live="polite">
{announcement}
</span>
</>
);
}