From 0c8325eede3d30407f60616459b89215f7add7ce Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 25 May 2025 17:09:09 +0100 Subject: [PATCH] fix tags input list up/down nav --- .../-dialogs/add-bookmark-dialog.tsx | 237 ++---------------- packages/web/src/components/tags-input.tsx | 42 +++- 2 files changed, 55 insertions(+), 224 deletions(-) diff --git a/packages/web/src/app/bookmarks/-dialogs/add-bookmark-dialog.tsx b/packages/web/src/app/bookmarks/-dialogs/add-bookmark-dialog.tsx index 5d490ef..1d29f9d 100644 --- a/packages/web/src/app/bookmarks/-dialogs/add-bookmark-dialog.tsx +++ b/packages/web/src/app/bookmarks/-dialogs/add-bookmark-dialog.tsx @@ -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(null) - const tagsInputRef = useRef(null) - const getTags = useAtomCallback( - useCallback((get) => { - const value = get(tagsInputValueAtom) - return value.trim().split(" ") - }, []), - ) + const tagsInputRef = useRef(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) { - 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 }) { - const [value, setValue] = useAtom(tagsInputValueAtom) - const [isInputFocused, setIsInputFocused] = useState(false) - const [lastTag] = useAtom(lastTagAtom) - const { refs, floatingStyles } = useFloating({ - whileElementsMounted: autoUpdate, - middleware: [ - size({ - apply({ rects, elements }) { - Object.assign(elements.floating.style, { - minWidth: `${rects.reference.width}px`, - }) - }, - }), - ], - }) - - useImperativeHandle(ref, () => refs.reference.current) - - return ( - <> - { - setValue(event.currentTarget.value) - }} - className="flex-1" - onFocus={() => { - setIsInputFocused(true) - }} - onBlur={() => { - setIsInputFocused(false) - }} - labelClassName="bg-stone-300 dark:bg-stone-800" - /> - {isInputFocused && lastTag !== "" ? : null} - - ) -} - -function TagList({ ref, style }: { ref: React.Ref; style: React.CSSProperties }) { - const { data: tags, status } = useTags() - switch (status) { - case "pending": - return ( -

- Loading -

- ) - case "success": - return <_TagList ref={ref} style={style} tags={tags} /> - case "error": - return null - } -} - -function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref; style: React.CSSProperties }) { - const [selectedTag, setSelectedTag] = useState(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( -
  • -  #{tag.name} -
  • , - ) - } - } - 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 ( -
    -
      - {listItems} - {hasExactMatch ? null : ( -
    • -  {lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`} -
    • - )} -
    -
    - ) -} - export { AddBookmarkDialog } diff --git a/packages/web/src/components/tags-input.tsx b/packages/web/src/components/tags-input.tsx index 547c1d8..75327ff 100644 --- a/packages/web/src/components/tags-input.tsx +++ b/packages/web/src/components/tags-input.tsx @@ -138,7 +138,14 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref -  #{tag.name} + , ) } @@ -163,12 +170,12 @@ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref 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 `${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 -  {lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`} + {lastTag.includes("#") ? ( + <> Tags cannot contain '#' + ) : ( + + )} )}