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