fix tags input list up/down nav

This commit is contained in:
2025-05-25 17:09:09 +01:00
parent 6afb5dee9a
commit 0c8325eede
2 changed files with 55 additions and 224 deletions

View File

@@ -1,48 +1,22 @@
import { autoUpdate, size, useFloating } from "@floating-ui/react-dom" import { useEffect, useId, useRef, useState } from "react"
import type { Tag } from "@markone/core"
import clsx from "clsx"
import { atom, useAtom } from "jotai"
import { useAtomCallback } from "jotai/utils"
import { useCallback, useEffect, useId, useImperativeHandle, useRef, useState } from "react"
import { ApiErrorCode, BadRequestError } from "~/api" import { ApiErrorCode, BadRequestError } from "~/api"
import { useCreateBookmark, useTags } from "~/bookmark/api" import { useCreateBookmark } from "~/bookmark/api"
import { Button } from "~/components/button" import { Button } from "~/components/button"
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
import { FormField } from "~/components/form-field" import { FormField } from "~/components/form-field"
import { LoadingSpinner } from "~/components/loading-spinner" import { LoadingSpinner } from "~/components/loading-spinner"
import { Message, MessageVariant } from "~/components/message" import { Message, MessageVariant } from "~/components/message"
import { TagsInput, type TagsInputRef } from "~/components/tags-input.tsx"
import { useMnemonics } from "~/hooks/use-mnemonics" import { useMnemonics } from "~/hooks/use-mnemonics"
import { DialogKind, useBookmarkPageStore } from "../-store" import { DialogKind, useBookmarkPageStore } from "../-store"
const tagsInputValueAtom = atom("")
const appendTagAtom = atom(null, (_, set, update: string) => {
set(tagsInputValueAtom, (current) => current + update)
})
const lastTagAtom = atom((get) => {
const value = get(tagsInputValueAtom)
let start = 0
for (let i = value.length; i > 0; --i) {
if (value.charAt(i) === " ") {
start = i + 1
break
}
}
return value.slice(start)
})
function AddBookmarkDialog() { function AddBookmarkDialog() {
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false) const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
const createBookmarkMutation = useCreateBookmark() const createBookmarkMutation = useCreateBookmark()
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const formId = useId() const formId = useId()
const linkInputRef = useRef<HTMLInputElement | null>(null) const linkInputRef = useRef<HTMLInputElement | null>(null)
const tagsInputRef = useRef<HTMLInputElement | null>(null) const tagsInputRef = useRef<TagsInputRef | null>(null)
const getTags = useAtomCallback(
useCallback((get) => {
const value = get(tagsInputValueAtom)
return value.trim().split(" ")
}, []),
)
useMnemonics( useMnemonics(
{ {
@@ -53,7 +27,7 @@ function AddBookmarkDialog() {
}, },
Escape: () => { Escape: () => {
linkInputRef.current?.blur() linkInputRef.current?.blur()
tagsInputRef.current?.blur() tagsInputRef.current?.input?.blur()
}, },
}, },
{ ignore: () => false }, { ignore: () => false },
@@ -68,19 +42,25 @@ function AddBookmarkDialog() {
}, []) }, [])
async function onSubmit(event: React.FormEvent<HTMLFormElement>) { async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() if (tagsInputRef.current) {
event.preventDefault()
const formData = new FormData(event.currentTarget) const formData = new FormData(event.currentTarget)
const url = formData.get("link") const url = formData.get("link")
if (url && typeof url === "string") { if (url && typeof url === "string") {
try { try {
await createBookmarkMutation.mutateAsync({ url, tags: getTags(), force: isWebsiteUnreachable }) await createBookmarkMutation.mutateAsync({
setActiveDialog({ kind: DialogKind.None }) url,
} catch (error) { tags: tagsInputRef.current.tags,
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) { force: isWebsiteUnreachable,
setIsWebsiteUnreachable(true) })
} else { setActiveDialog({ kind: DialogKind.None })
setIsWebsiteUnreachable(false) } catch (error) {
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
setIsWebsiteUnreachable(true)
} else {
setIsWebsiteUnreachable(false)
}
} }
} }
} }
@@ -144,175 +124,4 @@ function AddBookmarkDialog() {
) )
} }
function TagsInput({ ref }: { ref: React.Ref<HTMLInputElement | null> }) {
const [value, setValue] = useAtom(tagsInputValueAtom)
const [isInputFocused, setIsInputFocused] = useState(false)
const [lastTag] = useAtom(lastTagAtom)
const { refs, floatingStyles } = useFloating<HTMLInputElement>({
whileElementsMounted: autoUpdate,
middleware: [
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
})
},
}),
],
})
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(ref, () => refs.reference.current)
return (
<>
<FormField
ref={refs.setReference}
type="text"
name="tags"
label="TAGS"
value={value}
onChange={(event) => {
setValue(event.currentTarget.value)
}}
className="flex-1"
onFocus={() => {
setIsInputFocused(true)
}}
onBlur={() => {
setIsInputFocused(false)
}}
labelClassName="bg-stone-300 dark:bg-stone-800"
/>
{isInputFocused && lastTag !== "" ? <TagList ref={refs.setFloating} style={floatingStyles} /> : null}
</>
)
}
function TagList({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
const { data: tags, status } = useTags()
switch (status) {
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "success":
return <_TagList ref={ref} style={style} tags={tags} />
case "error":
return null
}
}
function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
const [selectedTag, setSelectedTag] = useState<Tag | null | undefined>(undefined)
const [, appendTag] = useAtom(appendTagAtom)
const [lastTag] = useAtom(lastTagAtom)
const filteredTags: Tag[] = []
const listItems: React.ReactElement[] = []
let hasExactMatch = false
let shouldResetSelection = selectedTag !== null
for (const tag of tags) {
if (tag.name.startsWith(lastTag)) {
if (tag.name.length === lastTag.length) {
hasExactMatch = true
}
if (tag.id === selectedTag?.id) {
shouldResetSelection = false
}
filteredTags.push(tag)
listItems.push(
<li
className={clsx("text-start py-1", {
"bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag?.id === tag.id,
})}
key={tag.id}
>
&nbsp;#{tag.name}
</li>,
)
}
}
if (hasExactMatch && selectedTag === null) {
shouldResetSelection = true
}
useEffect(() => {
if (shouldResetSelection) {
if (listItems.length === 0) {
setSelectedTag(null)
} else {
setSelectedTag(filteredTags[0])
}
}
}, [shouldResetSelection])
useMnemonics(
{
ArrowUp: (event) => {
event.preventDefault()
if (selectedTag) {
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
if (i === 0 || i === filteredTags.length - 1) {
setSelectedTag(null)
} else if (i === -1) {
setSelectedTag(filteredTags[0])
} else {
setSelectedTag(filteredTags[i + 1])
}
} else {
setSelectedTag(filteredTags.at(-1) ?? null)
}
},
ArrowDown: (event) => {
event.preventDefault()
if (selectedTag) {
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
if (i === filteredTags.length - 1) {
setSelectedTag(null)
} else {
setSelectedTag(filteredTags[i + 1])
}
} else {
setSelectedTag(filteredTags[0])
}
},
Enter: (event) => {
if (lastTag) {
event.preventDefault()
event.stopPropagation()
if (selectedTag) {
appendTag(`${selectedTag.name.slice(lastTag.length)} `)
} else {
appendTag(" ")
}
}
},
},
{ ignore: () => false },
)
if (lastTag === "") {
return null
}
return (
<div ref={ref} style={style} className="bg-stone-300 dark:bg-stone-800 border-2 mt-1">
<ul className="py-2">
{listItems}
{hasExactMatch ? null : (
<li
className={clsx("text-start py-1", {
"bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag === null,
})}
>
&nbsp;{lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`}
</li>
)}
</ul>
</div>
)
}
export { AddBookmarkDialog } export { AddBookmarkDialog }

View File

@@ -138,7 +138,14 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
})} })}
key={tag.id} key={tag.id}
> >
&nbsp;#{tag.name} <button
type="button"
onClick={() => {
addTag(tag)
}}
>
&nbsp;#{tag.name}
</button>
</li>, </li>,
) )
} }
@@ -163,12 +170,12 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
event.preventDefault() event.preventDefault()
if (selectedTag) { if (selectedTag) {
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id) const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
if (i === 0 || i === filteredTags.length - 1) { if (i === 0) {
setSelectedTag(null) setSelectedTag(null)
} else if (i === -1) { } else if (i === -1) {
setSelectedTag(filteredTags[0]) setSelectedTag(filteredTags[0])
} else { } else {
setSelectedTag(filteredTags[i + 1]) setSelectedTag(filteredTags[i - 1])
} }
} else { } else {
setSelectedTag(filteredTags.at(-1) ?? null) setSelectedTag(filteredTags.at(-1) ?? null)
@@ -191,18 +198,22 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
if (lastTag) { if (lastTag) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (selectedTag) { addTag(selectedTag)
setValue((value) => `${value}${selectedTag.name.slice(lastTag.length)} `)
} else {
// biome-ignore lint/style/useTemplate: this is more readable than using template literal
setValue((value) => value + " ")
}
} }
}, },
}, },
{ ignore: () => false }, { ignore: () => false },
) )
function addTag(selectedTag: Tag | null | undefined) {
if (selectedTag) {
setValue((value) => `${value}${selectedTag.name.slice(lastTag.length)} `)
} else {
// biome-ignore lint/style/useTemplate: this is more readable than using template literal
setValue((value) => value + " ")
}
}
if (lastTag === "") { if (lastTag === "") {
return null return null
} }
@@ -217,7 +228,18 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
"bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag === null, "bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag === null,
})} })}
> >
&nbsp;{lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`} {lastTag.includes("#") ? (
<>&nbsp;Tags cannot contain '#'</>
) : (
<button
type="button"
onClick={() => {
addTag(null)
}}
>
&nbsp;Add tag: #{lastTag}
</button>
)}
</li> </li>
)} )}
</ul> </ul>