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 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 { useEffect, useId, useRef, useState } from "react"
import { ApiErrorCode, BadRequestError } from "~/api"
import { useCreateBookmark, useTags } from "~/bookmark/api"
import { useCreateBookmark } from "~/bookmark/api"
import { Button } from "~/components/button"
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
import { FormField } from "~/components/form-field"
import { LoadingSpinner } from "~/components/loading-spinner"
import { Message, MessageVariant } from "~/components/message"
import { TagsInput, type TagsInputRef } from "~/components/tags-input.tsx"
import { useMnemonics } from "~/hooks/use-mnemonics"
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() {
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
const createBookmarkMutation = useCreateBookmark()
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const formId = useId()
const linkInputRef = useRef<HTMLInputElement | null>(null)
const tagsInputRef = useRef<HTMLInputElement | null>(null)
const getTags = useAtomCallback(
useCallback((get) => {
const value = get(tagsInputValueAtom)
return value.trim().split(" ")
}, []),
)
const tagsInputRef = useRef<TagsInputRef | null>(null)
useMnemonics(
{
@@ -53,7 +27,7 @@ function AddBookmarkDialog() {
},
Escape: () => {
linkInputRef.current?.blur()
tagsInputRef.current?.blur()
tagsInputRef.current?.input?.blur()
},
},
{ ignore: () => false },
@@ -68,19 +42,25 @@ function AddBookmarkDialog() {
}, [])
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (tagsInputRef.current) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const url = formData.get("link")
if (url && typeof url === "string") {
try {
await createBookmarkMutation.mutateAsync({ url, tags: getTags(), force: isWebsiteUnreachable })
setActiveDialog({ kind: DialogKind.None })
} catch (error) {
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
setIsWebsiteUnreachable(true)
} else {
setIsWebsiteUnreachable(false)
const formData = new FormData(event.currentTarget)
const url = formData.get("link")
if (url && typeof url === "string") {
try {
await createBookmarkMutation.mutateAsync({
url,
tags: tagsInputRef.current.tags,
force: isWebsiteUnreachable,
})
setActiveDialog({ kind: DialogKind.None })
} 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 }

View File

@@ -138,7 +138,14 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
})}
key={tag.id}
>
&nbsp;#{tag.name}
<button
type="button"
onClick={() => {
addTag(tag)
}}
>
&nbsp;#{tag.name}
</button>
</li>,
)
}
@@ -163,12 +170,12 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
event.preventDefault()
if (selectedTag) {
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
if (i === 0 || i === filteredTags.length - 1) {
if (i === 0) {
setSelectedTag(null)
} else if (i === -1) {
setSelectedTag(filteredTags[0])
} else {
setSelectedTag(filteredTags[i + 1])
setSelectedTag(filteredTags[i - 1])
}
} else {
setSelectedTag(filteredTags.at(-1) ?? null)
@@ -191,18 +198,22 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
if (lastTag) {
event.preventDefault()
event.stopPropagation()
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 + " ")
}
addTag(selectedTag)
}
},
},
{ 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 === "") {
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,
})}
>
&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>
)}
</ul>