create generic list component

This commit is contained in:
2025-06-01 00:39:47 +01:00
parent 4bc5630922
commit 6625451d47
5 changed files with 516 additions and 639 deletions

View File

@@ -1,259 +1,105 @@
import { create } from "zustand/react"
import { subscribeWithSelector } from "zustand/middleware"
import type { Collection } from "@markone/core"
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
import { clsx } from "clsx"
import { memo } from "react"
import { Button } from "~/components/button"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { DialogKind, useCollectionPageStore } from "./-store"
import { useStore } from "zustand"
import { Link } from "~/components/link"
import { List } from "~/components/list"
export enum CollectionListItemAction {
Delete = "Delete",
Edit = "Edit",
}
type ItemActionCallback = (collection: Collection, action: CollectionListItemAction) => void
interface CollectionListProps {
collections: Collection[]
className?: string
}
interface CreateStoreOptions {
collections: Collection[]
}
interface CollectionListState {
collections: Collection[]
selectedIndex: number
selectedCollectionId: string
isItemExpanded: boolean
setCollections: (collections: Collection[]) => void
setSelectedIndex: (index: number) => void
setSelectedCollectionId: (id: string) => void
setIsItemExpanded: (expanded: boolean) => void
}
type CollectionListStore = ReturnType<typeof createCollectionListStore>
const CollectionListStoreContext = createContext<CollectionListStore | null>(null)
function createCollectionListStore({ collections }: CreateStoreOptions) {
return create<CollectionListState>()(
subscribeWithSelector((set) => ({
collections,
selectedIndex: 0,
selectedCollectionId: collections.length > 0 ? collections[0].id : "",
isItemExpanded: false,
setCollections(collections: Collection[]) {
set({ collections, selectedCollectionId: collections.length > 0 ? collections[0].id : "", selectedIndex: 0 })
},
setSelectedIndex(index: number) {
set({ selectedIndex: index })
},
setSelectedCollectionId(id: string) {
set({ selectedCollectionId: id })
},
setIsItemExpanded(expanded: boolean) {
set({ isItemExpanded: expanded })
},
})),
)
}
function useCollectionListStoreContext() {
const store = useContext(CollectionListStoreContext)
if (!store) throw new Error("CollectionListStoreContext not found")
return store
}
function useCollectionListStore<T>(selector: (state: CollectionListState) => T): T {
const store = useCollectionListStoreContext()
return useStore(store, selector)
}
function CollectionList({ collections, className }: CollectionListProps) {
const storeRef = useRef<CollectionListStore | null>(null)
if (!storeRef.current) {
storeRef.current = createCollectionListStore({ collections })
}
useEffect(() => {
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
const store = storeRef.current!
store.getState().setCollections(collections)
}, [collections])
return (
<CollectionListStoreContext.Provider value={storeRef.current}>
<_CollectionList className={className} />
</CollectionListStoreContext.Provider>
)
}
const _CollectionList = memo(({ className }: { className?: string }) => {
const store = useCollectionListStoreContext()
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
useMnemonics(
{
j: selectNextItem,
ArrowDown: selectNextItem,
k: selectPrevItem,
ArrowUp: selectPrevItem,
h: collapseItem,
ArrowLeft: collapseItem,
l: expandItem,
ArrowRight: expandItem,
d: deleteItem,
e: editItem,
},
{
ignore: useCallback(() => useCollectionPageStore.getState().dialog.kind !== DialogKind.None, []),
},
)
function deleteItem() {
const { collections, selectedIndex } = store.getState()
handleCollectionListAction(collections[selectedIndex], CollectionListItemAction.Delete)
}
function editItem() {
const { collections, selectedIndex } = store.getState()
handleCollectionListAction(collections[selectedIndex], CollectionListItemAction.Edit)
}
function selectPrevItem() {
const { collections, selectedIndex, setSelectedCollectionId } = store.getState()
const prevIndex = selectedIndex - 1
if (prevIndex >= 0) {
setSelectedCollectionId(collections[prevIndex].id)
}
}
function selectNextItem() {
const { collections, selectedIndex, setSelectedCollectionId } = store.getState()
const nextIndex = selectedIndex + 1
if (nextIndex < collections.length) {
setSelectedCollectionId(collections[nextIndex].id)
}
}
function expandItem() {
store.getState().setIsItemExpanded(true)
}
function collapseItem() {
store.getState().setIsItemExpanded(false)
}
return (
<ul className={clsx("flex flex-col -mt-2", className)}>
<ListContainer />
</ul>
)
})
function ListContainer() {
const collections = useCollectionListStore((state) => state.collections)
const selectedItemId = useCollectionListStore((state) => state.selectedCollectionId)
return collections.length === 0 ? (
<p>You have not created any collections!</p>
) : (
collections.map((collection, index) => (
<CollectionListItem
key={collection.id}
collection={collection}
selected={collection.id === selectedItemId}
index={index}
/>
))
)
onItemAction: (collection: Collection, action: CollectionListItemAction) => void
}
const CollectionListItem = memo(
({ collection, selected, index }: { collection: Collection; selected: boolean; index: number }) => {
const store = useCollectionListStoreContext()
const isItemExpanded = useCollectionListStore((state) => state.isItemExpanded)
const setSelectedCollectionId = useCollectionListStore((state) => state.setSelectedCollectionId)
const setIsItemExpanded = useCollectionListStore((state) => state.setIsItemExpanded)
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
useEffect(() => {
if (selected) {
store.getState().setSelectedIndex(index)
}
}, [selected, index, store])
function onItemHover() {
if (!store.getState().isItemExpanded) {
setSelectedCollectionId(collection.id)
}
}
function deleteItem() {
handleCollectionListAction(collection, CollectionListItemAction.Delete)
}
function editItem() {
handleCollectionListAction(collection, CollectionListItemAction.Edit)
}
return (
<li
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isItemExpanded && selected,
"text-teal-600": selected && !isItemExpanded,
({
collection,
isSelected,
isExpanded,
onSelect,
onExpand,
onItemAction,
}: {
collection: Collection
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
onItemAction: (collection: Collection, action: CollectionListItemAction) => void
}) => (
<div
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isExpanded,
"text-teal-600": isSelected && !isExpanded,
})}
>
<button
type="button"
disabled={!isSelected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !isSelected,
})}
onMouseEnter={onItemHover}
onClick={onExpand}
>
<button
type="button"
disabled={!selected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !selected,
})}
onClick={() => {
setIsItemExpanded(!isItemExpanded)
}}
>
<span className="sr-only">Options for this collection</span>
<span>&nbsp;</span>
<span className={isItemExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<div className="block w-full text-start font-bold">{collection.name}</div>
<p className="opacity-80 text-sm">{collection.description}</p>
{isItemExpanded && selected ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<div className="flex space-x-2">
<Button variant="light" className="text-sm" onClick={editItem}>
<span className="underline">E</span>DIT
</Button>
<Button variant="light" className="text-sm" onClick={deleteItem}>
<span className="underline">D</span>ELETE
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
<span className="sr-only">Options for this collection</span>
<span>&nbsp;</span>
<span className={isExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<div className="block w-full text-start font-bold">
<Link href="#">{collection.name}</Link>
</div>
</li>
)
},
<p className="opacity-80 text-sm">{collection.description}</p>
{isExpanded ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<div className="flex space-x-2">
<Button
variant="light"
className="text-sm"
onClick={() => onItemAction(collection, CollectionListItemAction.Edit)}
>
<span className="underline">E</span>DIT
</Button>
<Button
variant="light"
className="text-sm"
onClick={() => onItemAction(collection, CollectionListItemAction.Delete)}
>
<span className="underline">D</span>ELETE
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
</div>
</div>
),
)
function CollectionList({ collections, className, onItemAction }: CollectionListProps) {
return (
<List
className="-mt-2"
items={collections}
emptyMessage="No collections found!"
renderItem={({ item: collection, isSelected, isExpanded, onSelect, onExpand }) => (
<CollectionListItem
collection={collection}
isSelected={isSelected}
isExpanded={isExpanded}
onSelect={onSelect}
onExpand={onExpand}
onItemAction={onItemAction}
/>
)}
/>
)
}
export { CollectionList }
export type { CollectionListState }