From 75cfd554ca6416581cfe69d96ecf18ae95f018a8 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 20 Sep 2024 20:41:28 +0800 Subject: [PATCH] refactor: auto completion component Signed-off-by: Innei --- apps/renderer/package.json | 1 + .../ui/auto-completion/AutoCompletion.tsx | 328 ++++-------------- .../src/hooks/common/useInputComposition.ts | 3 +- .../src/modules/discover/feed-form.tsx | 53 ++- locales/errors/zh-CN.json | 8 +- pnpm-lock.yaml | 150 ++++++++ 6 files changed, 251 insertions(+), 292 deletions(-) diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 3b914eecc..085656fd4 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -15,6 +15,7 @@ "@follow/electron-main": "workspace:*", "@follow/shared": "workspace:*", "@fontsource/sn-pro": "5.1.0", + "@headlessui/react": "2.1.8", "@hono/auth-js": "1.0.10", "@hookform/resolvers": "3.9.0", "@iconify/tools": "4.0.6", diff --git a/apps/renderer/src/components/ui/auto-completion/AutoCompletion.tsx b/apps/renderer/src/components/ui/auto-completion/AutoCompletion.tsx index 741b65446..181cae95a 100644 --- a/apps/renderer/src/components/ui/auto-completion/AutoCompletion.tsx +++ b/apps/renderer/src/components/ui/auto-completion/AutoCompletion.tsx @@ -1,24 +1,11 @@ -import clsx from "clsx" -import { AnimatePresence } from "framer-motion" +import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react" +import { AnimatePresence, m } from "framer-motion" import Fuse from "fuse.js" -import { merge, throttle } from "lodash-es" -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useLayoutEffect, - useRef, - useState, -} from "react" -import { useEventCallback } from "usehooks-ts" +import { forwardRef, Fragment, useCallback, useEffect, useState } from "react" -import { useRefValue } from "~/hooks/common" -import { nextFrame, stopPropagation } from "~/lib/dom" import { cn } from "~/lib/utils" import { Input } from "../input" -import { RootPortal } from "../portal" export type Suggestion = { name: string @@ -28,277 +15,110 @@ export interface AutocompleteProps extends React.InputHTMLAttributes any - onSuggestionSelected: (suggestion: Suggestion) => void - onConfirm?: (value: string) => void - onEndReached?: () => void + onSuggestionSelected: (suggestion: NoInfer | null) => void portal?: boolean // classnames - wrapperClassName?: string - maxHeight?: number } -const defaultRenderSuggestion = (suggestion: any) => suggestion.name +const defaultRenderSuggestion = (suggestion: any) => suggestion?.name export const Autocomplete = forwardRef( ( { suggestions, renderSuggestion = defaultRenderSuggestion, onSuggestionSelected, - onConfirm, - onEndReached, - onChange, - portal, - wrapperClassName, maxHeight, + portal, + value, + defaultValue, ...inputProps }, forwardedRef, ) => { - const [filterableSuggestions, setFilterableSuggestions] = useState(suggestions) - const [inputValue, setInputValue] = useState(inputProps.value || inputProps.defaultValue || "") + const [selectedOptions, setSelectedOptions] = useState | null>( + () => suggestions.find((suggestion) => suggestion.value === value) || null, + ) - const doFilter = useEventCallback(() => { + const [filterableSuggestions, setFilterableSuggestions] = useState(suggestions) + + const doFilter = useCallback(() => { const fuse = new Fuse(suggestions, { keys: ["name", "value"], }) - const trimInputValue = (inputValue as string).trim() + + const trimInputValue = (value as string)?.trim() if (!trimInputValue) return setFilterableSuggestions(suggestions) const results = fuse.search(trimInputValue) setFilterableSuggestions(results.map((result) => result.item)) - }) + }, [suggestions, value]) useEffect(() => { doFilter() - }, [inputValue, suggestions]) - - useEffect(() => { - const $input = inputRef.current - if (!$input) return - if (document.activeElement !== $input) { - return - } - - setIsOpen(filterableSuggestions.length > 0) - }, [filterableSuggestions]) - - const [isOpen, setIsOpen] = useState(false) - - const [listRef, setListRef] = useState(null) - const onBlur = useEventCallback((e: any) => { - inputProps.onBlur?.(e) - if (listRef?.contains(e.relatedTarget)) { - return - } - setIsOpen(false) - }) - - const handleInputKeyDown: React.KeyboardEventHandler = useEventCallback( - (e) => { - if (e.key === "Enter") { - e.preventDefault() - onConfirm?.((e.target as HTMLInputElement).value) - setIsOpen(false) - } - inputProps.onKeyDown?.(e) - }, - ) - const inputRef = useRef(null) - useImperativeHandle(forwardedRef, () => inputRef.current!) - - const [currentActiveIndex, setCurrentActiveIndex] = useState(0) - const currentActiveIndexRef = useRefValue(currentActiveIndex) - const filterableSuggestionsRef = useRefValue(filterableSuggestions) - // Bind hotkey - useEffect(() => { - const $input = inputRef.current - if (!$input) return - const handleKeyDown = (e: KeyboardEvent) => { - const currentActiveIndex = currentActiveIndexRef.current - const filterableSuggestionsLength = filterableSuggestionsRef.current.length - const filterableSuggestions = filterableSuggestionsRef.current - - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault() - const nextIndex = - currentActiveIndex + (e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0) - setCurrentActiveIndex(Math.max(0, Math.min(nextIndex, filterableSuggestionsLength - 1))) - } else if ( - (e.key === "Enter" || e.key === "Tab") && - currentActiveIndex >= 0 && - currentActiveIndex < filterableSuggestionsLength - ) { - e.stopPropagation() - e.preventDefault() - - onSuggestionSelected(filterableSuggestions[currentActiveIndex]) - setInputValue(filterableSuggestions[currentActiveIndex].name) - - nextFrame(() => { - setIsOpen(false) - }) - } - } - - $input.addEventListener("keydown", handleKeyDown) - return () => { - $input.removeEventListener("keydown", handleKeyDown) - } - }, [currentActiveIndexRef, filterableSuggestionsRef, onSuggestionSelected]) - - const [inputRect, setInputRect] = useState(null) - const [dropdownPos, setDropdownPos] = useState("down") - const [dropdownHeight, setDropdownHeight] = useState(maxHeight || 200) - - useLayoutEffect(() => { - const $input = inputRef.current - if (!$input) return - - const handler = () => { - const rect = $input.getBoundingClientRect() - - setInputRect(rect) - - const { top, height } = rect - - if (top + height + dropdownHeight > window.innerHeight) { - setDropdownPos("up") - } else { - setDropdownPos("down") - } - } - handler() - - const resizeObserver = new ResizeObserver(handler) - resizeObserver.observe($input) - - return () => { - resizeObserver.disconnect() - } - }, [dropdownHeight]) - - const handleScroll = useEventCallback( - throttle(() => { - if (!listRef) return - const { scrollHeight, scrollTop, clientHeight } = listRef! - // gap 50px - if (scrollHeight - scrollTop - clientHeight < 50) { - onEndReached?.() - } - }, 30), - ) - - const handleChange = useEventCallback((e: any) => { - setInputValue(e.target.value) - onChange?.(e) - }) - - const ListElement = ( -
{ - setListRef(el) - - const height = el?.getBoundingClientRect().height - if (height) { - setDropdownHeight(height) - } - }, [])} - style={merge( - {}, - portal && inputRect - ? { - width: `${inputRect.width}px`, - left: `${inputRect.x}px`, - top: `${inputRect.y + inputRect.height}px`, - bottom: dropdownPos === "up" ? 0 : undefined, - transform: dropdownPos === "up" ? `translateY(-${dropdownHeight}px)` : undefined, - maxHeight, - } - : {}, - )} - > -
    - {filterableSuggestions.map((suggestion, index) => { - const handleClick = () => { - onSuggestionSelected(suggestion) - setIsOpen(false) - - setInputValue(suggestion.name) - } - return ( -
  • setCurrentActiveIndex(index)} - > - {renderSuggestion(suggestion)} -
  • - ) - })} -
-
- ) - const handleFocus: React.FocusEventHandler = useEventCallback((e) => { - setIsOpen(true) - inputProps.onFocus?.(e) - }) + }, [doFilter]) return ( -
-
- - {!!inputValue && ( - - )} -
- - {isOpen && - filterableSuggestions.length > 0 && - (portal ? {ListElement} : ListElement)} - -
+ { + setSelectedOptions(suggestion) + onSuggestionSelected(suggestion) + }} + > + {({ open }) => { + return ( + + + + {open && ( + +
+ {filterableSuggestions.map((suggestion) => ( + + {suggestion.name} + + ))} +
+
+ )} +
+
+ ) + }} +
) }, ) diff --git a/apps/renderer/src/hooks/common/useInputComposition.ts b/apps/renderer/src/hooks/common/useInputComposition.ts index 12b432a83..59505f3e1 100644 --- a/apps/renderer/src/hooks/common/useInputComposition.ts +++ b/apps/renderer/src/hooks/common/useInputComposition.ts @@ -49,9 +49,10 @@ export const useInputComposition = ( onKeyDown?.(e) if (e.key === "Escape") { - e.currentTarget.blur() e.preventDefault() e.stopPropagation() + + e.currentTarget.blur() } }, [onKeyDown], diff --git a/apps/renderer/src/modules/discover/feed-form.tsx b/apps/renderer/src/modules/discover/feed-form.tsx index cae7e5407..58b0769c3 100644 --- a/apps/renderer/src/modules/discover/feed-form.tsx +++ b/apps/renderer/src/modules/discover/feed-form.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useMutation } from "@tanstack/react-query" -import { useEffect, useRef } from "react" +import { useEffect, useMemo, useRef } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -25,8 +25,7 @@ import { LoadingCircle } from "~/components/ui/loading" import { useCurrentModal } from "~/components/ui/modal" import { Switch } from "~/components/ui/switch" import { views } from "~/constants" -import { useDeleteSubscription } from "~/hooks/biz/useSubscriptionActions" -import { useAuthQuery } from "~/hooks/common" +import { useAuthQuery, useI18n } from "~/hooks/common" import { apiClient } from "~/lib/api-fetch" import { tipcClient } from "~/lib/client" import { FeedViewType } from "~/lib/enum" @@ -58,6 +57,7 @@ export const FeedForm: Component<{ onSuccess?: () => void }> = ({ id: _id, defaultValues = defaultValue, url, asWidget, onSuccess }) => { const queryParams = { id: _id, url } + const feedQuery = useFeed(queryParams) const id = feedQuery.data?.feed.id || _id @@ -171,7 +171,7 @@ const FeedInnerForm = ({ defaultValues, }) - const { setClickOutSideToDismiss } = useCurrentModal() + const { setClickOutSideToDismiss, dismiss } = useCurrentModal() useEffect(() => { setClickOutSideToDismiss(!form.formState.isDirty) @@ -233,27 +233,22 @@ const FeedInnerForm = ({ }, }) - const deleteSubscription = useDeleteSubscription({ - onSuccess: () => { - if (!asWidget && !isSubscribed) { - window.close() - } - - onSuccess?.() - }, - }) - function onSubmit(values: z.infer) { followMutation.mutate(values) } - const { t } = useTranslation() + const t = useI18n() const categories = useAuthQuery(subscriptionQuery.categories()) - // useEffect(() => { - // if (feed.isSuccess) nextFrame(() => buttonRef.current?.focus()); - // }, [feed.isSuccess]); + const suggestions = useMemo( + () => + categories.data?.map((i) => ({ + name: i, + value: i, + })) || [], + [categories.data], + ) return (
@@ -333,15 +328,12 @@ const FeedInnerForm = ({ ({ - name: i, - value: i, - })) || [] - } + suggestions={suggestions} {...(field as any)} onSuggestionSelected={(suggestion) => { - field.onChange(suggestion.value) + if (suggestion) { + field.onChange(suggestion.value) + } }} />
@@ -378,16 +370,11 @@ const FeedInnerForm = ({ type="button" ref={buttonRef} variant="text" - isLoading={deleteSubscription.isPending} - className="text-red-500" - onClick={(e) => { - e.preventDefault() - if (subscription) { - deleteSubscription.mutate(subscription) - } + onClick={() => { + dismiss() }} > - {t("feed_form.unfollow")} + {t.common("cancel")} )}