Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/react": "^0.3.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@wordpress/dataviews": "^11.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand All @@ -83,6 +88,7 @@
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@wordpress/scripts": "^30.25.0",
"autoprefixer": "^10.4.23",
"babel-loader": "^9.2.0",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
Expand Down
362 changes: 362 additions & 0 deletions src/components/ui/Sortable.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { GripVertical, GripHorizontal, X, Pencil, Check } from "lucide-react";
import {
verticalListSortingStrategy,
horizontalListSortingStrategy,
rectSortingStrategy,
} from "@dnd-kit/sortable";

import {
Sortable,
SortableItem,
SortableDragHandle,
SortableProvider,
} from "./sortable";
import { Button } from "./button";
import { Input } from "./input";
import { Switch } from "./switch";
import { cn } from "@/lib/utils";

const meta = {
title: "UI/Sortable",
component: Sortable,
parameters: { layout: "centered" },
tags: ["autodocs"],
} satisfies Meta<typeof Sortable>;

export default meta;

type Story = StoryObj<typeof meta>;

const initialItems = [
{ id: "1", content: "Item 1" },
{ id: "2", content: "Item 2" },
{ id: "3", content: "Item 3" },
{ id: "4", content: "Item 4" },
{ id: "5", content: "Item 5" },
];

export const AdvancedCustomUI: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState([
{ id: "1", label: "Dashboard", checked: false },
{ id: "2", label: "Products", checked: true },
{ id: "3", label: "Address", checked: true },
{ id: "4", label: "Request Quotes", checked: true },
{ id: "5", label: "Coupons", checked: true },
{ id: "6", label: "Reports", checked: true },
{ id: "7", label: "Delivery Time", checked: true },
{ id: "8", label: "Reviews", checked: true },
{ id: "9", label: "Withdraw", checked: false },
]);
const [editingId, setEditingId] = useState<string | null>("3");

const toggleChecked = (id: string) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, checked: !item.checked } : item
)
);
};

return (
<div className="w-[600px] overflow-hidden rounded-lg border bg-background shadow-sm">
<Sortable items={items} onValueChange={setItems}>
<div className="flex flex-col">
{items.map((item) => (
<SortableItem
key={item.id}
id={item.id}
useHandle
className="group border-b last:border-0"
>
<div className="flex h-14 items-center gap-4 px-4 py-2 transition-colors hover:bg-muted/30">
<SortableDragHandle className="text-muted-foreground/50 hover:text-foreground">
<GripVertical className="size-5" />
</SortableDragHandle>

<div className="flex flex-1 items-center">
{editingId === item.id ? (
<div className="relative flex-1">
<Input
defaultValue={item.label}
className="h-9 border-primary/50 ring-primary/20 focus-visible:border-primary focus-visible:ring-primary/30"
autoFocus
/>
<button
onClick={() => setEditingId(null)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
Comment on lines +85 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Icon-only <button> is missing an aria-label.

The native <button> wrapping the X icon (cancel editing) has no accessible name. Screen readers will announce it without meaningful context.

♿ Proposed fix
-            <button
-              onClick={() => setEditingId(null)}
-              className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
-            >
+            <button
+              onClick={() => setEditingId(null)}
+              aria-label="Cancel editing"
+              className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+            >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onClick={() => setEditingId(null)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
<button
onClick={() => setEditingId(null)}
aria-label="Cancel editing"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/Sortable.stories.tsx` around lines 85 - 90, The icon-only
<button> that calls setEditingId(null) and renders the X component lacks an
accessible name; update that button to include an appropriate aria-label (e.g.,
aria-label="Cancel editing" or "Close") so screen readers get meaningful context
for the action triggered by the X icon, and ensure the X icon itself remains
decorative (aria-hidden) if you add the label.

</div>
) : (
<span className="text-sm font-medium">{item.label}</span>
)}
</div>

<div className="flex items-center gap-3">
{editingId === item.id ? (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 border-muted"
onClick={() => setEditingId(null)}
>
<Check className="size-4 text-muted-foreground" />
</Button>
Comment on lines +78 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edits in AdvancedCustomUI are silently discarded — demo implies saving but never updates state.

The Input uses defaultValue (uncontrolled), so its value is never read back. The "save" action (Check button, line 99 and the X button, line 86) only calls setEditingId(null) without committing the new label to items state. Users interacting with the demo will type a label change, click save, and see it revert — which misrepresents the editing capability.

Either make it a read-only view-mode toggle (remove the Input entirely), or wire up a controlled flow:

🐛 Proposed fix to persist edits
+      const [editValues, setEditValues] = useState<Record<string, string>>(
+        Object.fromEntries(items.map((i) => [i.id, i.label]))
+      );
+
+      const saveEdit = (id: string) => {
+        setItems((prev) =>
+          prev.map((item) =>
+            item.id === id ? { ...item, label: editValues[id] } : item
+          )
+        );
+        setEditingId(null);
+      };

         {editingId === item.id ? (
           <div className="relative flex-1">
             <Input
-              defaultValue={item.label}
+              value={editValues[item.id]}
+              onChange={(e) =>
+                setEditValues((prev) => ({ ...prev, [item.id]: e.target.value }))
+              }
               className="..."
               autoFocus
             />
             <button
-              onClick={() => setEditingId(null)}
+              onClick={() => saveEdit(item.id)}
               ...
             >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/Sortable.stories.tsx` around lines 78 - 106, The edit UI is
currently uncontrolled and never persists changes: the Input uses defaultValue
and the save/cancel handlers (setEditingId in the X and Check button handlers)
only close edit mode without updating the items state. Change to a controlled
edit flow by adding a temporary edit value (e.g., editingValue) when entering
edit mode for the specific item (set editingValue = item.label in the handler
that sets editingId), bind that editingValue to the Input (onChange updates
editingValue), and on the Check button click update the items state (use
setItems to map the item with matching item.id and replace its label with
editingValue) then setEditingId(null); keep the X button to discard changes by
just clearing editingId (and optionally clearing editingValue).

) : (
<Button
variant="ghost"
size="icon-sm"
className="h-8 w-8 text-muted-foreground/50 hover:bg-muted hover:text-foreground"
onClick={() => setEditingId(item.id)}
>
<Pencil className="size-4" />
</Button>
)}
<Switch
checked={item.checked}
onCheckedChange={() => toggleChecked(item.id)}
/>
</div>
</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const Vertical: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState(initialItems);
return (
<div className="w-80">
<Sortable items={items} onValueChange={setItems}>
<div className="flex flex-col gap-2">
{items.map((item) => (
<SortableItem key={item.id} id={item.id}>
<div className="flex items-center justify-between rounded-md border bg-card p-4 shadow-sm">
<span>{item.content}</span>
</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const WithDragHandle: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState(initialItems);
return (
<div className="w-80">
<Sortable items={items} onValueChange={setItems}>
<div className="flex flex-col gap-2">
{items.map((item) => (
<SortableItem key={item.id} id={item.id} useHandle>
<div className="flex items-center gap-3 rounded-md border bg-card p-3 shadow-sm">
<SortableDragHandle className="text-muted-foreground hover:text-foreground">
<GripVertical className="size-5" />
</SortableDragHandle>
<span className="flex-1 font-medium">{item.content}</span>
<Button variant="ghost" size="icon-sm">
<X className="size-4" />
</Button>
Comment on lines +173 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove/action Button in WithDragHandle has no onClick handler.

The X icon button appears interactive but does nothing when clicked. In a demo, this creates confusion about whether removal is supported. Either wire up a remove handler or replace it with a non-interactive element.

🐛 Proposed fix to add a remove handler
-                    <Button variant="ghost" size="icon-sm">
+                    <Button
+                      variant="ghost"
+                      size="icon-sm"
+                      aria-label="Remove item"
+                      onClick={() =>
+                        setItems((prev) => prev.filter((i) => i.id !== item.id))
+                      }
+                    >
                       <X className="size-4" />
                     </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button variant="ghost" size="icon-sm">
<X className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Remove item"
onClick={() =>
setItems((prev) => prev.filter((i) => i.id !== item.id))
}
>
<X className="size-4" />
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/Sortable.stories.tsx` around lines 173 - 175, In the
WithDragHandle story in Sortable.stories.tsx the remove/action Button (the X
icon) is missing an onClick handler; either wire that Button up to call the
story's remove function (e.g., call an existing handleRemove or onRemove item
callback used by the Sortable list) or add a local handler that updates the
story's items state to remove the corresponding item id when clicked; if removal
isn't supported in this story, replace the interactive Button with a
non-interactive element (e.g., a span) to avoid implying interactivity. Ensure
you modify the Button inside the WithDragHandle render so it receives
onClick={() => removeItem(id)} (or similar) and that removeItem is defined and
wired to the same state/props managing the list.

</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const Horizontal: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState(initialItems);
return (
<div className="w-full max-w-2xl overflow-x-auto p-4">
<Sortable
items={items}
onValueChange={setItems}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-3">
{items.map((item) => (
<SortableItem key={item.id} id={item.id} className="shrink-0">
<div className="flex h-20 w-32 items-center justify-center rounded-lg border bg-card shadow-sm">
{item.content}
</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const Grid: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState([
...initialItems,
{ id: "6", content: "Item 6" },
{ id: "7", content: "Item 7" },
{ id: "8", content: "Item 8" },
{ id: "9", content: "Item 9" },
]);
return (
<div className="w-[500px] p-4">
<Sortable
items={items}
onValueChange={setItems}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-3 gap-4">
{items.map((item) => (
<SortableItem key={item.id} id={item.id}>
<div className="flex aspect-square items-center justify-center rounded-xl border-2 bg-card text-lg font-bold shadow-sm transition-colors hover:border-primary/50">
{item.content.split(" ")[1]}
</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const CustomGapsAndPadding: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState(initialItems);
return (
<div className="rounded-xl border bg-muted/30 p-8 shadow-inner">
<Sortable items={items} onValueChange={setItems}>
<div className="flex flex-col gap-6">
{items.map((item) => (
<SortableItem key={item.id} id={item.id} useHandle>
<div className="group flex items-center gap-4 rounded-2xl bg-background p-6 shadow-md transition-all hover:shadow-lg">
<SortableDragHandle className="rounded-lg bg-muted p-2 text-muted-foreground transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<GripVertical className="size-6" />
</SortableDragHandle>
<div className="flex-1">
<h4 className="font-bold">{item.content}</h4>
<p className="text-sm text-muted-foreground">Customizable padding and gaps</p>
</div>
</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const DragOverlayExample: Story = {
render: () => {
function Demo() {
const [items, setItems] = useState(initialItems);
return (
<div className="w-80">
<Sortable
items={items}
onValueChange={setItems}
overlay={
<div className="flex items-center gap-3 rounded-md border border-primary bg-primary/10 p-3 shadow-xl backdrop-blur-sm">
<GripVertical className="size-5 text-primary" />
<span className="font-bold text-primary">Dragging...</span>
</div>
}
>
<div className="flex flex-col gap-2">
{items.map((item) => (
<SortableItem key={item.id} id={item.id} useHandle>
<div className="flex items-center gap-3 rounded-md border bg-card p-3 shadow-sm">
<SortableDragHandle className="text-muted-foreground">
<GripVertical className="size-5" />
</SortableDragHandle>
<span className="flex-1">{item.content}</span>
</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
);
}
return <Demo />;
},
};

export const MultipleLists: Story = {
render: () => {
function Demo() {
const [itemsA, setItemsA] = useState([
{ id: "A1", content: "A1" },
{ id: "A2", content: "A2" },
]);
const [itemsB, setItemsB] = useState([
{ id: "B1", content: "B1" },
{ id: "B2", content: "B2" },
]);

return (
<div className="flex gap-8 p-4">
<div className="w-48 space-y-4">
<h3 className="font-bold">List A</h3>
<Sortable items={itemsA} onValueChange={setItemsA}>
<div className="min-h-[100px] rounded-lg border bg-muted/50 p-2 space-y-2">
{itemsA.map(item => (
<SortableItem key={item.id} id={item.id}>
<div className="rounded border bg-card p-2 shadow-sm text-center">{item.content}</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
<div className="w-48 space-y-4">
<h3 className="font-bold">List B</h3>
<Sortable items={itemsB} onValueChange={setItemsB}>
<div className="min-h-[100px] rounded-lg border bg-muted/50 p-2 space-y-2">
{itemsB.map(item => (
<SortableItem key={item.id} id={item.id}>
<div className="rounded border bg-card p-2 shadow-sm text-center">{item.content}</div>
</SortableItem>
))}
</div>
</Sortable>
</div>
</div>
);
}
return <Demo />;
},
};
Comment on lines +316 to +359

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

MultipleLists story doesn't demonstrate cross-list dragging.

Each list has its own independent <Sortable> (and thus its own DndContext), so items can only be reordered within each list, not dragged between them. If cross-list DnD is intended, both lists would need to share a single SortableProvider. If the intent is just "two independent sortable lists on one page," consider renaming to something like IndependentLists or SideBySideLists to set correct expectations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/Sortable.stories.tsx` around lines 319 - 362, The
MultipleLists story uses two independent Sortable components (each with its own
DndContext) so items cannot be dragged between lists; either change the
implementation to share a single SortableProvider/DndContext around both lists
to enable cross-list dragging (wrap both lists with a common SortableProvider or
move a single DndContext to encompass both Sortable instances and coordinate on
a shared onValueChange handler for itemsA/itemsB and SortableItem ids), or if
cross-list dragging is not desired, rename MultipleLists to IndependentLists or
SideBySideLists to make behavior clear; update the story accordingly
(referencing MultipleLists, Sortable, SortableProvider/DndContext, SortableItem,
itemsA/itemsB, setItemsA/setItemsB).

Loading
Loading