fix(topic): Topic list keybind (#181)

* fix(page): improve keybind

* fix(topic): improve keybind

* fix: learning state selector
This commit is contained in:
Aslam
2024-09-24 18:55:52 +07:00
committed by GitHub
parent cffe65ec5f
commit 58ce33fed5
5 changed files with 204 additions and 248 deletions

View File

@@ -1,35 +1,13 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { TopicHeader } from "./header"
import { TopicList } from "./list"
import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
export function TopicRoute() {
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [disableEnterKey, setDisableEnterKey] = useState(false)
const handleCommandPaletteClose = useCallback(() => {
setDisableEnterKey(true)
setTimeout(() => setDisableEnterKey(false), 100)
}, [])
useEffect(() => {
if (!isCommandPaletteOpen) {
handleCommandPaletteClose()
}
}, [isCommandPaletteOpen, handleCommandPaletteClose])
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<TopicHeader />
<TopicList
activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey}
/>
<TopicList />
</div>
)
}

View File

@@ -1,8 +1,7 @@
import React, { useCallback, useEffect, useMemo } from "react"
import * as React from "react"
import { Primitive } from "@radix-ui/react-primitive"
import { useAccount } from "@/lib/providers/jazz-provider"
import { atom, useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { atom } from "jotai"
import { TopicItem } from "./partials/topic-item"
import { useMedia } from "@/hooks/use-media"
import { useRouter } from "next/navigation"
@@ -11,12 +10,9 @@ import { Column } from "@/components/custom/column"
import { useColumnStyles } from "./hooks/use-column-styles"
import { LaAccount, ListOfTopics, Topic, UserRoot } from "@/lib/schema"
import { LearningStateValue } from "@/lib/constants"
import { useKeyDown } from "@/hooks/use-key-down"
interface TopicListProps {
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
disableEnterKey: boolean
}
interface TopicListProps {}
interface MainTopicListProps extends TopicListProps {
me: {
@@ -35,32 +31,21 @@ export interface PersonalTopic {
export const topicOpenPopoverForIdAtom = atom<string | null>(null)
export const TopicList: React.FC<TopicListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => {
export const TopicList: React.FC<TopicListProps> = () => {
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
if (!me) return null
return (
<MainTopicList
me={me}
activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey}
/>
)
return <MainTopicList me={me} />
}
export const MainTopicList: React.FC<MainTopicListProps> = ({
me,
activeItemIndex,
setActiveItemIndex,
disableEnterKey
}) => {
export const MainTopicList: React.FC<MainTopicListProps> = ({ me }) => {
const isTablet = useMedia("(max-width: 640px)")
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
const router = useRouter()
const personalTopics = useMemo(
const personalTopics = React.useMemo(
() => [
...me.root.topicsWantToLearn.map(topic => ({ topic, learningState: "wantToLearn" as const })),
...me.root.topicsLearning.map(topic => ({ topic, learningState: "learning" as const })),
@@ -69,44 +54,63 @@ export const MainTopicList: React.FC<MainTopicListProps> = ({
[me.root.topicsWantToLearn, me.root.topicsLearning, me.root.topicsLearned]
)
const itemCount = personalTopics.length
const handleEnter = useCallback(
const handleEnter = React.useCallback(
(selectedTopic: Topic) => {
router.push(`/${selectedTopic.name}`)
},
[router]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isCommandPaletteOpen) return
const next = () => Math.min((activeItemIndex ?? 0) + 1, (personalTopics?.length ?? 0) - 1)
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalTopics) {
e.preventDefault()
const selectedTopic = personalTopics[activeItemIndex]
if (selectedTopic?.topic) handleEnter?.(selectedTopic.topic)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalTopics, handleEnter]
)
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
const handleKeyDown = (ev: KeyboardEvent) => {
switch (ev.key) {
case "ArrowDown":
ev.preventDefault()
ev.stopPropagation()
setActiveItemIndex(next())
setKeyboardActiveIndex(next())
break
case "ArrowUp":
ev.preventDefault()
ev.stopPropagation()
setActiveItemIndex(prev())
setKeyboardActiveIndex(prev())
}
}
useKeyDown(() => true, handleKeyDown)
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: keyboardActiveIndex })
return (
<div className="flex h-full w-full flex-col overflow-hidden border-t">
{!isTablet && <ColumnHeader />}
<TopicListItems personalTopics={personalTopics} activeItemIndex={activeItemIndex} />
<Primitive.div
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalTopics?.map(
(pt, index) =>
pt.topic?.id && (
<TopicItem
key={pt.topic.id}
ref={el => setElementRef(el, index)}
topic={pt.topic}
learningState={pt.learningState}
isActive={index === activeItemIndex}
onPointerMove={() => {
setKeyboardActiveIndex(null)
setActiveItemIndex(index)
}}
data-keyboard-active={keyboardActiveIndex === index}
/>
)
)}
</Primitive.div>
</div>
)
}
@@ -125,33 +129,3 @@ export const ColumnHeader: React.FC = () => {
</div>
)
}
interface TopicListItemsProps {
personalTopics: PersonalTopic[] | null
activeItemIndex: number | null
}
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalTopics?.map(
(pt, index) =>
pt.topic?.id && (
<TopicItem
key={pt.topic.id}
ref={el => setElementRef(el, index)}
topic={pt.topic}
learningState={pt.learningState}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
}

View File

@@ -12,146 +12,163 @@ import { useAtom } from "jotai"
import { topicOpenPopoverForIdAtom } from "../list"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useRouter } from "next/navigation"
interface TopicItemProps {
interface TopicItemProps extends React.HTMLAttributes<HTMLAnchorElement> {
topic: Topic
learningState: LearningStateValue
isActive: boolean
}
export const TopicItem = React.forwardRef<HTMLDivElement, TopicItemProps>(({ topic, learningState, isActive }, ref) => {
const columnStyles = useColumnStyles()
const [openPopoverForId, setOpenPopoverForId] = useAtom(topicOpenPopoverForIdAtom)
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
({ topic, learningState, isActive, ...props }, ref) => {
const columnStyles = useColumnStyles()
const [openPopoverForId, setOpenPopoverForId] = useAtom(topicOpenPopoverForIdAtom)
const router = useRouter()
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
let p: {
index: number
topic?: Topic | null
learningState: LearningStateValue
} | null = null
let p: {
index: number
topic?: Topic | null
learningState: LearningStateValue
} | null = null
const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
if (wantToLearnIndex !== -1) {
p = {
index: wantToLearnIndex,
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
learningState: "wantToLearn"
}
}
const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
if (learningIndex !== -1) {
p = {
index: learningIndex,
topic: me?.root.topicsLearning[learningIndex],
learningState: "learning"
}
}
const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
if (learnedIndex !== -1) {
p = {
index: learnedIndex,
topic: me?.root.topicsLearned[learnedIndex],
learningState: "learned"
}
}
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === learningState), [learningState])
const handleLearningStateSelect = useCallback(
(value: string) => {
const newLearningState = value as LearningStateValue
const topicLists: Record<LearningStateValue, (ListOfTopics | null) | undefined> = {
wantToLearn: me?.root.topicsWantToLearn,
learning: me?.root.topicsLearning,
learned: me?.root.topicsLearned
const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
if (wantToLearnIndex !== -1) {
p = {
index: wantToLearnIndex,
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
learningState: "wantToLearn"
}
}
const removeFromList = (state: LearningStateValue, index: number) => {
topicLists[state]?.splice(index, 1)
const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
if (learningIndex !== -1) {
p = {
index: learningIndex,
topic: me?.root.topicsLearning[learningIndex],
learningState: "learning"
}
}
if (p) {
if (newLearningState === p.learningState) {
removeFromList(p.learningState, p.index)
return
const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
if (learnedIndex !== -1) {
p = {
index: learnedIndex,
topic: me?.root.topicsLearned[learnedIndex],
learningState: "learned"
}
}
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === learningState), [learningState])
const handleLearningStateSelect = useCallback(
(value: string) => {
const newLearningState = value as LearningStateValue
const topicLists: Record<LearningStateValue, (ListOfTopics | null) | undefined> = {
wantToLearn: me?.root.topicsWantToLearn,
learning: me?.root.topicsLearning,
learned: me?.root.topicsLearned
}
removeFromList(p.learningState, p.index)
}
topicLists[newLearningState]?.push(topic)
const removeFromList = (state: LearningStateValue, index: number) => {
topicLists[state]?.splice(index, 1)
}
setOpenPopoverForId(null)
},
[setOpenPopoverForId, me?.root.topicsWantToLearn, me?.root.topicsLearning, me?.root.topicsLearned, p, topic]
)
if (p) {
if (newLearningState === p.learningState) {
removeFromList(p.learningState, p.index)
return
}
removeFromList(p.learningState, p.index)
}
const handlePopoverTriggerClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
topicLists[newLearningState]?.push(topic)
setOpenPopoverForId(openPopoverForId === topic.id ? null : topic.id)
}
setOpenPopoverForId(null)
},
[setOpenPopoverForId, me?.root.topicsWantToLearn, me?.root.topicsLearning, me?.root.topicsLearned, p, topic]
)
return (
<div
ref={ref}
className={cn("relative block", "min-h-12 py-2 max-lg:px-5 sm:px-6", {
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
})}
role="listitem"
>
const handlePopoverTriggerClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setOpenPopoverForId(openPopoverForId === topic.id ? null : topic.id)
}
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLAnchorElement>) => {
if (ev.key === "Enter") {
ev.preventDefault()
ev.stopPropagation()
router.push(`/${topic.name}`)
}
},
[router, topic.id]
)
return (
<Link
ref={ref}
href={`/${topic.name}`}
className="flex h-full cursor-default items-center gap-4 outline-none"
tabIndex={isActive ? 0 : -1}
className={cn(
"relative block cursor-default outline-none",
"min-h-12 py-2 max-lg:px-4 sm:px-6",
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
)}
aria-selected={isActive}
data-active={isActive}
onKeyDown={handleKeyDown}
{...props}
>
<Column.Wrapper style={columnStyles.title}>
<Column.Text className="truncate text-[13px] font-medium">{topic.prettyName}</Column.Text>
</Column.Wrapper>
<div className="flex h-full cursor-default items-center gap-4 outline-none" tabIndex={isActive ? 0 : -1}>
<Column.Wrapper style={columnStyles.title}>
<Column.Text className="truncate text-[13px] font-medium">{topic.prettyName}</Column.Text>
</Column.Wrapper>
<Column.Wrapper style={columnStyles.topic} className="max-sm:justify-end">
<Popover
open={openPopoverForId === topic.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? topic.id : null)}
>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className="size-7 shrink-0 p-0"
onClick={handlePopoverTriggerClick}
>
{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="end"
onClick={e => e.stopPropagation()}
<Column.Wrapper style={columnStyles.topic} className="max-sm:justify-end">
<Popover
open={openPopoverForId === topic.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? topic.id : null)}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
</Column.Wrapper>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className="size-7 shrink-0 p-0"
onClick={handlePopoverTriggerClick}
>
{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="end"
onClick={e => e.stopPropagation()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
</Column.Wrapper>
</div>
</Link>
</div>
)
})
)
}
)
TopicItem.displayName = "TopicItem"