Inconsistent Tab Movement with sortableKeyboardCoordinates on Fixed-Width Layouts #1744
-
|
I'm using sortableKeyboardCoordinates with a horizontal list of fixed-width tabs. When using keyboard sorting (e.g., arrow keys), the tabs shift with extra movement that seems to be caused by incorrect width calculation or coordinate offsets. Recording.2025-06-17.225730.mp4Here's how I am using the sortable component: // Sortable.tsx
import {
type AnimateLayoutChanges,
defaultAnimateLayoutChanges,
horizontalListSortingStrategy,
arrayMove,
useSortable,
SortableContext,
sortableKeyboardCoordinates,
type SortingStrategy,
rectSortingStrategy,
type NewIndexGetter,
} from "@dnd-kit/sortable";
import React, { use, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { List } from "./components/list/list";
import { Item } from "./components/item/item";
import {
type Active,
type Announcements,
closestCenter,
type CollisionDetection,
DragOverlay,
DndContext,
type DropAnimation,
KeyboardSensor,
type KeyboardCoordinateGetter,
type Modifiers,
MouseSensor,
type MeasuringConfiguration,
type PointerActivationConstraint,
type ScreenReaderInstructions,
TouchSensor,
type UniqueIdentifier,
useSensor,
useSensors,
defaultDropAnimationSideEffects,
} from "@dnd-kit/core";
import { Wrapper } from "#ui/dnd-kit/components/wrapper/wrapper";
import { cn } from "#ui/lib/utils/cn";
import { TrpcNodeTypeIcon, type TrpcNodeType } from "#ui/custom/trpc/trpc-node-type-icon";
export type NodePath = {
id: UniqueIdentifier;
name: string;
}[];
export interface TabsProps {
id: UniqueIdentifier;
name: string;
path?: NodePath;
isInPreview: boolean;
trpcNodeType: TrpcNodeType;
}
export interface Props {
activationConstraint?: PointerActivationConstraint;
animateLayoutChanges?: AnimateLayoutChanges;
adjustScale?: boolean;
collisionDetection?: CollisionDetection;
coordinateGetter?: KeyboardCoordinateGetter;
Container?: any; // TODO: Fix me
dropAnimation?: DropAnimation | null;
getNewIndex?: NewIndexGetter;
handle?: boolean;
itemCount?: number;
items?: TabsProps[]; // Changed this from `UniqueIdentifier[]`
measuring?: MeasuringConfiguration;
modifiers?: Modifiers;
renderItem?: any;
removable?: boolean;
reorderItems?: typeof arrayMove;
strategy?: SortingStrategy;
style?: React.CSSProperties;
useDragOverlay?: boolean;
getItemStyles?(args: {
id: UniqueIdentifier;
index: number;
isSorting: boolean;
isDragOverlay: boolean;
overIndex: number;
isDragging: boolean;
}): React.CSSProperties;
wrapperStyle?(args: {
active: Pick<Active, "id"> | null;
index: number;
isDragging: boolean;
id: UniqueIdentifier;
}): React.CSSProperties;
isDisabled?(id: UniqueIdentifier): boolean;
}
const screenReaderInstructions: ScreenReaderInstructions = {
draggable: `
To pick up a sortable item, press the space bar.
While sorting, use the arrow keys to move the item.
Press space again to drop the item in its new position, or press escape to cancel.
`,
};
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.5",
},
},
}),
};
export function Sortable({
activationConstraint,
animateLayoutChanges,
adjustScale = false,
Container = List,
collisionDetection = closestCenter,
coordinateGetter = sortableKeyboardCoordinates,
dropAnimation = dropAnimationConfig,
getItemStyles = () => ({}),
getNewIndex,
handle = false,
itemCount = 16,
items: initialItems,
isDisabled = () => false,
measuring,
modifiers,
removable,
renderItem,
reorderItems = arrayMove,
strategy = rectSortingStrategy,
style,
useDragOverlay = true,
wrapperStyle = () => ({}),
}: Props) {
const [items, setItems] = useState<TabsProps[]>(
() =>
initialItems ?? [
{
id: "0194272a-f52c-76b4-8d47-8501c81a93b7",
isInPreview: true,
name: "getCountry",
trpcNodeType: "query",
},
{
id: "0194272b-20d6-79e9-9311-af77ddcc9203",
isInPreview: true,
name: "page.tsx",
trpcNodeType: "query",
},
], // Changed this from `initialItems ??`
// ?? createRange<UniqueIdentifier>(itemCount, (index) => index)
);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const [activeTabId, setActiveTabId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint,
}),
useSensor(TouchSensor, {
activationConstraint,
}),
useSensor(KeyboardSensor, {
// Disable smooth scrolling in Cypress automated tests
scrollBehavior: "Cypress" in window ? "auto" : undefined,
coordinateGetter
}),
);
const isFirstAnnouncement = useRef(true);
const getIndex = (id: UniqueIdentifier) =>
items.findIndex((item) => item.id === id);
const getPosition = (id: UniqueIdentifier) => getIndex(id) + 1;
const activeIndex = activeId != null ? getIndex(activeId) : -1;
const activeItem = items[activeIndex]!;
const handleClick = (id: UniqueIdentifier) => setActiveTabId(id);
const handleRemove = removable
? (id: UniqueIdentifier) =>
setItems((items) => items.filter((item) => item.id !== id))
: undefined;
const announcements: Announcements = {
onDragStart({ active: { id } }) {
return `Picked up sortable item ${String(
id,
)}. Sortable item ${id} is in position ${getPosition(id)} of ${
items.length
}`;
},
onDragOver({ active, over }) {
// In this specific use-case, the picked up item"s `id` is always the same as the first `over` id.
// The first `onDragOver` event therefore doesn"t need to be announced, because it is called
// immediately after the `onDragStart` announcement and is redundant.
if (isFirstAnnouncement.current === true) {
isFirstAnnouncement.current = false;
return;
}
if (over) {
return `Sortable item ${
active.id
} was moved into position ${getPosition(over.id)} of ${items.length}`;
}
return;
},
onDragEnd({ active, over }) {
if (over) {
return `Sortable item ${
active.id
} was dropped at position ${getPosition(over.id)} of ${items.length}`;
}
return;
},
onDragCancel({ active: { id } }) {
return `Sorting was cancelled. Sortable item ${id} was dropped and returned to position ${getPosition(
id,
)} of ${items.length}.`;
},
};
useEffect(() => {
if (activeId == null) {
isFirstAnnouncement.current = true;
}
}, [activeId]);
return (
<DndContext
accessibility={{
announcements,
screenReaderInstructions,
}}
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={({ active }) => {
if (!active) {
return;
}
setActiveId(active.id);
}}
onDragEnd={({ over }) => {
setActiveId(null);
if (over) {
const overIndex = getIndex(over.id);
if (activeIndex !== overIndex) {
setItems((items) => reorderItems(items, activeIndex, overIndex));
}
}
}}
onDragCancel={() => setActiveId(null)}
measuring={measuring}
modifiers={modifiers}
>
<Wrapper style={style} center>
<SortableContext items={items} strategy={strategy}>
<Container>
{items.map((value, index) => (
<SortableItem
key={value.id}
id={value.id}
item={value}
handle={handle}
index={index}
style={getItemStyles}
wrapperStyle={wrapperStyle}
disabled={isDisabled(value.id)}
renderItem={renderItem}
activeTab={activeTabId === value.id}
previewTab={value.isInPreview}
onClick={handleClick}
onRemove={handleRemove}
animateLayoutChanges={animateLayoutChanges}
useDragOverlay={useDragOverlay}
getNewIndex={getNewIndex}
/>
))}
</Container>
</SortableContext>
</Wrapper>
{useDragOverlay
? createPortal(
<DragOverlay
adjustScale={adjustScale}
dropAnimation={dropAnimation}
>
{activeId != null ? (
<Item
value={
<TabBodyItem
name={activeItem.name}
trpcNodeType={activeItem.trpcNodeType}
isInPreview={activeItem.isInPreview}
/>
}
handle={handle}
activeTab={activeTabId === activeItem.id}
renderItem={renderItem}
wrapperStyle={wrapperStyle({
active: { id: activeId },
index: activeIndex,
isDragging: true,
id: activeItem.id,
})}
style={getItemStyles({
id: activeItem.id,
index: activeIndex,
isSorting: activeId !== null,
isDragging: true,
overIndex: -1,
isDragOverlay: true,
})}
dragOverlay
/>
) : null}
</DragOverlay>,
document.body,
)
: null}
</DndContext>
);
}
interface SortableItemProps {
animateLayoutChanges?: AnimateLayoutChanges;
disabled?: boolean;
getNewIndex?: NewIndexGetter;
id: UniqueIdentifier;
item: TabsProps;
index: number;
handle: boolean;
useDragOverlay?: boolean;
activeTab: boolean;
previewTab: boolean;
onClick?(id: UniqueIdentifier): void;
onRemove?(id: UniqueIdentifier): void;
style(values: any): React.CSSProperties;
renderItem?(args: any): React.ReactElement;
wrapperStyle: Props["wrapperStyle"];
}
export function SortableItem({
disabled,
animateLayoutChanges,
getNewIndex,
handle,
id,
item,
index,
activeTab,
previewTab,
onClick,
onRemove,
style,
renderItem,
useDragOverlay,
wrapperStyle,
}: SortableItemProps) {
const {
active,
attributes,
isDragging,
isSorting,
listeners,
overIndex,
setNodeRef,
rect,
setActivatorNodeRef,
transform,
transition,
activeIndex,
} = useSortable({
id,
animateLayoutChanges,
disabled,
getNewIndex,
});
return (
<Item
ref={setNodeRef}
value={
<TabBodyItem
name={item.name}
trpcNodeType={item.trpcNodeType}
isInPreview={item.isInPreview}
/>
}
disabled={disabled}
dragging={isDragging}
sorting={isSorting}
handle={handle}
handleProps={
handle
? {
ref: setActivatorNodeRef,
}
: undefined
}
renderItem={renderItem}
index={index}
style={style({
index,
id,
isDragging,
isSorting,
overIndex,
})}
activeTab={activeTab}
previewTab={item.isInPreview || previewTab}
onClick={onClick ? () => onClick(id) : undefined}
onRemove={onRemove ? () => onRemove(id) : undefined}
transform={transform}
transition={transition}
wrapperStyle={wrapperStyle?.({ index, isDragging, active, id })}
listeners={listeners}
data-index={index}
data-id={id}
dragOverlay={!useDragOverlay && isDragging}
{...attributes}
/>
);
}
function TabBodyItem({
name,
trpcNodeType,
isInPreview,
}: {
name: string;
trpcNodeType: TrpcNodeType;
isInPreview: boolean;
}) {
return (
<div className="flex h-8 items-center space-x-2 px-2 text-xs">
<TrpcNodeTypeIcon trpcNodeType={trpcNodeType} />
<span
>
{name}
</span>
</div>
);
}// Tabs.tsx
import { XIcon } from "lucide-react";
import { MeasuringStrategy } from "@dnd-kit/core";
import {
type AnimateLayoutChanges,
defaultAnimateLayoutChanges,
} from "@dnd-kit/sortable";
import { horizontalListSortingStrategy } from "#trpc-explorer/features/tabs/strategies";
import { Sortable } from "@repo/ui/dnd-kit/sortable";
import { Badge } from "@repo/ui/shadcn-ui/badge";
import { Button } from "@repo/ui/shadcn-ui/button";
import { cn } from "@repo/ui/lib/utils/cn";
import { List } from "@repo/ui/dnd-kit/components/list/list";
export function Tabs() {
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
defaultAnimateLayoutChanges({ ...args, wasDragging: true });
return (
<Sortable
// {...props}
Container={(props: any) => <List horizontal {...props} />}
itemCount={3}
items={[
{
id: "0194272a-f52c-76b4-8d47-8501c81a93b7",
isInPreview: false,
name: "getCountry",
trpcNodeType: "query",
},
{
id: "0194272b-20d6-79e9-9311-af77ddcc9203",
isInPreview: true,
name: "page.tsx",
trpcNodeType: "query",
},
{
id: "0194272b-20d6-79e9-9311-af77ddcc9103",
isInPreview: false,
name: "dest",
trpcNodeType: "subscription",
},
]}
strategy={horizontalListSortingStrategy}
animateLayoutChanges={animateLayoutChanges}
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
activationConstraint={{ distance: 6 }}
removable
/>
);
} |
Beta Was this translation helpful? Give feedback.
Answered by
codesfromshad
Jun 17, 2025
Replies: 1 comment
-
|
I fixed it by having consistent borders. /* Item.module.css */
:root {
--font-weight: 400;
--background-color: #fff;
--border-color: #efefef;
--text-color: #333;
--handle-color: rgba(0, 0, 0, 0.25);
--box-shadow-border: 0 0 0 calc(1px / var(--scale-x, 1)) rgba(63, 63, 68, 0.05);
--box-shadow-common: 0 1px calc(3px / var(--scale-x, 1)) 0 rgba(34, 33, 81, 0.15);
--box-shadow: var(--box-shadow-border), var(--box-shadow-common);
--focused-outline-color: #4c9ffe;
}
@keyframes pop {
0% {
transform: scale(1);
box-shadow: var(--box-shadow);
}
100% {
transform: scale(var(--scale));
box-shadow: var(--box-shadow-picked-up);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.Wrapper {
display: flex;
box-sizing: border-box;
transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0)
scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1));
/* transform-origin: 0 0; */
touch-action: manipulation;
--tw-space-x-reverse: 0;
margin-inline-end: calc(-0.0625rem * var(--tw-space-x-reverse));
margin-inline-start: calc(-0.0625rem * calc(1 - var(--tw-space-x-reverse)));
&.fadeIn {
animation: fadeIn 500ms ease;
}
&.dragOverlay {
--scale: 1.05;
/* --box-shadow: var(--box-shadow); */
/* --box-shadow-picked-up: var(--box-shadow-border),
-1px 0 15px 0 rgba(34, 33, 81, 0.01),
0px 15px 15px 0 rgba(34, 33, 81, 0.25); */
z-index: 999;
}
}
.Item {
position: relative;
display: flex;
flex-grow: 1;
align-items: center;
/* padding: 18px 20px; */
background-color: var(--background);
/* box-shadow: var(--box-shadow); */
outline: none;
/* border-radius: calc(4px / var(--scale-x, 1)); */
border-width: 0.0625rem;
/* border-top-color: hsl(var(--primary)); */
box-sizing: border-box;
list-style: none;
transform-origin: 50% 50%;
-webkit-tap-highlight-color: transparent;
/* color: var(--text-color); */
/* font-weight: var(--font-weight); */
font-size: 1rem;
white-space: nowrap;
/* transform: scale(var(--scale, 1)); */
/* transition: box-shadow 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); */
&.activeTab {
border-width: 0.0625rem;
border-top-color: var(--primary);
border-top-width: 0.0625rem;
border-bottom-width: 0.0625rem;
border-bottom-color: transparent;
}
&.previewTab {
font-style: italic;
}
&:focus-visible {
/* box-shadow: 0 0px 4px 1px var(--focused-outline-color), var(--box-shadow); */
}
&:not(.withHandle) {
touch-action: manipulation;
cursor: pointer;
}
&.dragging:not(.dragOverlay) {
opacity: var(--dragging-opacity, 0.5);
z-index: 0;
&:focus {
/* box-shadow: var(--box-shadow); */
}
}
&.disabled {
color: #999;
background-color: #f1f1f1;
&:focus {
/* box-shadow: 0 0px 4px 1px rgba(0, 0, 0, 0.1), var(--box-shadow); */
}
cursor: not-allowed;
}
&.dragOverlay {
cursor: default;
/* box-shadow: 0 0px 6px 2px $focused-outline-color; */
animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
transform: scale(var(--scale));
box-shadow: var(--box-shadow-picked-up);
border: 0.0625rem solid var(--color-border);
border-top: 0.0625rem solid var(--color-primary);
opacity: 1;
}
&.color:before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0;
height: 100%;
width: 0.1875rem;
display: block;
border-top-left-radius: 0.1875rem;
border-bottom-left-radius: 0.1875rem;
background-color: var(--color);
}
&:hover {
.Remove {
visibility: visible;
}
}
}
.Remove {
visibility: hidden;
border-radius: calc(var(--radius) - 0.125rem);
padding: 0.125rem;
&:hover {
background-color: hsl(var(--accent));
}
svg {
width: 0.75rem;
height: 0.75rem;
}
}
.Actions {
display: flex;
align-self: center;
/* margin-top: -12px;
margin-left: auto;
margin-bottom: -15px;*/
margin-right: 0.5rem;
} |
Beta Was this translation helpful? Give feedback.
0 replies
Answer selected by
codesfromshad
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I fixed it by having consistent borders.