create generic list component
This commit is contained in:
@@ -1,112 +1,125 @@
|
|||||||
import type { Bookmark } from "@markone/core"
|
import type { Bookmark } from "@markone/core"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
|
import { memo, useCallback } from "react"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { createStore, useStore } from "zustand"
|
|
||||||
import { subscribeWithSelector } from "zustand/middleware"
|
|
||||||
import { useBookmarkTags } from "~/bookmark/api"
|
import { useBookmarkTags } from "~/bookmark/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
import { List } from "~/components/list"
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
|
||||||
import { DialogKind, useBookmarkPageStore } from "./-store"
|
import { DialogKind, useBookmarkPageStore } from "./-store"
|
||||||
|
|
||||||
const LONG_PRESS_DELAY_MS = 500
|
export enum BookmarkListItemAction {
|
||||||
|
|
||||||
enum BookmarkListItemAction {
|
|
||||||
Open = "Open",
|
Open = "Open",
|
||||||
Edit = "Edit",
|
Edit = "Edit",
|
||||||
Delete = "Delete",
|
Delete = "Delete",
|
||||||
CopyLink = "CopyLink",
|
CopyLink = "CopyLink",
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectionChangeCallback = (bookmark: Bookmark) => void
|
|
||||||
type ItemActionCallback = (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
selectedBookmarkId?: string
|
selectedBookmarkId?: string
|
||||||
alwaysExpandItem: boolean
|
alwaysExpandItem: boolean
|
||||||
onSelectionChange?: SelectionChangeCallback
|
onSelectionChange?: (bookmark: Bookmark) => void
|
||||||
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateStoreOptions {
|
const BookmarkTagList = memo(function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
|
||||||
bookmarks: Bookmark[]
|
const { data: tags } = useBookmarkTags(bookmark)
|
||||||
selectedBookmarkId?: string
|
|
||||||
alwaysExpandItem: boolean
|
|
||||||
onItemAction: ItemActionCallback
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookmarkListState {
|
return tags ? (
|
||||||
bookmarks: Bookmark[]
|
<div className="flex flex-wrap gap-1 pt-2">
|
||||||
selectedIndex: number
|
{tags.map((tag) => (
|
||||||
selectedBookmarkId: string
|
<span key={tag.id} className="text-xs bg-stone-100 text-stone-900 px-2 py-0.5 rounded">
|
||||||
alwaysExpandItem: boolean
|
{tag.name}
|
||||||
isItemExpanded: boolean
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})
|
||||||
|
|
||||||
onItemAction: ItemActionCallback
|
const BookmarkListItem = memo(function BookmarkListItem({
|
||||||
setBookmarks: (bookmarks: Bookmark[]) => void
|
bookmark,
|
||||||
setSelectedIndex: (index: number) => void
|
isSelected,
|
||||||
setSelectedBookmarkId: (id: string) => void
|
isExpanded,
|
||||||
setIsItemExpanded: (expanded: boolean) => void
|
onSelect,
|
||||||
}
|
onExpand,
|
||||||
|
|
||||||
type BookmarkListStore = ReturnType<typeof createBookmarkListStore>
|
|
||||||
|
|
||||||
const BookmarkListStoreContext = createContext<BookmarkListStore | null>(null)
|
|
||||||
|
|
||||||
function createBookmarkListStore({
|
|
||||||
bookmarks,
|
|
||||||
selectedBookmarkId,
|
|
||||||
alwaysExpandItem,
|
|
||||||
onItemAction,
|
onItemAction,
|
||||||
}: CreateStoreOptions) {
|
alwaysExpandItem,
|
||||||
let _selectedBookmarkId = selectedBookmarkId
|
}: {
|
||||||
if (!_selectedBookmarkId && bookmarks.length > 0) {
|
bookmark: Bookmark
|
||||||
_selectedBookmarkId = bookmarks[0].id
|
isSelected: boolean
|
||||||
}
|
isExpanded: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onExpand: () => void
|
||||||
|
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
||||||
|
alwaysExpandItem: boolean
|
||||||
|
}) {
|
||||||
|
const url = new URL(bookmark.url)
|
||||||
|
|
||||||
return createStore<BookmarkListState>()(
|
return (
|
||||||
subscribeWithSelector((set) => ({
|
<div
|
||||||
bookmarks,
|
className={clsx("group flex flex-row justify-start py-2", {
|
||||||
alwaysExpandItem,
|
"bg-teal-600 text-stone-100": isExpanded,
|
||||||
selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0,
|
"text-teal-600": isSelected && !isExpanded,
|
||||||
selectedBookmarkId: _selectedBookmarkId ?? "",
|
})}
|
||||||
isItemExpanded: alwaysExpandItem,
|
>
|
||||||
|
<button
|
||||||
onItemAction,
|
type="button"
|
||||||
|
disabled={alwaysExpandItem || !isSelected}
|
||||||
setBookmarks(bookmarks: Bookmark[]) {
|
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
||||||
set({ bookmarks, selectedBookmarkId: bookmarks.length > 0 ? bookmarks[0].id : "", selectedIndex: 0 })
|
invisible: !isSelected,
|
||||||
},
|
})}
|
||||||
|
onClick={onExpand}
|
||||||
setSelectedIndex(index: number) {
|
>
|
||||||
set({ selectedIndex: index })
|
<span className="sr-only">Options for this bookmark</span>
|
||||||
},
|
<span> </span>
|
||||||
|
<span className={isExpanded ? "rotate-90" : ""}>></span>
|
||||||
setSelectedBookmarkId(id: string) {
|
<span> </span>
|
||||||
set({ selectedBookmarkId: id })
|
</button>
|
||||||
},
|
<div className="flex flex-col w-full">
|
||||||
|
<Link
|
||||||
setIsItemExpanded(expanded: boolean) {
|
to={`/bookmarks/${bookmark.id}`}
|
||||||
set({ isItemExpanded: expanded })
|
className={clsx("block w-full text-start font-bold", { underline: isSelected })}
|
||||||
},
|
>
|
||||||
})),
|
{bookmark.title}
|
||||||
|
</Link>
|
||||||
|
<p className="opacity-80 text-sm">{url.host}</p>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<BookmarkTagList bookmark={bookmark} />
|
||||||
|
<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(bookmark, BookmarkListItemAction.CopyLink)}
|
||||||
|
>
|
||||||
|
<span>COPY LINK</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
className="text-sm"
|
||||||
|
onClick={() => onItemAction(bookmark, BookmarkListItemAction.Edit)}
|
||||||
|
>
|
||||||
|
<span className="underline">E</span>DIT
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
className="text-sm"
|
||||||
|
onClick={() => onItemAction(bookmark, BookmarkListItemAction.Delete)}
|
||||||
|
>
|
||||||
|
<span className="underline">D</span>ELETE
|
||||||
|
</Button>
|
||||||
|
<span className="-ml-2"> </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function useBookmarkListStoreContext() {
|
|
||||||
const store = useContext(BookmarkListStoreContext)
|
|
||||||
if (!store) throw new Error("BookmarkListStoreContext not found")
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
function useBookmarkListStore<T>(selector: (state: BookmarkListState) => T): T {
|
|
||||||
const store = useBookmarkListStoreContext()
|
|
||||||
return useStore(store, selector)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookmarkList({
|
function BookmarkList({
|
||||||
bookmarks,
|
bookmarks,
|
||||||
@@ -116,295 +129,38 @@ function BookmarkList({
|
|||||||
onItemAction,
|
onItemAction,
|
||||||
className,
|
className,
|
||||||
}: BookmarkListProps) {
|
}: BookmarkListProps) {
|
||||||
const storeRef = useRef<BookmarkListStore | null>(null)
|
const handleSelect = useCallback(
|
||||||
if (!storeRef.current) {
|
(bookmark: Bookmark) => {
|
||||||
storeRef.current = createBookmarkListStore({ bookmarks, selectedBookmarkId, alwaysExpandItem, onItemAction })
|
onSelectionChange?.(bookmark)
|
||||||
}
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
)
|
||||||
|
|
||||||
const setSelectedBookmarkId = useStore(storeRef.current, (state) => state.setSelectedBookmarkId)
|
const handleExpand = useCallback((bookmark: Bookmark) => {
|
||||||
|
// No-op since expansion is handled by the List component
|
||||||
useEffect(() => {
|
}, [])
|
||||||
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
|
|
||||||
const store = storeRef.current!
|
|
||||||
store.getState().setBookmarks(bookmarks)
|
|
||||||
}, [bookmarks])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
|
|
||||||
const store = storeRef.current!
|
|
||||||
const unsub = store.subscribe(
|
|
||||||
(state) => state,
|
|
||||||
({ bookmarks, selectedIndex }) => {
|
|
||||||
onSelectionChange?.(bookmarks[selectedIndex])
|
|
||||||
},
|
|
||||||
{
|
|
||||||
equalityFn: (stateA, stateB) => stateA.selectedIndex === stateB.selectedIndex,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return () => {
|
|
||||||
unsub()
|
|
||||||
}
|
|
||||||
}, [onSelectionChange])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null
|
|
||||||
const store = storeRef.current!
|
|
||||||
if (selectedBookmarkId !== store.getState().selectedBookmarkId && selectedBookmarkId) {
|
|
||||||
setSelectedBookmarkId(selectedBookmarkId)
|
|
||||||
}
|
|
||||||
}, [setSelectedBookmarkId, selectedBookmarkId])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BookmarkListStoreContext value={storeRef.current}>
|
<List
|
||||||
<_BookmarkList className={className} />
|
items={bookmarks}
|
||||||
</BookmarkListStoreContext>
|
selectedItemId={selectedBookmarkId}
|
||||||
|
alwaysExpandItem={alwaysExpandItem}
|
||||||
|
onSelectionChange={handleSelect}
|
||||||
|
className={className}
|
||||||
|
emptyMessage="No bookmarks found!"
|
||||||
|
renderItem={({ item: bookmark, isSelected, isExpanded, onSelect, onExpand }) => (
|
||||||
|
<BookmarkListItem
|
||||||
|
bookmark={bookmark}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onExpand={onExpand}
|
||||||
|
onItemAction={onItemAction}
|
||||||
|
alwaysExpandItem={alwaysExpandItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const _BookmarkList = memo(({ className }: { className?: string }) => {
|
export { BookmarkList }
|
||||||
const store = useBookmarkListStoreContext()
|
|
||||||
|
|
||||||
useMnemonics(
|
|
||||||
{
|
|
||||||
j: selectNextItem,
|
|
||||||
ArrowDown: selectNextItem,
|
|
||||||
|
|
||||||
k: selectPrevItem,
|
|
||||||
ArrowUp: selectPrevItem,
|
|
||||||
|
|
||||||
h: collapseItem,
|
|
||||||
ArrowLeft: collapseItem,
|
|
||||||
|
|
||||||
l: expandItem,
|
|
||||||
ArrowRight: expandItem,
|
|
||||||
|
|
||||||
d: deleteItem,
|
|
||||||
|
|
||||||
Enter: openItem,
|
|
||||||
|
|
||||||
c: (event) => {
|
|
||||||
console.log("event", event)
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
event.preventDefault()
|
|
||||||
copyBookmarkLink()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
e: editItem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function copyBookmarkLink() {
|
|
||||||
const { bookmarks, selectedIndex, onItemAction } = store.getState()
|
|
||||||
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.CopyLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openItem() {
|
|
||||||
const { bookmarks, selectedIndex, onItemAction } = store.getState()
|
|
||||||
expandItem()
|
|
||||||
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteItem() {
|
|
||||||
const { bookmarks, selectedIndex, onItemAction } = store.getState()
|
|
||||||
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPrevItem() {
|
|
||||||
const { bookmarks, selectedIndex, setSelectedBookmarkId } = store.getState()
|
|
||||||
const prevIndex = selectedIndex - 1
|
|
||||||
if (prevIndex >= 0) {
|
|
||||||
setSelectedBookmarkId(bookmarks[prevIndex].id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectNextItem() {
|
|
||||||
const { bookmarks, selectedIndex, setSelectedBookmarkId } = store.getState()
|
|
||||||
const nextIndex = selectedIndex + 1
|
|
||||||
if (nextIndex < bookmarks.length) {
|
|
||||||
setSelectedBookmarkId(bookmarks[nextIndex].id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandItem() {
|
|
||||||
store.getState().setIsItemExpanded(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseItem() {
|
|
||||||
const state = store.getState()
|
|
||||||
if (!state.alwaysExpandItem) {
|
|
||||||
store.getState().setIsItemExpanded(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editItem() {
|
|
||||||
const { bookmarks, selectedIndex, onItemAction } = store.getState()
|
|
||||||
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className={twMerge("flex flex-col", className)}>
|
|
||||||
<ListContainer />
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function ListContainer() {
|
|
||||||
const bookmarks = useBookmarkListStore((state) => state.bookmarks)
|
|
||||||
const selectedItemId = useBookmarkListStore((state) => state.selectedBookmarkId)
|
|
||||||
|
|
||||||
return bookmarks.length === 0 ? (
|
|
||||||
<p className="mt-2">You have not saved any bookmark!</p>
|
|
||||||
) : (
|
|
||||||
bookmarks.map((bookmark, i) => (
|
|
||||||
<BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} selected={bookmark.id === selectedItemId} />
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookmarkListItem = memo(
|
|
||||||
({ bookmark, index, selected }: { bookmark: Bookmark; index: number; selected: boolean }) => {
|
|
||||||
const url = new URL(bookmark.url)
|
|
||||||
const store = useBookmarkListStoreContext()
|
|
||||||
const alwaysExpandItem = useBookmarkListStore((state) => state.alwaysExpandItem)
|
|
||||||
const isBookmarkItemExpanded = useBookmarkListStore((state) => state.alwaysExpandItem || state.isItemExpanded)
|
|
||||||
const setSelectedBookmarkId = useBookmarkListStore((state) => state.setSelectedBookmarkId)
|
|
||||||
const setIsItemExpanded = useBookmarkListStore((state) => state.setIsItemExpanded)
|
|
||||||
const onItemAction = useBookmarkListStore((state) => state.onItemAction)
|
|
||||||
|
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const isLongPressActive = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selected) {
|
|
||||||
store.getState().setSelectedIndex(index)
|
|
||||||
}
|
|
||||||
}, [selected, index, store])
|
|
||||||
|
|
||||||
function handleTouchStart() {
|
|
||||||
isLongPressActive.current = true
|
|
||||||
longPressTimerRef.current = setTimeout(() => {
|
|
||||||
if (isLongPressActive.current) {
|
|
||||||
setSelectedBookmarkId(bookmark.id)
|
|
||||||
setIsItemExpanded(true)
|
|
||||||
}
|
|
||||||
}, LONG_PRESS_DELAY_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchEnd() {
|
|
||||||
isLongPressActive.current = false
|
|
||||||
if (longPressTimerRef.current) {
|
|
||||||
clearTimeout(longPressTimerRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (longPressTimerRef.current) {
|
|
||||||
clearTimeout(longPressTimerRef.current)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
function deleteItem() {
|
|
||||||
onItemAction(bookmark, BookmarkListItemAction.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyItemLink() {
|
|
||||||
onItemAction(bookmark, BookmarkListItemAction.CopyLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
function editItem() {
|
|
||||||
onItemAction(bookmark, BookmarkListItemAction.Edit)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onItemHover() {
|
|
||||||
if (!store.getState().isItemExpanded) {
|
|
||||||
setSelectedBookmarkId(bookmark.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={clsx("group flex flex-row justify-start py-2", {
|
|
||||||
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && selected,
|
|
||||||
"text-teal-600": selected && !isBookmarkItemExpanded,
|
|
||||||
})}
|
|
||||||
onMouseEnter={onItemHover}
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchCancel={handleTouchEnd}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={alwaysExpandItem || !selected}
|
|
||||||
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
|
||||||
invisible: !selected,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
setIsItemExpanded(!isBookmarkItemExpanded)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Options for this bookmark</span>
|
|
||||||
<span> </span>
|
|
||||||
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>></span>
|
|
||||||
<span> </span>
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<Link
|
|
||||||
to={`/bookmarks/${bookmark.id}`}
|
|
||||||
className={clsx("block w-full text-start font-bold", { underline: selected })}
|
|
||||||
>
|
|
||||||
{bookmark.title}
|
|
||||||
</Link>
|
|
||||||
<p className="opacity-80 text-sm">{url.host}</p>
|
|
||||||
{isBookmarkItemExpanded && selected ? (
|
|
||||||
<>
|
|
||||||
<BookmarkTagList bookmark={bookmark} />
|
|
||||||
<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={copyItemLink}>
|
|
||||||
<span>COPY LINK</span>
|
|
||||||
</Button>
|
|
||||||
<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"> </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
|
|
||||||
const { data: tags, status } = useBookmarkTags(bookmark)
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return <LoadingSpinner />
|
|
||||||
case "success":
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row flex-wrap space-x-2">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Link key={tag.id} to={`/bookmarks?tags=${tag.name}`} className="underline">
|
|
||||||
#{tag.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case "error":
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { BookmarkList, BookmarkListItemAction }
|
|
||||||
|
@@ -1,259 +1,105 @@
|
|||||||
import { create } from "zustand/react"
|
|
||||||
import { subscribeWithSelector } from "zustand/middleware"
|
|
||||||
import type { Collection } from "@markone/core"
|
import type { Collection } from "@markone/core"
|
||||||
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
|
|
||||||
import { clsx } from "clsx"
|
import { clsx } from "clsx"
|
||||||
|
import { memo } from "react"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
import { Link } from "~/components/link"
|
||||||
import { DialogKind, useCollectionPageStore } from "./-store"
|
import { List } from "~/components/list"
|
||||||
import { useStore } from "zustand"
|
|
||||||
|
|
||||||
export enum CollectionListItemAction {
|
export enum CollectionListItemAction {
|
||||||
Delete = "Delete",
|
Delete = "Delete",
|
||||||
Edit = "Edit",
|
Edit = "Edit",
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemActionCallback = (collection: Collection, action: CollectionListItemAction) => void
|
|
||||||
|
|
||||||
interface CollectionListProps {
|
interface CollectionListProps {
|
||||||
collections: Collection[]
|
collections: Collection[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
onItemAction: (collection: Collection, action: CollectionListItemAction) => void
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollectionListItem = memo(
|
const CollectionListItem = memo(
|
||||||
({ collection, selected, index }: { collection: Collection; selected: boolean; index: number }) => {
|
({
|
||||||
const store = useCollectionListStoreContext()
|
collection,
|
||||||
const isItemExpanded = useCollectionListStore((state) => state.isItemExpanded)
|
isSelected,
|
||||||
const setSelectedCollectionId = useCollectionListStore((state) => state.setSelectedCollectionId)
|
isExpanded,
|
||||||
const setIsItemExpanded = useCollectionListStore((state) => state.setIsItemExpanded)
|
onSelect,
|
||||||
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
|
onExpand,
|
||||||
|
onItemAction,
|
||||||
useEffect(() => {
|
}: {
|
||||||
if (selected) {
|
collection: Collection
|
||||||
store.getState().setSelectedIndex(index)
|
isSelected: boolean
|
||||||
}
|
isExpanded: boolean
|
||||||
}, [selected, index, store])
|
onSelect: () => void
|
||||||
|
onExpand: () => void
|
||||||
function onItemHover() {
|
onItemAction: (collection: Collection, action: CollectionListItemAction) => void
|
||||||
if (!store.getState().isItemExpanded) {
|
}) => (
|
||||||
setSelectedCollectionId(collection.id)
|
<div
|
||||||
}
|
className={clsx("group flex flex-row justify-start py-2", {
|
||||||
}
|
"bg-teal-600 text-stone-100": isExpanded,
|
||||||
|
"text-teal-600": isSelected && !isExpanded,
|
||||||
function deleteItem() {
|
})}
|
||||||
handleCollectionListAction(collection, CollectionListItemAction.Delete)
|
>
|
||||||
}
|
<button
|
||||||
|
type="button"
|
||||||
function editItem() {
|
disabled={!isSelected}
|
||||||
handleCollectionListAction(collection, CollectionListItemAction.Edit)
|
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
||||||
}
|
invisible: !isSelected,
|
||||||
|
|
||||||
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,
|
|
||||||
})}
|
})}
|
||||||
onMouseEnter={onItemHover}
|
onClick={onExpand}
|
||||||
>
|
>
|
||||||
<button
|
<span className="sr-only">Options for this collection</span>
|
||||||
type="button"
|
<span> </span>
|
||||||
disabled={!selected}
|
<span className={isExpanded ? "rotate-90" : ""}>></span>
|
||||||
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
<span> </span>
|
||||||
invisible: !selected,
|
</button>
|
||||||
})}
|
<div className="flex flex-col w-full">
|
||||||
onClick={() => {
|
<div className="block w-full text-start font-bold">
|
||||||
setIsItemExpanded(!isItemExpanded)
|
<Link href="#">{collection.name}</Link>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Options for this collection</span>
|
|
||||||
<span> </span>
|
|
||||||
<span className={isItemExpanded ? "rotate-90" : ""}>></span>
|
|
||||||
<span> </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"> </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</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"> </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 { CollectionList }
|
||||||
export type { CollectionListState }
|
|
||||||
|
@@ -57,13 +57,14 @@ function CollectionsPane() {
|
|||||||
|
|
||||||
function CollectionsContainer() {
|
function CollectionsContainer() {
|
||||||
const { data: collections, status } = useCollections()
|
const { data: collections, status } = useCollections()
|
||||||
|
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "success":
|
case "success":
|
||||||
return collections.length === 0 ? (
|
return collections.length === 0 ? (
|
||||||
<p>You have not created any collections!</p>
|
<p>You have not created any collections!</p>
|
||||||
) : (
|
) : (
|
||||||
<CollectionList collections={collections} />
|
<CollectionList collections={collections} onItemAction={handleCollectionListAction} />
|
||||||
)
|
)
|
||||||
|
|
||||||
case "pending":
|
case "pending":
|
||||||
|
@@ -1,32 +0,0 @@
|
|||||||
import type { Collection } from "@markone/core"
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
|
||||||
import { UnauthenticatedError, fetchApi } from "~/api"
|
|
||||||
|
|
||||||
function useUpdateCollection(collection: Collection) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (body: { title: string; description: string }) =>
|
|
||||||
fetchApi(`/collections/${collection.id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}).then((res) => (res.status === 204 ? collection : res.json())),
|
|
||||||
onError: (error) => {
|
|
||||||
if (error instanceof UnauthenticatedError) {
|
|
||||||
navigate({ to: "/login", replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: (updatedCollection: Collection | undefined) => {
|
|
||||||
if (updatedCollection) {
|
|
||||||
queryClient.setQueryData(["collections"], (collections: Collection[]) =>
|
|
||||||
collections ? collections.map((it) => (it.id === updatedCollection.id ? updatedCollection : it)) : [updatedCollection],
|
|
||||||
)
|
|
||||||
queryClient.setQueryData(["collections", updatedCollection.id], updatedCollection)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useUpdateCollection }
|
|
306
packages/web/src/components/list.tsx
Normal file
306
packages/web/src/components/list.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
|
||||||
|
import { createStore, useStore } from "zustand"
|
||||||
|
import { subscribeWithSelector } from "zustand/middleware"
|
||||||
|
import { clsx } from "clsx"
|
||||||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
|
|
||||||
|
interface ListData {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListState<T extends ListData> {
|
||||||
|
items: T[]
|
||||||
|
selectedIndex: number
|
||||||
|
selectedItemId: string
|
||||||
|
isItemExpanded: boolean
|
||||||
|
alwaysExpandItem: boolean
|
||||||
|
setItems: (items: T[]) => void
|
||||||
|
setSelectedIndex: (index: number) => void
|
||||||
|
setSelectedItemId: (id: string) => void
|
||||||
|
setIsItemExpanded: (expanded: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListStore<T extends ListData> = ReturnType<typeof createListStore<T>>
|
||||||
|
|
||||||
|
const ListStoreContext = createContext<ListStore<ListData> | null>(null)
|
||||||
|
|
||||||
|
interface ListProps<T extends ListData> {
|
||||||
|
items: T[]
|
||||||
|
selectedItemId?: string
|
||||||
|
alwaysExpandItem?: boolean
|
||||||
|
onSelectionChange?: (item: T) => void
|
||||||
|
renderItem: (props: {
|
||||||
|
item: T
|
||||||
|
isSelected: boolean
|
||||||
|
isExpanded: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onExpand: () => void
|
||||||
|
}) => React.ReactNode
|
||||||
|
className?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function List<T extends ListData>({
|
||||||
|
items,
|
||||||
|
selectedItemId,
|
||||||
|
alwaysExpandItem = false,
|
||||||
|
onSelectionChange,
|
||||||
|
renderItem,
|
||||||
|
className,
|
||||||
|
emptyMessage = "No items found!",
|
||||||
|
}: ListProps<T>) {
|
||||||
|
const storeRef = useRef<ListStore<T> | null>(null)
|
||||||
|
if (!storeRef.current) {
|
||||||
|
storeRef.current = createListStore({ items, selectedItemId, alwaysExpandItem })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const store = storeRef.current
|
||||||
|
if (!store) return
|
||||||
|
store.getState().setItems(items)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const store = storeRef.current
|
||||||
|
if (!store) return
|
||||||
|
if (selectedItemId !== store.getState().selectedItemId && selectedItemId) {
|
||||||
|
store.getState().setSelectedItemId(selectedItemId)
|
||||||
|
}
|
||||||
|
}, [selectedItemId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const store = storeRef.current
|
||||||
|
if (!store) return
|
||||||
|
const unsub = store.subscribe(
|
||||||
|
(state) => state,
|
||||||
|
({ items, selectedIndex }) => {
|
||||||
|
onSelectionChange?.(items[selectedIndex])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
equalityFn: (stateA, stateB) => stateA.selectedIndex === stateB.selectedIndex,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
|
}, [onSelectionChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListStoreContext.Provider value={storeRef.current as unknown as ListStore<ListData>}>
|
||||||
|
<MemoizedList className={className} renderItem={renderItem} emptyMessage={emptyMessage} />
|
||||||
|
</ListStoreContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createListStore<T extends ListData>({
|
||||||
|
items,
|
||||||
|
selectedItemId,
|
||||||
|
alwaysExpandItem = false,
|
||||||
|
}: {
|
||||||
|
items: T[]
|
||||||
|
selectedItemId?: string
|
||||||
|
alwaysExpandItem?: boolean
|
||||||
|
}) {
|
||||||
|
let _selectedItemId = selectedItemId
|
||||||
|
if (!_selectedItemId && items.length > 0) {
|
||||||
|
_selectedItemId = items[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
return createStore<ListState<T>>()(
|
||||||
|
subscribeWithSelector((set) => ({
|
||||||
|
items,
|
||||||
|
alwaysExpandItem,
|
||||||
|
selectedIndex: selectedItemId ? items.findIndex((item) => item.id === selectedItemId) : 0,
|
||||||
|
selectedItemId: _selectedItemId ?? "",
|
||||||
|
isItemExpanded: alwaysExpandItem,
|
||||||
|
|
||||||
|
setItems(items: T[]) {
|
||||||
|
set({
|
||||||
|
items,
|
||||||
|
selectedItemId: items.length > 0 ? items[0].id : "",
|
||||||
|
selectedIndex: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedIndex(index: number) {
|
||||||
|
set({ selectedIndex: index })
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedItemId(id: string) {
|
||||||
|
set({ selectedItemId: id })
|
||||||
|
},
|
||||||
|
|
||||||
|
setIsItemExpanded(expanded: boolean) {
|
||||||
|
set({ isItemExpanded: expanded })
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useListStoreContext<T extends ListData>() {
|
||||||
|
const store = useContext(ListStoreContext)
|
||||||
|
if (!store) throw new Error("ListStoreContext not found")
|
||||||
|
return store as unknown as ListStore<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
function useListStore<T extends ListData, R>(selector: (state: ListState<T>) => R): R {
|
||||||
|
const store = useListStoreContext<T>()
|
||||||
|
return useStore(store, selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListItem<T extends ListData>({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
isSelected,
|
||||||
|
isExpanded,
|
||||||
|
onSelect,
|
||||||
|
onExpand,
|
||||||
|
renderItem,
|
||||||
|
}: {
|
||||||
|
item: T
|
||||||
|
index: number
|
||||||
|
isSelected: boolean
|
||||||
|
isExpanded: boolean
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onExpand: (expanded: boolean) => void
|
||||||
|
renderItem: (props: {
|
||||||
|
item: T
|
||||||
|
isSelected: boolean
|
||||||
|
isExpanded: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onExpand: () => void
|
||||||
|
}) => React.ReactNode
|
||||||
|
}) {
|
||||||
|
const setSelectedIndex = useListStore((state) => state.setSelectedIndex)
|
||||||
|
const isItemExpanded = useListStore((state) => state.isItemExpanded)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedIndex(index)
|
||||||
|
}
|
||||||
|
}, [isSelected, index, setSelectedIndex])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
onSelect(item.id)
|
||||||
|
}, [item.id, onSelect])
|
||||||
|
|
||||||
|
const handleExpand = useCallback(() => {
|
||||||
|
onExpand(!isExpanded)
|
||||||
|
}, [isExpanded, onExpand])
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (!isItemExpanded) {
|
||||||
|
onSelect(item.id)
|
||||||
|
}
|
||||||
|
}, [isItemExpanded, item.id, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li onMouseEnter={handleMouseEnter}>
|
||||||
|
{renderItem({
|
||||||
|
item,
|
||||||
|
isSelected,
|
||||||
|
isExpanded,
|
||||||
|
onSelect: handleSelect,
|
||||||
|
onExpand: handleExpand,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoizedListItem = memo(ListItem) as typeof ListItem
|
||||||
|
|
||||||
|
function _List<T extends ListData>({
|
||||||
|
className,
|
||||||
|
renderItem,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
renderItem: (props: {
|
||||||
|
item: T
|
||||||
|
isSelected: boolean
|
||||||
|
isExpanded: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onExpand: () => void
|
||||||
|
}) => React.ReactNode
|
||||||
|
emptyMessage: string
|
||||||
|
}) {
|
||||||
|
const store = useListStoreContext<T>()
|
||||||
|
const items = useListStore<T, T[]>((state) => state.items)
|
||||||
|
const selectedItemId = useListStore<T, string>((state) => state.selectedItemId)
|
||||||
|
const isItemExpanded = useListStore<T, boolean>((state) => state.isItemExpanded)
|
||||||
|
const setSelectedItemId = useListStore<T, (id: string) => void>((state) => state.setSelectedItemId)
|
||||||
|
const setIsItemExpanded = useListStore<T, (expanded: boolean) => void>((state) => state.setIsItemExpanded)
|
||||||
|
|
||||||
|
const shortcuts = {
|
||||||
|
j: selectNextItem,
|
||||||
|
ArrowDown: selectNextItem,
|
||||||
|
k: selectPrevItem,
|
||||||
|
ArrowUp: selectPrevItem,
|
||||||
|
l: expandItem,
|
||||||
|
ArrowRight: expandItem,
|
||||||
|
h: collapseItem,
|
||||||
|
ArrowLeft: collapseItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
useMnemonics(shortcuts, {
|
||||||
|
ignore: useCallback(() => false, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectNextItem() {
|
||||||
|
const { items, selectedIndex, setSelectedItemId } = store.getState()
|
||||||
|
const nextIndex = selectedIndex + 1
|
||||||
|
if (nextIndex < items.length) {
|
||||||
|
setSelectedItemId(items[nextIndex].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPrevItem() {
|
||||||
|
const { items, selectedIndex, setSelectedItemId } = store.getState()
|
||||||
|
const prevIndex = selectedIndex - 1
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
setSelectedItemId(items[prevIndex].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandItem() {
|
||||||
|
store.getState().setIsItemExpanded(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseItem() {
|
||||||
|
const state = store.getState()
|
||||||
|
if (!state.alwaysExpandItem) {
|
||||||
|
store.getState().setIsItemExpanded(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={clsx("flex flex-col", className)}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p>{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
items.map((item, index) => {
|
||||||
|
const isSelected = item.id === selectedItemId
|
||||||
|
const isExpanded = isItemExpanded && isSelected
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemoizedListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onSelect={setSelectedItemId}
|
||||||
|
onExpand={setIsItemExpanded}
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoizedList = memo(_List) as typeof _List
|
||||||
|
|
||||||
|
export { List }
|
||||||
|
export type { ListData, ListProps }
|
Reference in New Issue
Block a user