Skip to content

Commit

Permalink
feat: add sortable-list component
Browse files Browse the repository at this point in the history
  • Loading branch information
Jordan-Gilliam committed May 30, 2024
1 parent fd965b3 commit 3730d81
Show file tree
Hide file tree
Showing 10 changed files with 826 additions and 1 deletion.
22 changes: 22 additions & 0 deletions apps/www/__registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"sortable-list": {
name: "sortable-list",
type: "components:ui",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/ui/sortable-list")),
source: "",
files: ["registry/default/ui/sortable-list.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"text-animate-demo": {
name: "text-animate-demo",
type: "components:example",
Expand Down Expand Up @@ -357,6 +368,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"sortable-list-demo": {
name: "sortable-list-demo",
type: "components:example",
registryDependencies: ["sortable-list"],
component: React.lazy(() => import("@/registry/default/example/sortable-list-demo")),
source: "",
files: ["registry/default/example/sortable-list-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"authentication-01": {
name: "authentication-01",
type: "components:block",
Expand Down
6 changes: 5 additions & 1 deletion apps/www/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const forcedThemeFromPathname = pathname === "/" ? "light" : undefined
return (
<JotaiProvider>
<NextThemesProvider {...props} forcedTheme={forcedThemeFromPathname}>
<NextThemesProvider
{...props}
forcedTheme={forcedThemeFromPathname}
defaultTheme="light"
>
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
</NextThemesProvider>
</JotaiProvider>
Expand Down
6 changes: 6 additions & 0 deletions apps/www/config/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export const docsConfig: DocsConfig = {
items: [],
label: "new",
},
{
title: "Sortable List",
href: "/docs/components/sortable-list",
items: [],
label: "new new",
},
{
title: "Shift Card",
href: "/docs/components/shift-card",
Expand Down
112 changes: 112 additions & 0 deletions apps/www/content/docs/components/sortable-list.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
title: SortableList
description: An animated sortable list
component: true
links:
---

<ComponentPreview
name="sortable-list-demo"
className="[&_.preview>[data-orientation=vertical]]:sm:max-w-[70%]"
description="All variations"
/>

## Installation

<Tabs defaultValue="manual">

<TabsList>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>

<TabsContent value="manual">

<Steps>

<Step>Copy and paste the following code into your project.</Step>

<ComponentSource name="sortable-list" />

<Step>Update the import paths to match your project setup.</Step>

</Steps>

</TabsContent>

</Tabs>

## Usage

```tsx
import { useState } from "react"

import {
Item,
SortableList,
SortableListItem,
} from "@/components/ui/sortable-list"
```

```tsx
export default function Example() {
const [items, setItems] = useState<Item[]>([
{ text: "Item 1", checked: false, id: 1, description: "Description 1" },
{ text: "Item 2", checked: false, id: 2, description: "Description 2" },
{ text: "Item 3", checked: false, id: 3, description: "Description 3" },
])

const handleAddItem = () => {
const newItem: Item = {
text: `Item ${items.length + 1}`,
checked: false,
id: items.length + 1,
description: `Description ${items.length + 1}`,
}
setItems([...items, newItem])
}

const handleResetItems = () => {
setItems([])
}

const handleCompleteItem = (id: number) => {
setItems((prevItems) =>
prevItems.map((item) =>
item.id === id ? { ...item, checked: !item.checked } : item
)
)
}

const handleRemoveItem = (id: number) => {
setItems((prevItems) => prevItems.filter((item) => item.id !== id))
}

const renderItem = (
item: Item,
onCompleteItem: (id: number) => void,
onRemoveItem: (id: number) => void
) => (
<SortableListItem
key={item.id}
item={item}
onCompleteItem={onCompleteItem}
onRemoveItem={onRemoveItem}
handleDrag={() => {}}
/>
)

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Sortable List Example</h1>
<SortableList
items={items}
setItems={setItems}
onAddItem={handleAddItem}
onResetItems={handleResetItems}
onCompleteItem={handleCompleteItem}
renderItem={renderItem}
/>
</div>
)
}
```
10 changes: 10 additions & 0 deletions apps/www/public/registry/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,15 @@
"ui/animated-number.tsx"
],
"type": "components:ui"
},
{
"name": "sortable-list",
"dependencies": [
"framer-motion, react-use-measure"
],
"files": [
"ui/sortable-list.tsx"
],
"type": "components:ui"
}
]
13 changes: 13 additions & 0 deletions apps/www/public/registry/styles/default/sortable-list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "sortable-list",
"dependencies": [
"framer-motion, react-use-measure"
],
"files": [
{
"name": "sortable-list.tsx",
"content": "\"use client\"\n\n// npx shadcn-ui@latest add checkbox\n// npm i react-use-measure\nimport { Dispatch, ReactNode, SetStateAction, useState } from \"react\"\nimport {\n AnimatePresence,\n LayoutGroup,\n Reorder,\n motion,\n useDragControls,\n} from \"framer-motion\"\nimport { Plus, RepeatIcon, Trash } from \"lucide-react\"\nimport useMeasure from \"react-use-measure\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\n\nexport type Item = {\n text: string\n checked: boolean\n id: number\n description: string\n}\n\ninterface SortableListItemProps {\n item: Item\n onCompleteItem: (id: number) => void\n onRemoveItem: (id: number) => void\n renderExtra?: (item: Item) => React.ReactNode\n isExpanded?: boolean\n className?: string\n handleDrag: () => void\n}\n\nfunction SortableListItem({\n item,\n onCompleteItem,\n onRemoveItem,\n renderExtra,\n handleDrag,\n isExpanded,\n className,\n}: SortableListItemProps) {\n let [ref, bounds] = useMeasure()\n const [isDragging, setIsDragging] = useState(false)\n const [isDraggable, setIsDraggable] = useState(true)\n const dragControls = useDragControls()\n\n const handleDragStart = (event: any) => {\n setIsDragging(true)\n dragControls.start(event, { snapToCursor: true })\n handleDrag()\n }\n\n const handleDragEnd = () => {\n setIsDragging(false)\n }\n\n return (\n <motion.div\n className={cn(\n \"flex w-full items-center justify-between gap-2\",\n className\n )}\n key={item.id}\n >\n <div className=\"flex w-full items-center justify-between gap-0\">\n <Reorder.Item\n value={item}\n className={cn(\n \"relative z-auto grow\",\n \"shadow-[0_1px_0_0_rgba(255,255,255,0.03)_inset,0_0_0_1px_rgba(255,255,255,0.03)_inset,0_0_0_1px_rgba(0,0,0,0.1),0_2px_2px_0_rgba(0,0,0,0.1),0_4px_4px_0_rgba(0,0,0,0.1),0_8px_8px_0_rgba(0,0,0,0.1)]\",\n item.checked ? \"cursor-not-allowed\" : \"cursor-grab\",\n \"h-full rounded-2xl border border-white/10 bg-[#141712]\",\n item.checked && !isDragging ? \"w-7/10\" : \"w-full\"\n )}\n key={item.id}\n initial={{ opacity: 0 }}\n animate={{\n opacity: 1,\n height: bounds.height > 0 ? bounds.height : undefined,\n transition: { type: \"spring\", bounce: 0, duration: 0.3 },\n }}\n exit={{\n opacity: 0,\n transition: {\n delay: 0,\n duration: 0.05,\n type: \"spring\",\n bounce: 0,\n },\n }}\n layout\n layoutId={`item-${item.id}`}\n dragListener={!item.checked}\n dragControls={dragControls}\n onDragEnd={handleDragEnd}\n style={\n isExpanded\n ? {\n zIndex: 9999,\n marginTop: 10,\n marginBottom: 10,\n position: \"relative\",\n overflow: \"hidden\",\n }\n : {\n position: \"relative\",\n overflow: \"hidden\",\n }\n }\n whileDrag={{ zIndex: 9999 }}\n >\n <div ref={ref} className=\"z-20\">\n <motion.div layout=\"position\" className=\"flex items-center gap-4 \">\n <AnimatePresence>\n {!isExpanded ? (\n <motion.div\n initial={{ opacity: 0, filter: \"blur(4px)\" }}\n animate={{ opacity: 1, filter: \"blur(0px)\" }}\n exit={{ opacity: 0, filter: \"blur(4px)\" }}\n transition={{ duration: 0.001 }}\n className=\"flex w-full items-center gap-4 p-1\"\n >\n <Checkbox\n checked={item.checked}\n id={`checkbox-${item.id}`}\n aria-label=\"Mark as done\"\n onCheckedChange={() => onCompleteItem(item.id)}\n className=\"ml-3 h-5 w-5 rounded-md border-neutral-400/80 bg-black \"\n />\n <motion.span className=\"w-full px-1 text-lg tracking-tight text-neutral-300/90\">\n {item.text}\n </motion.span>\n </motion.div>\n ) : null}\n </AnimatePresence>\n {renderExtra && renderExtra(item)}\n </motion.div>\n </div>\n <div\n onPointerDown={isDraggable ? handleDragStart : undefined}\n style={{ touchAction: \"none\" }}\n />\n </Reorder.Item>\n <AnimatePresence mode=\"popLayout\">\n {item.checked ? (\n <motion.div\n layout\n initial={{ opacity: 0, x: -1 }}\n animate={{\n opacity: 1,\n x: 0,\n transition: {\n delay: 0.17,\n duration: 0.2,\n type: \"spring\",\n bounce: 0.3,\n },\n zIndex: 5,\n }}\n exit={{\n opacity: 0,\n x: -5,\n transition: {\n delay: 0,\n duration: 0.05,\n type: \"spring\",\n bounce: 0,\n },\n }}\n className=\"-ml-2 h-[3.1rem] w-4 rounded-l-sm rounded-r-md border-y border-r border-y-white/10 border-r-white/10 bg-[#141712] shadow-[0_1px_0_0_rgba(255,255,255,0.03)_inset,0_0_0_1px_rgba(255,255,255,0.03)_inset,0_0_0_1px_rgba(0,0,0,0.1),0_2px_2px_0_rgba(0,0,0,0.1),0_4px_4px_0_rgba(0,0,0,0.1),0_8px_8px_0_rgba(0,0,0,0.1)]\"\n />\n ) : null}\n </AnimatePresence>\n <AnimatePresence mode=\"popLayout\">\n {item.checked ? (\n <motion.div\n layout\n initial={{ opacity: 0, x: -5, filter: \"blur(4px)\" }}\n animate={{\n opacity: 1,\n x: 0,\n filter: \"blur(0px)\",\n transition: {\n delay: 0.3,\n duration: 0.15,\n type: \"spring\",\n bounce: 0.9,\n },\n }}\n exit={{\n opacity: 0,\n filter: \"blur(4px)\",\n x: -10,\n transition: { delay: 0, duration: 0.12 },\n }}\n className=\"inset-0 z-0 border-spacing-1 rounded-r-xl border-y border-r-2 border-y-white/10 border-r-red-300 bg-[#141712]/80 shadow-[0_1px_0_0_rgba(255,255,255,0.03)_inset,0_0_0_1px_rgba(255,255,255,0.03)_inset,0_0_0_1px_rgba(0,0,0,0.1),0_2px_2px_0_rgba(0,0,0,0.1),0_4px_4px_0_rgba(0,0,0,0.1),0_8px_8px_0_rgba(0,0,0,0.1)] dark:bg-[#141712]/50\"\n >\n <button\n className=\" group inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-3 text-sm font-medium ring-offset-background transition-colors duration-150 hover:bg-[#141712] hover:text-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\"\n onClick={() => onRemoveItem(item.id)}\n >\n <Trash className=\"h-4 w-4 text-red-400 transition-colors duration-150 group-hover:fill-red-400/60\" />\n </button>\n </motion.div>\n ) : null}\n </AnimatePresence>\n </div>\n </motion.div>\n )\n}\n\nSortableListItem.displayName = \"SortableListItem\"\n\ninterface SortableListProps {\n items: Item[]\n setItems: Dispatch<SetStateAction<Item[]>>\n onAddItem: () => void\n onResetItems: () => void\n onCompleteItem: (id: number) => void\n renderItem: (\n item: Item,\n onCompleteItem: (id: number) => void,\n onRemoveItem: (id: number) => void\n ) => ReactNode\n}\n\nfunction SortableList({\n items,\n setItems,\n onAddItem,\n onResetItems,\n onCompleteItem,\n renderItem,\n}: SortableListProps) {\n if (items) {\n return (\n <div className=\"mb-9 rounded-2xl border border-black/5 p-2 shadow-sm md:p-6 \">\n <div className=\" overflow-auto p-4\">\n <div className=\"flex flex-col space-y-2\">\n <div className=\"\">\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"256\"\n height=\"260\"\n preserveAspectRatio=\"xMidYMid\"\n viewBox=\"0 0 256 260\"\n className=\"h-6 w-6\"\n >\n <path d=\"M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z\" />\n </svg>\n <h3>Agent workflow</h3>\n </div>\n <div className=\"flex items-center justify-between gap-4\">\n <button\n className=\"flex items-center gap-1 rounded-md border border-black/10 p-2 disabled:opacity-50\"\n disabled={items?.length > 5}\n onClick={onAddItem}\n >\n <Plus className=\"dark:text-netural-100 h-4 w-4 text-neutral-800\" />\n New stage\n </button>\n <div data-tip=\"Reset task list\">\n <button onClick={onResetItems}>\n <RepeatIcon className=\"dark:text-netural-100 h-4 w-4 text-neutral-800\" />\n </button>\n </div>\n </div>\n <LayoutGroup>\n <Reorder.Group\n axis=\"y\"\n values={items}\n onReorder={setItems}\n className=\"flex flex-col\"\n >\n <AnimatePresence>\n {items?.map((item) =>\n renderItem(item, onCompleteItem, (id: number) =>\n setItems((items) =>\n items.filter((item) => item.id !== id)\n )\n )\n )}\n </AnimatePresence>\n </Reorder.Group>\n </LayoutGroup>\n </div>\n </div>\n </div>\n )\n }\n return null\n}\n\nSortableList.displayName = \"SortableList\"\n\nexport { SortableList, SortableListItem }\nexport default SortableList\n"
}
],
"type": "components:ui"
}
Loading

0 comments on commit 3730d81

Please sign in to comment.