fix(search): adapt input shape and button position to content line count (#17529)

This commit is contained in:
chanx
2026-07-29 17:15:19 +08:00
committed by GitHub
parent c3aa8053c0
commit f8e01d558e
3 changed files with 70 additions and 17 deletions

View File

@@ -1,26 +1,67 @@
import { useLayoutEffect, type RefObject } from 'react';
import { useLayoutEffect, useState, type RefObject } from 'react';
/**
* Auto-resize a textarea to fit its content, clamped to a maximum height.
*
* useLayoutEffect runs synchronously after DOM mutation but before the
* browser paints, so the height adjustment never produces a visible flicker.
*
* Measurement also re-runs when the element's content-box width changes
* (e.g. responsive layout or font-metric changes): soft-wrapping can add or
* remove lines without `value` changing, which would otherwise leave
* `isMultiLine` stale and misposition controls. A ResizeObserver watches
* width only; height-only changes (which we cause ourselves when setting
* `el.style.height`) are ignored to avoid feedback loops.
*
* Returns `isMultiLine` - true when the rendered content spans more than one
* line (including soft-wrapped lines), derived from scrollHeight vs. the
* computed single-line height.
*/
export function useAutoResizeTextarea(
ref: RefObject<HTMLTextAreaElement | null>,
value: string,
maxHeight = 160,
) {
const [isMultiLine, setIsMultiLine] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
// scrollHeight excludes borders while height is border-box, so the
// content box ends up a couple of pixels short and overflow-y-auto
// would show a scrollbar even for a single line. Hide it until the
// content actually exceeds the max height.
const overflowing = el.scrollHeight > maxHeight;
el.style.overflowY = overflowing ? 'auto' : 'hidden';
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
const measure = () => {
el.style.height = 'auto';
// scrollHeight excludes borders while height is border-box, so the
// content box ends up a couple of pixels short and overflow-y-auto
// would show a scrollbar even for a single line. Hide it until the
// content actually exceeds the max height.
const scrollHeight = el.scrollHeight;
const overflowing = scrollHeight > maxHeight;
el.style.overflowY = overflowing ? 'auto' : 'hidden';
el.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
const style = getComputedStyle(el);
const lineHeight =
parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
const verticalPadding =
parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
setIsMultiLine(scrollHeight > verticalPadding + lineHeight + 1);
};
measure();
let lastWidth: number | undefined;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
if (width === lastWidth) continue;
lastWidth = width;
measure();
}
});
ro.observe(el);
return () => ro.disconnect();
}, [ref, value, maxHeight]);
return isMultiLine;
}

View File

@@ -1,7 +1,8 @@
import Spotlight from '@/components/spotlight';
import message from '@/components/ui/message';
import { IUserInfo } from '@/interfaces/database/user-setting';
import { useAutoResizeTextarea } from '@/hooks/use-auto-resize-textarea';
import { IUserInfo } from '@/interfaces/database/user-setting';
import { cn } from '@/lib/utils';
import { Search } from 'lucide-react';
import { Dispatch, SetStateAction, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,7 +30,7 @@ export default function SearchHome({
const { t } = useTranslation();
const searchInputRef = useRef<HTMLTextAreaElement>(null);
useAutoResizeTextarea(searchInputRef, searchText);
const isMultiLine = useAutoResizeTextarea(searchInputRef, searchText);
return (
<section className="relative w-full flex transition-all justify-center items-center mt-[15vh]">
@@ -56,7 +57,10 @@ export default function SearchHome({
ref={searchInputRef}
rows={1}
placeholder={t('search.searchGreeting')}
className="w-full rounded-3xl py-4 px-4 pr-14 text-text-primary text-lg bg-bg-base delay-700 border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50"
className={cn(
'w-full py-4 px-4 pr-14 text-text-primary text-lg bg-bg-base border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50',
isMultiLine ? 'rounded-3xl' : 'rounded-full',
)}
value={searchText}
onKeyDown={(e) => {
if (
@@ -82,7 +86,10 @@ export default function SearchHome({
/>
<button
type="button"
className="absolute bottom-3 right-3 flex size-9 items-center justify-center rounded-full bg-text-primary text-bg-base shadow transition-opacity hover:opacity-90"
className={cn(
'absolute right-3 flex size-9 items-center justify-center rounded-full bg-text-primary text-bg-base shadow transition-opacity hover:opacity-90',
isMultiLine ? 'bottom-3' : 'top-1/2 -translate-y-1/2',
)}
onClick={() => {
if (canSearch === false) {
message.warning(t('search.chooseDataset'));

View File

@@ -79,8 +79,7 @@ export default function SearchingView({
setSearchText(searchStr);
}, [searchStr, setSearchText]);
useAutoResizeTextarea(searchInputRef, searchText);
const isMultiLine = useAutoResizeTextarea(searchInputRef, searchText);
return (
<section
className={cn(
@@ -111,7 +110,8 @@ export default function SearchingView({
rows={1}
placeholder={t('search.searchGreeting')}
className={cn(
'w-full rounded-3xl py-4 pl-4 !pr-[8rem] text-primary text-lg bg-bg-base border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
'w-full rounded-full py-4 pl-4 !pr-[8rem] text-primary text-lg bg-bg-base border border-border-button resize-none scrollbar-thin outline-none focus-visible:ring-1 focus-visible:ring-text-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
isMultiLine ? 'rounded-3xl' : 'rounded-full',
)}
value={searchText}
onChange={(e) => {
@@ -129,7 +129,12 @@ export default function SearchingView({
}
}}
/>
<div className="absolute bottom-3 right-3 flex items-center gap-3">
<div
className={cn(
'absolute right-3 flex items-center gap-3',
isMultiLine ? 'bottom-3' : 'top-1/2 -translate-y-1/2',
)}
>
{searchText && (
<X
className="text-text-secondary cursor-pointer opacity-80 hover:opacity-100"