fix(link): Navigate between item and fix Enter keybind (#165)

* feat: add item scroll to active

* fix: reset enterkey and scroll to view

* fix: link item displayName
This commit is contained in:
Aslam
2024-09-19 21:11:52 +07:00
committed by GitHub
parent 8871a8959c
commit afaef5d3c5
5 changed files with 167 additions and 129 deletions

View File

@@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs"
import { atom, useAtom } from "jotai" import { atom, useAtom } from "jotai"
import { LinkBottomBar } from "./bottom-bar" import { LinkBottomBar } from "./bottom-bar"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { useKey } from "react-use"
export const isDeleteConfirmShownAtom = atom(false) export const isDeleteConfirmShownAtom = atom(false)
export function LinkRoute(): React.ReactElement { export function LinkRoute(): React.ReactElement {
const [nuqsEditId] = useQueryState("editId") const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null) const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isInCreateMode] = useQueryState("create", parseAsBoolean) const [isInCreateMode] = useQueryState("create", parseAsBoolean)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
@@ -50,6 +51,11 @@ export function LinkRoute(): React.ReactElement {
} }
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose]) }, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
useKey("Escape", () => {
setDisableEnterKey(false)
setNuqsEditId(null)
})
return ( return (
<> <>
<LinkHeader /> <LinkHeader />

View File

@@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => {
return ( return (
<> <>
<ContentHeader className="px-6 max-lg:px-4 lg:py-5"> <ContentHeader className="px-6 max-lg:px-4 lg:py-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5"> <div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton /> <SidebarToggleButton />
<div className="flex min-h-0 items-center"> <div className="flex min-h-0 items-center">

View File

@@ -24,6 +24,7 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm
import { useConfirm } from "@omit/react-confirm-dialog" import { useConfirm } from "@omit/react-confirm-dialog"
import { useLinkActions } from "./hooks/use-link-actions" import { useLinkActions } from "./hooks/use-link-actions"
import { isDeleteConfirmShownAtom } from "./LinkRoute" import { isDeleteConfirmShownAtom } from "./LinkRoute"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
interface LinkListProps { interface LinkListProps {
activeItemIndex: number | null activeItemIndex: number | null
@@ -77,12 +78,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
) )
useKey("Escape", () => {
if (editId) {
setEditId(null)
}
})
useKey( useKey(
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
async () => { async () => {
@@ -245,6 +240,8 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
setDraggingId(null) setDraggingId(null)
} }
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
return ( return (
<Primitive.div <Primitive.div
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]" className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
@@ -271,6 +268,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
isActive={activeItemIndex === index} isActive={activeItemIndex === index}
setActiveItemIndex={setActiveItemIndex} setActiveItemIndex={setActiveItemIndex}
index={index} index={index}
ref={el => setElementRef(el, index)}
/> />
) )
)} )}

View File

@@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { linkOpenPopoverForIdAtom } from "@/store/link" import { linkOpenPopoverForIdAtom } from "@/store/link"
interface LinkItemProps { interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
personalLink: PersonalLink personalLink: PersonalLink
disabled?: boolean disabled?: boolean
isEditing: boolean isEditing: boolean
@@ -26,134 +26,138 @@ interface LinkItemProps {
index: number index: number
} }
export const LinkItem: React.FC<LinkItemProps> = ({ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
isEditing, ({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
setEditId, const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
personalLink, const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
disabled = false,
isDragging,
isActive,
setActiveItemIndex,
index
}) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = useMemo( const style = useMemo(
() => ({ () => ({
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
pointerEvents: isDragging ? "none" : "auto" pointerEvents: isDragging ? "none" : "auto"
}), }),
[transform, transition, isDragging] [transform, transition, isDragging]
) )
const handleSuccess = useCallback(() => setEditId(null), [setEditId]) const handleSuccess = useCallback(() => setEditId(null), [setEditId])
const handleOnClose = useCallback(() => setEditId(null), [setEditId]) const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id]) const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const selectedLearningState = useMemo( const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState] [personalLink.learningState]
) )
const handleLearningStateSelect = useCallback( const handleLearningStateSelect = useCallback(
(value: string) => { (value: string) => {
const learningState = value as LearningStateValue const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
setOpenPopoverForId(null) setOpenPopoverForId(null)
}, },
[personalLink, setOpenPopoverForId] [personalLink, setOpenPopoverForId]
) )
if (isEditing) { if (isEditing) {
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} /> return (
} <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
)
}
return ( return (
<li <li
ref={setNodeRef} ref={node => {
style={style as React.CSSProperties} setNodeRef(node)
{...attributes} if (typeof ref === "function") {
{...listeners} ref(node)
tabIndex={0} } else if (ref) {
onFocus={() => setActiveItemIndex(index)} ref.current = node
onBlur={() => setActiveItemIndex(null)} }
className={cn( }}
"relative cursor-default outline-none", style={style as React.CSSProperties}
"mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2", {...attributes}
{ {...listeners}
"bg-muted-foreground/5": isActive, tabIndex={0}
"hover:bg-muted/50": !isActive onFocus={() => setActiveItemIndex(index)}
} onBlur={() => setActiveItemIndex(null)}
)} className={cn(
onDoubleClick={handleRowDoubleClick} "relative cursor-default outline-none",
> "grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
<Popover {
open={openPopoverForId === personalLink.id} "bg-muted-foreground/5": isActive,
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)} "hover:bg-muted/50": !isActive
> }
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
) : (
<LaIcon name="Circle" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink.learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 shrink-0 rounded-full"
width={16}
height={16}
/>
)} )}
onDoubleClick={handleRowDoubleClick}
>
<Popover
open={openPopoverForId === personalLink.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
>
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
) : (
<LaIcon name="Circle" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink.learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2"> <div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p> {personalLink.icon && (
{personalLink.url && ( <Image
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1"> src={personalLink.icon}
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" /> alt={personalLink.title}
<Link className="size-5 shrink-0 rounded-full"
href={ensureUrlProtocol(personalLink.url)} width={16}
passHref height={16}
prefetch={false} />
target="_blank" )}
onClick={e => e.stopPropagation()} <div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
className="hover:text-primary truncate text-xs" <p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
> {personalLink.url && (
{personalLink.url} <div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
</Link> <LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
</div> <Link
href={ensureUrlProtocol(personalLink.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="hover:text-primary truncate text-xs"
>
{personalLink.url}
</Link>
</div>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
{personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)} )}
</div> </div>
</div> </li>
)
}
)
<div className="flex shrink-0 items-center justify-end"> LinkItem.displayName = "LinkItem"
{personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)}
</div>
</li>
)
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useRef, useCallback } from "react"
type ElementRef<T extends HTMLElement> = T | null
type ElementRefs<T extends HTMLElement> = ElementRef<T>[]
interface ActiveItemScrollOptions {
activeIndex: number | null
}
export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemScrollOptions) {
const { activeIndex } = options
const elementRefs = useRef<ElementRefs<T>>([])
const scrollActiveElementIntoView = useCallback((index: number) => {
const activeElement = elementRefs.current[index]
activeElement?.scrollIntoView({ block: "nearest" })
}, [])
useEffect(() => {
if (activeIndex !== null) {
scrollActiveElementIntoView(activeIndex)
}
}, [activeIndex, scrollActiveElementIntoView])
const setElementRef = useCallback((element: ElementRef<T>, index: number) => {
elementRefs.current[index] = element
}, [])
return setElementRef
}