fix tags input list up/down nav
This commit is contained in:
@@ -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}
|
||||
>
|
||||
#{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,
|
||||
})}
|
||||
>
|
||||
{lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { AddBookmarkDialog }
|
||||
|
@@ -138,7 +138,14 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivEle
|
||||
})}
|
||||
key={tag.id}
|
||||
>
|
||||
#{tag.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
addTag(tag)
|
||||
}}
|
||||
>
|
||||
#{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,
|
||||
})}
|
||||
>
|
||||
{lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`}
|
||||
{lastTag.includes("#") ? (
|
||||
<> Tags cannot contain '#'</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
addTag(null)
|
||||
}}
|
||||
>
|
||||
Add tag: #{lastTag}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
Reference in New Issue
Block a user