diff --git a/packages/web/src/app/bookmarks/-bookmark-list.tsx b/packages/web/src/app/bookmarks/-bookmark-list.tsx index cd11cf0..a3234b0 100644 --- a/packages/web/src/app/bookmarks/-bookmark-list.tsx +++ b/packages/web/src/app/bookmarks/-bookmark-list.tsx @@ -1,112 +1,125 @@ import type { Bookmark } from "@markone/core" import { Link } from "@tanstack/react-router" import clsx from "clsx" -import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react" +import { memo, useCallback } from "react" import { twMerge } from "tailwind-merge" -import { createStore, useStore } from "zustand" -import { subscribeWithSelector } from "zustand/middleware" import { useBookmarkTags } from "~/bookmark/api" import { Button } from "~/components/button" -import { LoadingSpinner } from "~/components/loading-spinner" -import { useMnemonics } from "~/hooks/use-mnemonics" +import { List } from "~/components/list" import { DialogKind, useBookmarkPageStore } from "./-store" -const LONG_PRESS_DELAY_MS = 500 - -enum BookmarkListItemAction { +export enum BookmarkListItemAction { Open = "Open", Edit = "Edit", Delete = "Delete", CopyLink = "CopyLink", } -type SelectionChangeCallback = (bookmark: Bookmark) => void -type ItemActionCallback = (bookmark: Bookmark, action: BookmarkListItemAction) => void - interface BookmarkListProps { bookmarks: Bookmark[] selectedBookmarkId?: string alwaysExpandItem: boolean - onSelectionChange?: SelectionChangeCallback + onSelectionChange?: (bookmark: Bookmark) => void onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void className?: string } -interface CreateStoreOptions { - bookmarks: Bookmark[] - selectedBookmarkId?: string - alwaysExpandItem: boolean - onItemAction: ItemActionCallback -} +const BookmarkTagList = memo(function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) { + const { data: tags } = useBookmarkTags(bookmark) -interface BookmarkListState { - bookmarks: Bookmark[] - selectedIndex: number - selectedBookmarkId: string - alwaysExpandItem: boolean - isItemExpanded: boolean + return tags ? ( +
+ {tags.map((tag) => ( + + {tag.name} + + ))} +
+ ) : null +}) - onItemAction: ItemActionCallback - setBookmarks: (bookmarks: Bookmark[]) => void - setSelectedIndex: (index: number) => void - setSelectedBookmarkId: (id: string) => void - setIsItemExpanded: (expanded: boolean) => void -} - -type BookmarkListStore = ReturnType - -const BookmarkListStoreContext = createContext(null) - -function createBookmarkListStore({ - bookmarks, - selectedBookmarkId, - alwaysExpandItem, +const BookmarkListItem = memo(function BookmarkListItem({ + bookmark, + isSelected, + isExpanded, + onSelect, + onExpand, onItemAction, -}: CreateStoreOptions) { - let _selectedBookmarkId = selectedBookmarkId - if (!_selectedBookmarkId && bookmarks.length > 0) { - _selectedBookmarkId = bookmarks[0].id - } + alwaysExpandItem, +}: { + bookmark: Bookmark + isSelected: boolean + isExpanded: boolean + onSelect: () => void + onExpand: () => void + onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void + alwaysExpandItem: boolean +}) { + const url = new URL(bookmark.url) - return createStore()( - subscribeWithSelector((set) => ({ - bookmarks, - alwaysExpandItem, - selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0, - selectedBookmarkId: _selectedBookmarkId ?? "", - isItemExpanded: alwaysExpandItem, - - onItemAction, - - setBookmarks(bookmarks: Bookmark[]) { - set({ bookmarks, selectedBookmarkId: bookmarks.length > 0 ? bookmarks[0].id : "", selectedIndex: 0 }) - }, - - setSelectedIndex(index: number) { - set({ selectedIndex: index }) - }, - - setSelectedBookmarkId(id: string) { - set({ selectedBookmarkId: id }) - }, - - setIsItemExpanded(expanded: boolean) { - set({ isItemExpanded: expanded }) - }, - })), + return ( +
+ +
+ + {bookmark.title} + +

{url.host}

+ {isExpanded ? ( + <> + +
+
+ + + +   +
+
+ + ) : null} +
+
) -} - -function useBookmarkListStoreContext() { - const store = useContext(BookmarkListStoreContext) - if (!store) throw new Error("BookmarkListStoreContext not found") - return store -} - -function useBookmarkListStore(selector: (state: BookmarkListState) => T): T { - const store = useBookmarkListStoreContext() - return useStore(store, selector) -} +}) function BookmarkList({ bookmarks, @@ -116,295 +129,38 @@ function BookmarkList({ onItemAction, className, }: BookmarkListProps) { - const storeRef = useRef(null) - if (!storeRef.current) { - storeRef.current = createBookmarkListStore({ bookmarks, selectedBookmarkId, alwaysExpandItem, onItemAction }) - } + const handleSelect = useCallback( + (bookmark: Bookmark) => { + onSelectionChange?.(bookmark) + }, + [onSelectionChange], + ) - const setSelectedBookmarkId = useStore(storeRef.current, (state) => state.setSelectedBookmarkId) - - 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]) + const handleExpand = useCallback((bookmark: Bookmark) => { + // No-op since expansion is handled by the List component + }, []) return ( - - <_BookmarkList className={className} /> - + ( + + )} + /> ) } -const _BookmarkList = memo(({ className }: { className?: string }) => { - 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 ( -
    - -
- ) -}) - -function ListContainer() { - const bookmarks = useBookmarkListStore((state) => state.bookmarks) - const selectedItemId = useBookmarkListStore((state) => state.selectedBookmarkId) - - return bookmarks.length === 0 ? ( -

You have not saved any bookmark!

- ) : ( - bookmarks.map((bookmark, i) => ( - - )) - ) -} - -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 | 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 ( -
  • - -
    - - {bookmark.title} - -

    {url.host}

    - {isBookmarkItemExpanded && selected ? ( - <> - -
    -
    - - - -   -
    -
    - - ) : null} -
    -
  • - ) - }, -) - -function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) { - const { data: tags, status } = useBookmarkTags(bookmark) - switch (status) { - case "pending": - return - case "success": - return ( -
    - {tags.map((tag) => ( - - #{tag.name} - - ))} -
    - ) - case "error": - return null - } -} - -export { BookmarkList, BookmarkListItemAction } +export { BookmarkList } diff --git a/packages/web/src/app/collections/-collection-list.tsx b/packages/web/src/app/collections/-collection-list.tsx index 652e5dc..9c04fcb 100644 --- a/packages/web/src/app/collections/-collection-list.tsx +++ b/packages/web/src/app/collections/-collection-list.tsx @@ -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 - -const CollectionListStoreContext = createContext(null) - -function createCollectionListStore({ collections }: CreateStoreOptions) { - return create()( - 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(selector: (state: CollectionListState) => T): T { - const store = useCollectionListStoreContext() - return useStore(store, selector) -} - -function CollectionList({ collections, className }: CollectionListProps) { - const storeRef = useRef(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 ( - - <_CollectionList className={className} /> - - ) -} - -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 ( -
      - -
    - ) -}) - -function ListContainer() { - const collections = useCollectionListStore((state) => state.collections) - const selectedItemId = useCollectionListStore((state) => state.selectedCollectionId) - - return collections.length === 0 ? ( -

    You have not created any collections!

    - ) : ( - collections.map((collection, 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 ( -
  • void + onExpand: () => void + onItemAction: (collection: Collection, action: CollectionListItemAction) => void + }) => ( +
    + -
    -
    {collection.name}
    -

    {collection.description}

    - {isItemExpanded && selected ? ( -
    -
    - - -   -
    -
    - ) : null} + Options for this collection +   + > +   + +
    +
    + {collection.name}
    -
  • - ) - }, +

    {collection.description}

    + {isExpanded ? ( +
    +
    + + +   +
    +
    + ) : null} + + + ), ) +function CollectionList({ collections, className, onItemAction }: CollectionListProps) { + return ( + ( + + )} + /> + ) +} + export { CollectionList } -export type { CollectionListState } diff --git a/packages/web/src/app/collections/index.tsx b/packages/web/src/app/collections/index.tsx index 98a8eb8..ddcdb7b 100644 --- a/packages/web/src/app/collections/index.tsx +++ b/packages/web/src/app/collections/index.tsx @@ -57,13 +57,14 @@ function CollectionsPane() { function CollectionsContainer() { const { data: collections, status } = useCollections() + const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction) switch (status) { case "success": return collections.length === 0 ? (

    You have not created any collections!

    ) : ( - + ) case "pending": diff --git a/packages/web/src/collections/api.ts b/packages/web/src/collections/api.ts deleted file mode 100644 index 6e778ca..0000000 --- a/packages/web/src/collections/api.ts +++ /dev/null @@ -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 } \ No newline at end of file diff --git a/packages/web/src/components/list.tsx b/packages/web/src/components/list.tsx new file mode 100644 index 0000000..c59a2b5 --- /dev/null +++ b/packages/web/src/components/list.tsx @@ -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 { + 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 = ReturnType> + +const ListStoreContext = createContext | null>(null) + +interface ListProps { + 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({ + items, + selectedItemId, + alwaysExpandItem = false, + onSelectionChange, + renderItem, + className, + emptyMessage = "No items found!", +}: ListProps) { + const storeRef = useRef | 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 ( + }> + + + ) +} + +function createListStore({ + items, + selectedItemId, + alwaysExpandItem = false, +}: { + items: T[] + selectedItemId?: string + alwaysExpandItem?: boolean +}) { + let _selectedItemId = selectedItemId + if (!_selectedItemId && items.length > 0) { + _selectedItemId = items[0].id + } + + return createStore>()( + 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() { + const store = useContext(ListStoreContext) + if (!store) throw new Error("ListStoreContext not found") + return store as unknown as ListStore +} + +function useListStore(selector: (state: ListState) => R): R { + const store = useListStoreContext() + return useStore(store, selector) +} + +function ListItem({ + 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 ( +
  • + {renderItem({ + item, + isSelected, + isExpanded, + onSelect: handleSelect, + onExpand: handleExpand, + })} +
  • + ) +} + +const MemoizedListItem = memo(ListItem) as typeof ListItem + +function _List({ + className, + renderItem, + emptyMessage, +}: { + className?: string + renderItem: (props: { + item: T + isSelected: boolean + isExpanded: boolean + onSelect: () => void + onExpand: () => void + }) => React.ReactNode + emptyMessage: string +}) { + const store = useListStoreContext() + const items = useListStore((state) => state.items) + const selectedItemId = useListStore((state) => state.selectedItemId) + const isItemExpanded = useListStore((state) => state.isItemExpanded) + const setSelectedItemId = useListStore void>((state) => state.setSelectedItemId) + const setIsItemExpanded = useListStore 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 ( +
      + {items.length === 0 ? ( +

      {emptyMessage}

      + ) : ( + items.map((item, index) => { + const isSelected = item.id === selectedItemId + const isExpanded = isItemExpanded && isSelected + + return ( + + ) + }) + )} +
    + ) +} + +const MemoizedList = memo(_List) as typeof _List + +export { List } +export type { ListData, ListProps }