add a menu for bookmark list; remove btns

This commit is contained in:
2025-06-03 15:41:45 +00:00
parent 3fcd90af69
commit cd5147f3cf
3 changed files with 89 additions and 141 deletions

View File

@@ -2,8 +2,8 @@ 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 { memo, useCallback, useRef } from "react" import { memo, useCallback, useRef } from "react"
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react"
import { useBookmarkTags } from "~/bookmark/api" import { useBookmarkTags } from "~/bookmark/api"
import { Button } from "~/components/button"
import { List, type ListRef } from "~/components/list" import { List, type ListRef } from "~/components/list"
import { DialogKind, ActionBarContentKind, useBookmarkPageStore } from "./-store" import { DialogKind, ActionBarContentKind, useBookmarkPageStore } from "./-store"
import { useMnemonics } from "~/hooks/use-mnemonics" import { useMnemonics } from "~/hooks/use-mnemonics"
@@ -18,7 +18,6 @@ export enum BookmarkListItemAction {
interface BookmarkListProps { interface BookmarkListProps {
bookmarks: Bookmark[] bookmarks: Bookmark[]
selectedBookmarkId?: string selectedBookmarkId?: string
alwaysExpandItem: boolean
onSelectionChange?: (bookmark: Bookmark) => void onSelectionChange?: (bookmark: Bookmark) => void
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
className?: string className?: string
@@ -38,93 +37,104 @@ const BookmarkTagList = memo(function BookmarkTagList({ bookmark }: { bookmark:
) : null ) : null
}) })
const BookmarkListItem = memo(function BookmarkListItem({ const BookmarkItemMenu = memo(function BookmarkItemMenu({
bookmark, bookmark,
isSelected,
isExpanded,
onSelect,
onExpand,
onItemAction, onItemAction,
alwaysExpandItem,
}: { }: {
bookmark: Bookmark bookmark: Bookmark
isSelected: boolean
isExpanded: boolean
onSelect: () => void
onExpand: () => void
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
alwaysExpandItem: boolean
}) { }) {
const url = new URL(bookmark.url)
return ( return (
<div <Menu as="div" className="relative">
className={clsx("group flex flex-row justify-start py-2", { <MenuButton
"bg-teal-600 text-stone-100": isExpanded,
"text-teal-600": isSelected && !isExpanded,
})}
>
<button
type="button" type="button"
disabled={alwaysExpandItem || !isSelected} className="select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100 px-2 focus:outline-none data-open:bg-stone-900 data-open:dark:bg-stone-100 data-open:text-stone-100 data-open:dark:text-stone-900"
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !isSelected,
})}
onClick={onExpand}
> >
<span className="sr-only">Options for this bookmark</span> <span className="sr-only">More options for {bookmark.title}</span>
<span>&nbsp;</span> <span className="text-xl"></span>
<span className={isExpanded ? "rotate-90" : ""}>&gt;</span> </MenuButton>
<span>&nbsp;</span> <MenuItems
</button> anchor="bottom end"
<div className="flex flex-col w-full"> className="w-48 origin-top-right bg-stone-300 dark:bg-stone-800 border-2 border-stone-900 dark:border-stone-100 focus:outline-none"
<Link >
to={`/bookmarks/${bookmark.id}`} <div className="py-2">
className={clsx("block w-full text-start font-bold", { underline: isSelected })} <MenuItem
> as="button"
{bookmark.title} type="button"
</Link> className="block w-full pl-3 pr-4 py-1 text-left text-sm font-bold text-stone-900 dark:text-stone-100 data-[focus]:bg-stone-800 data-[focus]:dark:bg-stone-300 data-[focus]:text-stone-300 data-[focus]:dark:text-stone-800"
<p className="opacity-80 text-sm">{url.host}</p> onClick={() => onItemAction(bookmark, BookmarkListItemAction.CopyLink)}
{isExpanded ? ( >
<> COPY LINK
<BookmarkTagList bookmark={bookmark} /> </MenuItem>
<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"> <MenuItem
<div className="flex space-x-2"> as="button"
<Button type="button"
variant="light" className="block w-full pl-3 pr-4 py-1 text-left text-sm font-bold text-stone-900 dark:text-stone-100 data-[focus]:bg-stone-800 data-[focus]:dark:bg-stone-300 data-[focus]:text-stone-300 data-[focus]:dark:text-stone-800"
className="text-sm" onClick={() => onItemAction(bookmark, BookmarkListItemAction.Edit)}
onClick={() => onItemAction(bookmark, BookmarkListItemAction.CopyLink)} >
> <span className="underline">E</span>DIT
<span>COPY LINK</span> </MenuItem>
</Button> <MenuItem
<Button as="button"
variant="light" type="button"
className="text-sm" className="block w-full pl-3 pr-4 py-1 text-left text-sm font-bold text-stone-900 dark:text-stone-100 data-[focus]:bg-stone-800 data-[focus]:dark:bg-stone-300 data-[focus]:text-stone-300 data-[focus]:dark:text-stone-800"
onClick={() => onItemAction(bookmark, BookmarkListItemAction.Edit)} onClick={() => onItemAction(bookmark, BookmarkListItemAction.Delete)}
> >
<span className="underline">E</span>DIT <span className="underline">D</span>ELETE
</Button> </MenuItem>
<Button </div>
variant="light" </MenuItems>
className="text-sm" </Menu>
onClick={() => onItemAction(bookmark, BookmarkListItemAction.Delete)}
>
<span className="underline">D</span>ELETE
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
</>
) : null}
</div>
</div>
) )
}) })
const BookmarkListItem = memo(
({
bookmark,
isSelected,
onItemAction,
}: {
bookmark: Bookmark
isSelected: boolean
onItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
}) => {
const url = new URL(bookmark.url)
return (
<div
className={clsx("group flex flex-row justify-start py-2", {
"text-teal-600": isSelected,
})}
>
<span
className={clsx("select-none flex items-start font-bold", {
invisible: !isSelected,
})}
>
<span>&nbsp;</span>
<span>&gt;</span>
<span>&nbsp;</span>
</span>
<div className="flex flex-col w-full">
<div className="flex justify-between items-start">
<Link
to={`/bookmarks/${bookmark.id}`}
className={clsx("block w-full text-start font-bold", { underline: isSelected })}
>
{bookmark.title}
</Link>
{isSelected ? <BookmarkItemMenu bookmark={bookmark} onItemAction={onItemAction} /> : null}
</div>
<p className="opacity-80 text-sm">{url.host}</p>
</div>
</div>
)
},
)
function BookmarkList({ function BookmarkList({
bookmarks, bookmarks,
selectedBookmarkId, selectedBookmarkId,
alwaysExpandItem,
onSelectionChange, onSelectionChange,
onItemAction, onItemAction,
className, className,
@@ -165,20 +175,11 @@ function BookmarkList({
ref={listRef} ref={listRef}
items={bookmarks} items={bookmarks}
selectedItemId={selectedBookmarkId} selectedItemId={selectedBookmarkId}
alwaysExpandItem={alwaysExpandItem}
onSelectionChange={handleSelect} onSelectionChange={handleSelect}
className={className} className={className}
emptyMessage="No bookmarks found!" emptyMessage="No bookmarks found!"
renderItem={({ item: bookmark, isSelected, isExpanded, onSelect, onExpand }) => ( renderItem={({ item: bookmark, isSelected, onSelect }) => (
<BookmarkListItem <BookmarkListItem bookmark={bookmark} isSelected={isSelected} onItemAction={onItemAction} />
bookmark={bookmark}
isSelected={isSelected}
isExpanded={isExpanded}
onSelect={onSelect}
onExpand={onExpand}
onItemAction={onItemAction}
alwaysExpandItem={alwaysExpandItem}
/>
)} )}
/> />
) )

View File

@@ -79,7 +79,6 @@ function BookmarkListContainer() {
return ( return (
<BookmarkList <BookmarkList
className={bookmarks.length > 0 ? "-mt-2" : ""} className={bookmarks.length > 0 ? "-mt-2" : ""}
alwaysExpandItem={false}
bookmarks={bookmarks} bookmarks={bookmarks}
onItemAction={handleBookmarkListItemAction} onItemAction={handleBookmarkListItemAction}
/> />

View File

@@ -16,12 +16,9 @@ interface ListState<T extends ListData> {
items: T[] items: T[]
selectedIndex: number selectedIndex: number
selectedItemId: string selectedItemId: string
isItemExpanded: boolean
alwaysExpandItem: boolean
setItems: (items: T[]) => void setItems: (items: T[]) => void
setSelectedIndex: (index: number) => void setSelectedIndex: (index: number) => void
setSelectedItemId: (id: string) => void setSelectedItemId: (id: string) => void
setIsItemExpanded: (expanded: boolean) => void
} }
type ListStore<T extends ListData> = ReturnType<typeof createListStore<T>> type ListStore<T extends ListData> = ReturnType<typeof createListStore<T>>
@@ -33,14 +30,11 @@ type MnemonicCallback<T> = (event: KeyboardEvent, item: T) => void
interface ListProps<T extends ListData> { interface ListProps<T extends ListData> {
items: T[] items: T[]
selectedItemId?: string selectedItemId?: string
alwaysExpandItem?: boolean
onSelectionChange?: (item: T) => void onSelectionChange?: (item: T) => void
renderItem: (props: { renderItem: (props: {
item: T item: T
isSelected: boolean isSelected: boolean
isExpanded: boolean
onSelect: () => void onSelect: () => void
onExpand: () => void
}) => React.ReactNode }) => React.ReactNode
className?: string className?: string
emptyMessage?: string emptyMessage?: string
@@ -51,7 +45,6 @@ interface ListProps<T extends ListData> {
function List<T extends ListData>({ function List<T extends ListData>({
items, items,
selectedItemId, selectedItemId,
alwaysExpandItem = false,
onSelectionChange, onSelectionChange,
renderItem, renderItem,
className, className,
@@ -59,7 +52,7 @@ function List<T extends ListData>({
mnemonics, mnemonics,
ref, ref,
}: ListProps<T>) { }: ListProps<T>) {
const storeRef = useRef<ListStore<T>>(createListStore({ items, selectedItemId, alwaysExpandItem })) const storeRef = useRef<ListStore<T>>(createListStore({ items, selectedItemId }))
useImperativeHandle( useImperativeHandle(
ref, ref,
@@ -107,11 +100,9 @@ function List<T extends ListData>({
function createListStore<T extends ListData>({ function createListStore<T extends ListData>({
items, items,
selectedItemId, selectedItemId,
alwaysExpandItem = false,
}: { }: {
items: T[] items: T[]
selectedItemId?: string selectedItemId?: string
alwaysExpandItem?: boolean
}) { }) {
let _selectedItemId = selectedItemId let _selectedItemId = selectedItemId
if (!_selectedItemId && items.length > 0) { if (!_selectedItemId && items.length > 0) {
@@ -121,10 +112,8 @@ function createListStore<T extends ListData>({
return createStore<ListState<T>>()( return createStore<ListState<T>>()(
subscribeWithSelector((set) => ({ subscribeWithSelector((set) => ({
items, items,
alwaysExpandItem,
selectedIndex: selectedItemId ? items.findIndex((item) => item.id === selectedItemId) : 0, selectedIndex: selectedItemId ? items.findIndex((item) => item.id === selectedItemId) : 0,
selectedItemId: _selectedItemId ?? "", selectedItemId: _selectedItemId ?? "",
isItemExpanded: alwaysExpandItem,
setItems(items: T[]) { setItems(items: T[]) {
set({ set({
@@ -141,10 +130,6 @@ function createListStore<T extends ListData>({
setSelectedItemId(id: string) { setSelectedItemId(id: string) {
set({ selectedItemId: id }) set({ selectedItemId: id })
}, },
setIsItemExpanded(expanded: boolean) {
set({ isItemExpanded: expanded })
},
})), })),
) )
} }
@@ -164,27 +149,20 @@ function ListItem<T extends ListData>({
item, item,
index, index,
isSelected, isSelected,
isExpanded,
onSelect, onSelect,
onExpand,
renderItem, renderItem,
}: { }: {
item: T item: T
index: number index: number
isSelected: boolean isSelected: boolean
isExpanded: boolean
onSelect: (id: string) => void onSelect: (id: string) => void
onExpand: (expanded: boolean) => void
renderItem: (props: { renderItem: (props: {
item: T item: T
isSelected: boolean isSelected: boolean
isExpanded: boolean
onSelect: () => void onSelect: () => void
onExpand: () => void
}) => React.ReactNode }) => React.ReactNode
}) { }) {
const setSelectedIndex = useListStore((state) => state.setSelectedIndex) const setSelectedIndex = useListStore((state) => state.setSelectedIndex)
const isItemExpanded = useListStore((state) => state.isItemExpanded)
useEffect(() => { useEffect(() => {
if (isSelected) { if (isSelected) {
@@ -196,24 +174,16 @@ function ListItem<T extends ListData>({
onSelect(item.id) onSelect(item.id)
}, [item.id, onSelect]) }, [item.id, onSelect])
const handleExpand = useCallback(() => {
onExpand(!isExpanded)
}, [isExpanded, onExpand])
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
if (!isItemExpanded) { onSelect(item.id)
onSelect(item.id) }, [item.id, onSelect])
}
}, [isItemExpanded, item.id, onSelect])
return ( return (
<li onMouseEnter={handleMouseEnter}> <li onMouseEnter={handleMouseEnter}>
{renderItem({ {renderItem({
item, item,
isSelected, isSelected,
isExpanded,
onSelect: handleSelect, onSelect: handleSelect,
onExpand: handleExpand,
})} })}
</li> </li>
) )
@@ -231,9 +201,7 @@ function _List<T extends ListData>({
renderItem: (props: { renderItem: (props: {
item: T item: T
isSelected: boolean isSelected: boolean
isExpanded: boolean
onSelect: () => void onSelect: () => void
onExpand: () => void
}) => React.ReactNode }) => React.ReactNode
emptyMessage: string emptyMessage: string
mnemonics?: Record<string, MnemonicCallback<T>> mnemonics?: Record<string, MnemonicCallback<T>>
@@ -241,9 +209,7 @@ function _List<T extends ListData>({
const store = useListStoreContext<T>() const store = useListStoreContext<T>()
const items = useListStore<T, T[]>((state) => state.items) const items = useListStore<T, T[]>((state) => state.items)
const selectedItemId = useListStore<T, string>((state) => state.selectedItemId) 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 setSelectedItemId = useListStore<T, (id: string) => void>((state) => state.setSelectedItemId)
const setIsItemExpanded = useListStore<T, (expanded: boolean) => void>((state) => state.setIsItemExpanded)
const shortcuts = useMemo(() => { const shortcuts = useMemo(() => {
const baseShortcuts: Record<string, (event: KeyboardEvent) => void> = { const baseShortcuts: Record<string, (event: KeyboardEvent) => void> = {
@@ -251,10 +217,6 @@ function _List<T extends ListData>({
ArrowDown: selectNextItem, ArrowDown: selectNextItem,
k: selectPrevItem, k: selectPrevItem,
ArrowUp: selectPrevItem, ArrowUp: selectPrevItem,
l: expandItem,
ArrowRight: expandItem,
h: collapseItem,
ArrowLeft: collapseItem,
} }
if (!mnemonics) { if (!mnemonics) {
@@ -291,17 +253,6 @@ function _List<T extends ListData>({
} }
} }
function expandItem() {
store.getState().setIsItemExpanded(true)
}
function collapseItem() {
const state = store.getState()
if (!state.alwaysExpandItem) {
store.getState().setIsItemExpanded(false)
}
}
return ( return (
<ul className={clsx("flex flex-col", className)}> <ul className={clsx("flex flex-col", className)}>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -309,7 +260,6 @@ function _List<T extends ListData>({
) : ( ) : (
items.map((item, index) => { items.map((item, index) => {
const isSelected = item.id === selectedItemId const isSelected = item.id === selectedItemId
const isExpanded = isItemExpanded && isSelected
return ( return (
<MemoizedListItem <MemoizedListItem
@@ -317,9 +267,7 @@ function _List<T extends ListData>({
item={item} item={item}
index={index} index={index}
isSelected={isSelected} isSelected={isSelected}
isExpanded={isExpanded}
onSelect={setSelectedItemId} onSelect={setSelectedItemId}
onExpand={setIsItemExpanded}
renderItem={renderItem} renderItem={renderItem}
/> />
) )