mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(search): make the carried text survive the blur, and keep the selector out of the remount
Round-1 fix round on #4953. Four approved changes, no behaviour outside them. 1. The carry was INERT on AutocompleteSearch. Its input bound onBlur to the same handler as the clear button, and that handler writes the carrier — so clicking the category selector (which blurs the input first) emptied the carrier a moment before the index switch it exists to survive. useCarriedSearchText now also returns a display-only clear; blur uses it, the clear button still goes through the setter, and onClear?.() fires from both so the mobile overlay still closes. The carrier is emptied when NAVIGATION moves the target, so only a pick from the selector re-seeds and abandoned text cannot reappear on the next link followed. 2. Both index selectors are now rendered ABOVE <InstantSearch>. The provider returns null until its start effect has run, so a key change commits one render with no subtree at all: the control the user just clicked was destroyed and rebuilt by their own click, dropping focus to <body>. Neither selector consumes the provider's context. Whether that null commit is PAINTED was not measured. 3. The selector-value clamp pin is now compared with whitespace removed from both sides. Both call sites had sat at exactly printWidth, so one rename or one indent level would have reddened it for a prettier re-wrap. Mutating the clamp still fails it, and a deliberate re-wrap does not. 4. QuickSearchDropdown's fallbackIndex comment said the target stays inside the set the selector OFFERS. It does not: the clamp reads supportedIndexes, and the offered list narrows that further by feature flag. Reworded to what the code does. Coverage: the blur-then-switch path had none — every carry assertion was a source spelling check and the behavioural harness modelled a remount with no blur. Added that case plus a same-carrier negative control that differs only in which setter the empty value goes through, a file-order pin for each selector's position, and a spelling pin for the blur wiring including the onClear?.() in both handlers.
This commit is contained in:
@@ -93,36 +93,36 @@ type Props = Omit<AutocompleteProps, 'data' | 'onSubmit'> & {
|
||||
// never reaches `useInstantSearch().status`, so we can't key off that).
|
||||
const searchClient: InstantSearchProps['searchClient'] = withUserHydration(
|
||||
createResilientSearchClient(
|
||||
{
|
||||
...meilisearch,
|
||||
search(requests) {
|
||||
// Prevent making a request if there is no query
|
||||
// @see https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/#detecting-empty-search-requests
|
||||
// @see https://github.com/algolia/react-instantsearch/issues/1111#issuecomment-496132977
|
||||
if (requests.every(({ params }) => !params?.query)) {
|
||||
return Promise.resolve({
|
||||
results: requests.map(() => ({
|
||||
hits: [],
|
||||
nbHits: 0,
|
||||
nbPages: 0,
|
||||
page: 0,
|
||||
processingTimeMS: 0,
|
||||
hitsPerPage: 0,
|
||||
exhaustiveNbHits: false,
|
||||
query: '',
|
||||
params: '',
|
||||
})),
|
||||
});
|
||||
}
|
||||
{
|
||||
...meilisearch,
|
||||
search(requests) {
|
||||
// Prevent making a request if there is no query
|
||||
// @see https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/#detecting-empty-search-requests
|
||||
// @see https://github.com/algolia/react-instantsearch/issues/1111#issuecomment-496132977
|
||||
if (requests.every(({ params }) => !params?.query)) {
|
||||
return Promise.resolve({
|
||||
results: requests.map(() => ({
|
||||
hits: [],
|
||||
nbHits: 0,
|
||||
nbPages: 0,
|
||||
page: 0,
|
||||
processingTimeMS: 0,
|
||||
hitsPerPage: 0,
|
||||
exhaustiveNbHits: false,
|
||||
query: '',
|
||||
params: '',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return meilisearch.search(requests);
|
||||
return meilisearch.search(requests);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: () => autocompleteAvailability.setUnavailable(true),
|
||||
onSuccess: () => autocompleteAvailability.setUnavailable(false),
|
||||
}
|
||||
)
|
||||
{
|
||||
onError: () => autocompleteAvailability.setUnavailable(true),
|
||||
onSuccess: () => autocompleteAvailability.setUnavailable(false),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const DEFAULT_DROPDOWN_ITEM_LIMIT = 6;
|
||||
@@ -140,6 +140,7 @@ const targetData = [
|
||||
|
||||
export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ...props }, ref) => {
|
||||
const browsingSettingsAddons = useBrowsingSettingsAddons();
|
||||
const features = useFeatureFlags();
|
||||
const [targetIndex, setTargetIndex] = useState<SearchIndexKey>('models');
|
||||
const handleTargetChange = (value: SearchIndexKey) => {
|
||||
setTargetIndex(value);
|
||||
@@ -158,6 +159,12 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
|
||||
const currentSection = pathname.split('/')[1] || 'models';
|
||||
const searchTarget = targetData.find((t) => t.value === currentSection)?.value ?? 'models';
|
||||
useEffect(() => {
|
||||
// A navigation is not the switch the carry exists for. The input's blur handler empties the
|
||||
// visible text WITHOUT emptying the carrier (a blur is how you reach the category selector at
|
||||
// all), so text a user typed and walked away from would otherwise reappear — and be searched
|
||||
// again — on the next link they follow into another section. Only a pick from the selector
|
||||
// leaves the carrier loaded, and a pick does not change `searchTarget`.
|
||||
carriedSearchText.current = '';
|
||||
setTargetIndex(searchTarget);
|
||||
}, [searchTarget]);
|
||||
|
||||
@@ -186,25 +193,73 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
|
||||
|
||||
const resolvedIndexName = searchIndexMap[targetIndex as keyof typeof searchIndexMap];
|
||||
|
||||
// Ensure we disable search targets if they are not enabled. Hoisted because the selector's
|
||||
// value is clamped to this set as well as read from it — the two have to be the same list.
|
||||
const enabledTargets = targetData.filter(
|
||||
({ value }) =>
|
||||
(features.imageSearch ? true : value !== 'images') &&
|
||||
(features.bounties ? true : value !== 'bounties') &&
|
||||
(features.articles ? true : value !== 'articles') &&
|
||||
(features.toolSearch ? true : value !== 'tools') &&
|
||||
(features.comicSearch ? true : value !== 'comics')
|
||||
);
|
||||
|
||||
return (
|
||||
<InstantSearch
|
||||
// Needs re-render, the same way `SearchLayout` does it. Otherwise the search fires with the
|
||||
// previous index's parameters: react-instantsearch sets the new index and searches in its
|
||||
// render body, before the children that own `filters` have re-rendered.
|
||||
key={resolvedIndexName}
|
||||
searchClient={searchClient}
|
||||
indexName={resolvedIndexName}
|
||||
future={{ preserveSharedStateOnUnmount: false }}
|
||||
>
|
||||
<AutocompleteSearchContent
|
||||
{...props}
|
||||
indexName={targetIndex}
|
||||
ref={ref}
|
||||
onTargetChange={handleTargetChange}
|
||||
baseFilters={filters}
|
||||
carriedSearchText={carriedSearchText}
|
||||
<Group className={classes.wrapper} gap={0} wrap="nowrap">
|
||||
{/*
|
||||
ABOVE the keyed provider, and that placement is the point. `<InstantSearch>` returns `null`
|
||||
until its own effect has started the search, so a key change commits one render in which
|
||||
the whole subtree is gone. Inside it, the control the user just clicked would be destroyed
|
||||
and rebuilt by their own click — focus lands on `<body>`. It consumes nothing from the
|
||||
provider's context, so nothing is lost by lifting it out.
|
||||
*/}
|
||||
<Select
|
||||
// CONTROLLED. Uncontrolled, its displayed label is internal state, so a target switch
|
||||
// driven from anywhere else — the URL-follow effect above — would leave it showing a
|
||||
// category the search has moved off: a selector that lies about what it is searching.
|
||||
//
|
||||
// `null` rather than the target when the target is not an OFFERED option: the URL can
|
||||
// point the search at an index whose feature flag is off, and Mantine leaves a
|
||||
// controlled value it cannot resolve showing the PREVIOUS option's label. Blank is
|
||||
// honest about "none of these"; a stale label is the same lie in a different place.
|
||||
value={enabledTargets.some(({ value }) => value === targetIndex) ? targetIndex : null}
|
||||
aria-label="Search category"
|
||||
classNames={{
|
||||
root: classes.targetSelectorRoot,
|
||||
input: classes.targetSelectorInput,
|
||||
option: classes.targetSelectorOption,
|
||||
options: classes.targetSelectorOptions,
|
||||
dropdown: classes.targetSelectorDropdown,
|
||||
}}
|
||||
rightSectionProps={{
|
||||
className: classes.targetSelectorRightSection,
|
||||
}}
|
||||
maxDropdownHeight={280}
|
||||
data={enabledTargets}
|
||||
rightSection={<IconChevronDown size={16} color="currentColor" />}
|
||||
style={{ flexShrink: 1 }}
|
||||
onChange={(v: string | null) => handleTargetChange(v as SearchIndexKey)}
|
||||
autoComplete="off"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</InstantSearch>
|
||||
<InstantSearch
|
||||
// Needs re-render, the same way `SearchLayout` does it. Otherwise the search fires with the
|
||||
// previous index's parameters: react-instantsearch sets the new index and searches in its
|
||||
// render body, before the children that own `filters` have re-rendered.
|
||||
key={resolvedIndexName}
|
||||
searchClient={searchClient}
|
||||
indexName={resolvedIndexName}
|
||||
future={{ preserveSharedStateOnUnmount: false }}
|
||||
>
|
||||
<AutocompleteSearchContent
|
||||
{...props}
|
||||
indexName={targetIndex}
|
||||
ref={ref}
|
||||
baseFilters={filters}
|
||||
carriedSearchText={carriedSearchText}
|
||||
/>
|
||||
</InstantSearch>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -212,7 +267,6 @@ AutocompleteSearch.displayName = 'AutocompleteSearch';
|
||||
|
||||
type AutocompleteSearchProps<T extends SearchIndexKey> = Props & {
|
||||
indexName: T;
|
||||
onTargetChange: (target: T) => void;
|
||||
baseFilters: string[];
|
||||
carriedSearchText: React.MutableRefObject<string>;
|
||||
};
|
||||
@@ -224,7 +278,6 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
|
||||
className,
|
||||
searchBoxProps,
|
||||
indexName: indexNameProp,
|
||||
onTargetChange,
|
||||
baseFilters,
|
||||
carriedSearchText,
|
||||
...autocompleteProps
|
||||
@@ -237,7 +290,6 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
|
||||
const browsingSettingsAddons = useBrowsingSettingsAddons();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
const features = useFeatureFlags();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const domainColor = useDomainColor();
|
||||
|
||||
@@ -252,7 +304,7 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
|
||||
: indexNameProp;
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<ComboboxData[number] | null>(null);
|
||||
const [search, setSearch] = useCarriedSearchText(carriedSearchText, query);
|
||||
const [search, setSearch, clearDisplayedText] = useCarriedSearchText(carriedSearchText, query);
|
||||
const [queryFilters, setQueryFilters] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
|
||||
@@ -402,17 +454,6 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
|
||||
indexName,
|
||||
]);
|
||||
|
||||
// Ensure we disable search targets if they are not enabled. Hoisted because the selector's
|
||||
// value is clamped to this set as well as read from it — the two have to be the same list.
|
||||
const enabledTargets = targetData.filter(
|
||||
({ value }) =>
|
||||
(features.imageSearch ? true : value !== 'images') &&
|
||||
(features.bounties ? true : value !== 'bounties') &&
|
||||
(features.articles ? true : value !== 'articles') &&
|
||||
(features.toolSearch ? true : value !== 'tools') &&
|
||||
(features.comicSearch ? true : value !== 'comics')
|
||||
);
|
||||
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
const blurInput = () => inputRef.current?.blur();
|
||||
|
||||
@@ -433,11 +474,23 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
|
||||
onSubmit?.();
|
||||
};
|
||||
|
||||
// The explicit clear — the input's clear button. The user asked for the text to go, so the
|
||||
// carrier goes with it.
|
||||
const handleClear = () => {
|
||||
setSearch('');
|
||||
onClear?.();
|
||||
};
|
||||
|
||||
// Blur empties the input the same way it always has, but leaves the carried copy alone. Reaching
|
||||
// the category selector REQUIRES blurring this input, so a blur that wrote through `setSearch`
|
||||
// emptied the carrier immediately before every selector-driven index switch — the one path the
|
||||
// carry exists for. `onClear?.()` still fires, unchanged: on mobile it is what closes the search
|
||||
// overlay (`AppHeader` passes `onSearchDone`), and that is not ours to change here.
|
||||
const handleBlur = () => {
|
||||
clearDisplayedText();
|
||||
onClear?.();
|
||||
};
|
||||
|
||||
const getItemFromValue = (value: string) => {
|
||||
return (
|
||||
items.find((i) => i.value === value) ?? {
|
||||
@@ -536,186 +589,155 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
|
||||
filters={[...baseFilters, queryFilters]}
|
||||
hitsPerPage={DEFAULT_DROPDOWN_ITEM_LIMIT}
|
||||
/>
|
||||
<Group className={classes.wrapper} gap={0} wrap="nowrap">
|
||||
<Select
|
||||
// CONTROLLED. Uncontrolled, its displayed label is internal state inside the keyed
|
||||
// provider, so a target switch would remount it back to whatever the default said
|
||||
// while the search really did move — a selector that lies about what it is searching.
|
||||
//
|
||||
// `null` rather than the target when the target is not an OFFERED option: the URL can
|
||||
// point the search at an index whose feature flag is off, and Mantine leaves a
|
||||
// controlled value it cannot resolve showing the PREVIOUS option's label. Blank is
|
||||
// honest about "none of these"; a stale label is the same lie in a different place.
|
||||
value={enabledTargets.some(({ value }) => value === indexNameProp) ? indexNameProp : null}
|
||||
aria-label="Search category"
|
||||
classNames={{
|
||||
root: classes.targetSelectorRoot,
|
||||
input: classes.targetSelectorInput,
|
||||
option: classes.targetSelectorOption,
|
||||
options: classes.targetSelectorOptions,
|
||||
dropdown: classes.targetSelectorDropdown,
|
||||
}}
|
||||
rightSectionProps={{
|
||||
className: classes.targetSelectorRightSection,
|
||||
}}
|
||||
maxDropdownHeight={280}
|
||||
data={enabledTargets}
|
||||
rightSection={<IconChevronDown size={16} color="currentColor" />}
|
||||
style={{ flexShrink: 1 }}
|
||||
onChange={(v: string | null) => onTargetChange(v as TKey)}
|
||||
autoComplete="off"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ClearableAutoComplete
|
||||
ref={inputRef}
|
||||
key={indexName}
|
||||
className={className}
|
||||
classNames={classes}
|
||||
placeholder="Search Civitai"
|
||||
type="search"
|
||||
limit={
|
||||
results && results.nbHits > DEFAULT_DROPDOWN_ITEM_LIMIT
|
||||
? DEFAULT_DROPDOWN_ITEM_LIMIT + 1 // Allow one more to show more results option
|
||||
: DEFAULT_DROPDOWN_ITEM_LIMIT
|
||||
<ClearableAutoComplete
|
||||
ref={inputRef}
|
||||
key={indexName}
|
||||
className={className}
|
||||
classNames={classes}
|
||||
placeholder="Search Civitai"
|
||||
type="search"
|
||||
limit={
|
||||
results && results.nbHits > DEFAULT_DROPDOWN_ITEM_LIMIT
|
||||
? DEFAULT_DROPDOWN_ITEM_LIMIT + 1 // Allow one more to show more results option
|
||||
: DEFAULT_DROPDOWN_ITEM_LIMIT
|
||||
}
|
||||
defaultValue={query}
|
||||
value={search}
|
||||
data={items}
|
||||
onChange={(value) => {
|
||||
if (value == null || value === 'View more results') return;
|
||||
setSearch(value);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onClear={handleClear}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
['Escape', blurInput],
|
||||
['Enter', handleSubmit],
|
||||
])}
|
||||
onOptionSubmit={handleItemClick}
|
||||
renderOption={({ option }) => {
|
||||
const { key, ...item } = getItemFromValue(option.value);
|
||||
// Render special states
|
||||
if (key === 'blocked') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search query contains inappropriate content and has been blocked.
|
||||
</Text>
|
||||
<Text size="xs" align="center">
|
||||
Please try a different search term.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
defaultValue={query}
|
||||
value={search}
|
||||
data={items}
|
||||
onChange={(value) => {
|
||||
if (value == null || value === 'View more results') return;
|
||||
setSearch(value);
|
||||
}}
|
||||
onBlur={handleClear}
|
||||
onClear={handleClear}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
['Escape', blurInput],
|
||||
['Enter', handleSubmit],
|
||||
])}
|
||||
onOptionSubmit={handleItemClick}
|
||||
renderOption={({ option }) => {
|
||||
const { key, ...item } = getItemFromValue(option.value);
|
||||
// Render special states
|
||||
if (key === 'blocked') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search query contains inappropriate content and has been blocked.
|
||||
if (key === 'profanity') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search query contains inappropriate content that violates our community
|
||||
guidelines.
|
||||
</Text>
|
||||
{profanityAnalysis.matches.length > 0 && (
|
||||
<Text size="xs" align="center" c="dimmed">
|
||||
Flagged terms: {profanityAnalysis.matches.join(', ')}
|
||||
</Text>
|
||||
<Text size="xs" align="center">
|
||||
Please try a different search term.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'profanity') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search query contains inappropriate content that violates our community
|
||||
guidelines.
|
||||
</Text>
|
||||
{profanityAnalysis.matches.length > 0 && (
|
||||
<Text size="xs" align="center" c="dimmed">
|
||||
Flagged terms: {profanityAnalysis.matches.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" align="center">
|
||||
Please refine your search terms to find appropriate content.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'disabled') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search includes terms tied to real people. Content depicting real people is
|
||||
filtered from search results.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'blocked-words') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search query contains blocked words and has been filtered.
|
||||
</Text>
|
||||
<Text size="xs" align="center">
|
||||
Please try a different search term.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'error') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
There was an error while performing your request…
|
||||
</Text>
|
||||
<Text size="xs" align="center">
|
||||
Please try again later
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<Text size="xs" align="center">
|
||||
Please refine your search terms to find appropriate content.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'disabled') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search includes terms tied to real people. Content depicting real people is
|
||||
filtered from search results.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'blocked-words') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
Your search query contains blocked words and has been filtered.
|
||||
</Text>
|
||||
<Text size="xs" align="center">
|
||||
Please try a different search term.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (key === 'error') {
|
||||
return (
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="sm" align="center">
|
||||
There was an error while performing your request…
|
||||
</Text>
|
||||
<Text size="xs" align="center">
|
||||
Please try again later
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const Render = IndexRenderItem[indexName] ?? ModelSearchItem;
|
||||
return <Render {...item} />;
|
||||
}}
|
||||
rightSection={
|
||||
<HoverCard withArrow width={300} shadow="sm" openDelay={500}>
|
||||
<HoverCard.Target>
|
||||
<Text
|
||||
component="div"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Quick search keyboard shortcut"
|
||||
fw="bold"
|
||||
style={{
|
||||
border: `1px solid ${
|
||||
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]
|
||||
}`,
|
||||
borderRadius: theme.radius.sm,
|
||||
backgroundColor:
|
||||
colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0],
|
||||
color: colorScheme === 'dark' ? theme.colors.gray[5] : theme.colors.gray[6],
|
||||
textAlign: 'center',
|
||||
width: 24,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
/
|
||||
</Text>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Text size="sm" c="yellow" fw={500}>
|
||||
Pro-tip: Quick search faster!
|
||||
</Text>
|
||||
<Text size="xs" lh={1.2}>
|
||||
Open the quick search without leaving your keyboard by tapping the <Code>/</Code>{' '}
|
||||
key from anywhere and just start typing.
|
||||
</Text>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
}
|
||||
// prevent default filtering behavior
|
||||
filter={({ options }) => options}
|
||||
clearable={query.length > 0}
|
||||
maxDropdownHeight={isMobile ? 'calc(90vh - var(--header-height))' : 500}
|
||||
{...autocompleteProps}
|
||||
/>
|
||||
<LegacyActionIcon
|
||||
className={classes.searchButton}
|
||||
color="gray"
|
||||
variant="filled"
|
||||
size={36}
|
||||
onMouseDown={handleSubmit}
|
||||
aria-label="Search"
|
||||
>
|
||||
<IconSearch size={18} />
|
||||
</LegacyActionIcon>
|
||||
</Group>
|
||||
const Render = IndexRenderItem[indexName] ?? ModelSearchItem;
|
||||
return <Render {...item} />;
|
||||
}}
|
||||
rightSection={
|
||||
<HoverCard withArrow width={300} shadow="sm" openDelay={500}>
|
||||
<HoverCard.Target>
|
||||
<Text
|
||||
component="div"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Quick search keyboard shortcut"
|
||||
fw="bold"
|
||||
style={{
|
||||
border: `1px solid ${
|
||||
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]
|
||||
}`,
|
||||
borderRadius: theme.radius.sm,
|
||||
backgroundColor:
|
||||
colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0],
|
||||
color: colorScheme === 'dark' ? theme.colors.gray[5] : theme.colors.gray[6],
|
||||
textAlign: 'center',
|
||||
width: 24,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
/
|
||||
</Text>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Text size="sm" c="yellow" fw={500}>
|
||||
Pro-tip: Quick search faster!
|
||||
</Text>
|
||||
<Text size="xs" lh={1.2}>
|
||||
Open the quick search without leaving your keyboard by tapping the <Code>/</Code>{' '}
|
||||
key from anywhere and just start typing.
|
||||
</Text>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
}
|
||||
// prevent default filtering behavior
|
||||
filter={({ options }) => options}
|
||||
clearable={query.length > 0}
|
||||
maxDropdownHeight={isMobile ? 'calc(90vh - var(--header-height))' : 500}
|
||||
{...autocompleteProps}
|
||||
/>
|
||||
<LegacyActionIcon
|
||||
className={classes.searchButton}
|
||||
color="gray"
|
||||
variant="filled"
|
||||
size={36}
|
||||
onMouseDown={handleSubmit}
|
||||
aria-label="Search"
|
||||
>
|
||||
<IconSearch size={18} />
|
||||
</LegacyActionIcon>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,12 +155,15 @@ export const QuickSearchDropdown = ({
|
||||
dropdownItemLimit = 5,
|
||||
startingIndex,
|
||||
disableInitialSearch,
|
||||
showIndexSelect = true,
|
||||
...props
|
||||
}: QuickSearchDropdownProps) => {
|
||||
// The target has to stay inside the set the selector OFFERS, at both ends. A bare `models`
|
||||
// fallback can leave the component searching an index the caller never supported — a caller
|
||||
// that passes `['users']` gets a users picker whose hits are models — and now that the selector
|
||||
// is controlled by this value, a target outside the offered set also blanks the control.
|
||||
const features = useFeatureFlags();
|
||||
// The target is clamped to `supportedIndexes` — the set the CALLER declared, which is not
|
||||
// necessarily the set the selector offers: the offered list below narrows it further by feature
|
||||
// flag, and this fallback does not. A bare `models` fallback would leave the component searching
|
||||
// an index the caller never supported — a caller that passes `['users']` gets a users picker
|
||||
// whose hits are models.
|
||||
//
|
||||
// Every current caller either passes `startingIndex` or supports `models` first, so the
|
||||
// INITIAL value below is unchanged at every call site today. The reachable path is the
|
||||
@@ -178,58 +181,92 @@ export const QuickSearchDropdown = ({
|
||||
|
||||
const indexName = searchIndexMap[targetIndex];
|
||||
|
||||
return (
|
||||
<InstantSearch
|
||||
// Needs re-render, the same way `SearchLayout` does it. Otherwise the search fires with the
|
||||
// previous index's parameters: react-instantsearch sets the new index and searches in its
|
||||
// render body, before the children that own `filters` have re-rendered.
|
||||
key={indexName}
|
||||
searchClient={disableInitialSearch ? searchClient : meilisearch}
|
||||
indexName={indexName}
|
||||
future={{ preserveSharedStateOnUnmount: true }}
|
||||
>
|
||||
<BrowsingLevelFilter
|
||||
indexKey={targetIndex}
|
||||
filters={filters}
|
||||
hitsPerPage={dropdownItemLimit}
|
||||
/>
|
||||
// Ensure we disable search targets if they are not enabled. Hoisted because the selector's
|
||||
// value is clamped to this set as well as read from it — the two have to be the same list.
|
||||
const enabledTargets = (props.supportedIndexes ?? [])
|
||||
.filter(
|
||||
(value) =>
|
||||
(features.imageSearch ? true : searchIndexMap[value] !== IMAGES_SEARCH_INDEX) &&
|
||||
(features.toolSearch ? true : searchIndexMap[value] !== TOOLS_SEARCH_INDEX) &&
|
||||
(features.articles ? true : value !== 'articles')
|
||||
)
|
||||
.map((index) => ({ label: IndexToLabel[searchIndexMap[index]], value: index }));
|
||||
|
||||
<QuickSearchDropdownContent
|
||||
{...props}
|
||||
indexName={targetIndex}
|
||||
onIndexNameChange={handleTargetChange}
|
||||
dropdownItemLimit={dropdownItemLimit}
|
||||
carriedSearchText={carriedSearchText}
|
||||
/>
|
||||
</InstantSearch>
|
||||
return (
|
||||
<Group className={classes.wrapper} gap={0} wrap="nowrap">
|
||||
{!!showIndexSelect && (
|
||||
/*
|
||||
ABOVE the keyed provider, and that placement is the point. `<InstantSearch>` returns
|
||||
`null` until its own effect has started the search, so a key change commits one render in
|
||||
which the whole subtree is gone. Inside it, the control the user just clicked would be
|
||||
destroyed and rebuilt by their own click — focus lands on `<body>`. It consumes nothing
|
||||
from the provider's context, so nothing is lost by lifting it out.
|
||||
*/
|
||||
<Select
|
||||
className="shrink"
|
||||
classNames={{
|
||||
root: classes.targetSelectorRoot,
|
||||
input: classes.targetSelectorInput,
|
||||
section: classes.targetSelectorRightSection,
|
||||
}}
|
||||
maxDropdownHeight={280}
|
||||
// CONTROLLED, so the displayed label cannot drift from the index being searched.
|
||||
//
|
||||
// `null` rather than the target when the target is not an OFFERED option: Mantine leaves
|
||||
// a controlled value it cannot resolve showing the PREVIOUS option's label, which is a
|
||||
// lie about what is being searched. Blank is honest about "none of these".
|
||||
value={enabledTargets.some(({ value }) => value === targetIndex) ? targetIndex : null}
|
||||
data={enabledTargets}
|
||||
rightSection={<IconChevronDown size={16} color="currentColor" />}
|
||||
onChange={(value) => handleTargetChange(value as SearchIndexKey)}
|
||||
/>
|
||||
)}
|
||||
<InstantSearch
|
||||
// Needs re-render, the same way `SearchLayout` does it. Otherwise the search fires with the
|
||||
// previous index's parameters: react-instantsearch sets the new index and searches in its
|
||||
// render body, before the children that own `filters` have re-rendered.
|
||||
key={indexName}
|
||||
searchClient={disableInitialSearch ? searchClient : meilisearch}
|
||||
indexName={indexName}
|
||||
future={{ preserveSharedStateOnUnmount: true }}
|
||||
>
|
||||
<BrowsingLevelFilter
|
||||
indexKey={targetIndex}
|
||||
filters={filters}
|
||||
hitsPerPage={dropdownItemLimit}
|
||||
/>
|
||||
|
||||
<QuickSearchDropdownContent
|
||||
{...props}
|
||||
indexName={targetIndex}
|
||||
dropdownItemLimit={dropdownItemLimit}
|
||||
carriedSearchText={carriedSearchText}
|
||||
/>
|
||||
</InstantSearch>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
function QuickSearchDropdownContent<TIndex extends SearchIndexKey>({
|
||||
indexName: indexNameProp,
|
||||
onIndexNameChange,
|
||||
onItemSelected,
|
||||
filters,
|
||||
supportedIndexes,
|
||||
dropdownItemLimit = 5,
|
||||
showIndexSelect = true,
|
||||
placeholder,
|
||||
onHits,
|
||||
carriedSearchText,
|
||||
...autocompleteProps
|
||||
}: QuickSearchDropdownProps & {
|
||||
indexName: TIndex;
|
||||
onIndexNameChange: (indexName: TIndex) => void;
|
||||
carriedSearchText: React.MutableRefObject<string>;
|
||||
}) {
|
||||
// const currentUser = useCurrentUser();
|
||||
const { query, refine: setQuery, isSearchStalled } = useSearchBox();
|
||||
const { hits, results } = useHitsTransformed<TIndex>();
|
||||
const features = useFeatureFlags();
|
||||
const [search, setSearch] = useCarriedSearchText(carriedSearchText, query);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const isSubmittingOptionRef = useRef(false);
|
||||
const availableIndexes = supportedIndexes ?? [];
|
||||
|
||||
const indexName = results?.index
|
||||
? reverseSearchIndexMap[results.index as ReverseSearchIndexKey]
|
||||
@@ -310,98 +347,63 @@ function QuickSearchDropdownContent<TIndex extends SearchIndexKey>({
|
||||
// request itself. `isSearchStalled` alone leaves the first 300ms looking like a dead input.
|
||||
const loading = search.length > 0 && (search !== query || isSearchStalled);
|
||||
|
||||
// Ensure we disable search targets if they are not enabled. Hoisted because the selector's
|
||||
// value is clamped to this set as well as read from it — the two have to be the same list.
|
||||
const enabledTargets = availableIndexes
|
||||
.filter(
|
||||
(value) =>
|
||||
(features.imageSearch ? true : searchIndexMap[value] !== IMAGES_SEARCH_INDEX) &&
|
||||
(features.toolSearch ? true : searchIndexMap[value] !== TOOLS_SEARCH_INDEX) &&
|
||||
(features.articles ? true : value !== 'articles')
|
||||
)
|
||||
.map((index) => ({ label: IndexToLabel[searchIndexMap[index]], value: index }));
|
||||
|
||||
return (
|
||||
<Group className={classes.wrapper} gap={0} wrap="nowrap">
|
||||
{!!showIndexSelect && (
|
||||
<Select
|
||||
className="shrink"
|
||||
classNames={{
|
||||
root: classes.targetSelectorRoot,
|
||||
input: classes.targetSelectorInput,
|
||||
section: classes.targetSelectorRightSection,
|
||||
}}
|
||||
maxDropdownHeight={280}
|
||||
// CONTROLLED. Uncontrolled, its displayed label is internal state inside the keyed
|
||||
// provider, so a target switch would remount it back to the first supported index
|
||||
// while the search really did move — a selector that lies about what it is searching.
|
||||
//
|
||||
// `null` rather than the target when the target is not an OFFERED option: Mantine leaves
|
||||
// a controlled value it cannot resolve showing the PREVIOUS option's label, which is the
|
||||
// same lie in a different place. Blank is honest about "none of these".
|
||||
value={enabledTargets.some(({ value }) => value === indexNameProp) ? indexNameProp : null}
|
||||
data={enabledTargets}
|
||||
rightSection={<IconChevronDown size={16} color="currentColor" />}
|
||||
onChange={(value) => onIndexNameChange(value as TIndex)}
|
||||
/>
|
||||
)}
|
||||
<ClearableAutoComplete
|
||||
key={indexName}
|
||||
classNames={classes}
|
||||
placeholder={placeholder ?? 'Search Civitai'}
|
||||
type="search"
|
||||
maxDropdownHeight={300}
|
||||
// TODO: Mantine7
|
||||
// nothingFound={
|
||||
// !hits.length ? (
|
||||
// <Stack gap={0} align="center">
|
||||
// <TimeoutLoader delay={1500} renderTimeout={() => <Text>No results found</Text>} />
|
||||
// </Stack>
|
||||
// ) : undefined
|
||||
// }
|
||||
limit={
|
||||
results && results.nbHits > dropdownItemLimit
|
||||
? dropdownItemLimit + 1 // Allow one more to show more results option
|
||||
: dropdownItemLimit
|
||||
<ClearableAutoComplete
|
||||
key={indexName}
|
||||
classNames={classes}
|
||||
placeholder={placeholder ?? 'Search Civitai'}
|
||||
type="search"
|
||||
maxDropdownHeight={300}
|
||||
// TODO: Mantine7
|
||||
// nothingFound={
|
||||
// !hits.length ? (
|
||||
// <Stack gap={0} align="center">
|
||||
// <TimeoutLoader delay={1500} renderTimeout={() => <Text>No results found</Text>} />
|
||||
// </Stack>
|
||||
// ) : undefined
|
||||
// }
|
||||
limit={
|
||||
results && results.nbHits > dropdownItemLimit
|
||||
? dropdownItemLimit + 1 // Allow one more to show more results option
|
||||
: dropdownItemLimit
|
||||
}
|
||||
defaultValue={query}
|
||||
value={search}
|
||||
data={items}
|
||||
onChange={(value) => {
|
||||
// Ignore onChange events that happen during option submission
|
||||
if (isSubmittingOptionRef.current) {
|
||||
isSubmittingOptionRef.current = false;
|
||||
return;
|
||||
}
|
||||
defaultValue={query}
|
||||
value={search}
|
||||
data={items}
|
||||
onChange={(value) => {
|
||||
// Ignore onChange events that happen during option submission
|
||||
if (isSubmittingOptionRef.current) {
|
||||
isSubmittingOptionRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSearch(value);
|
||||
}}
|
||||
onClear={() => setSearch('')}
|
||||
// onBlur={() => (!isMobile ? onClear?.() : undefined)}
|
||||
onOptionSubmit={(value) => {
|
||||
const item = getItemFromValue(value);
|
||||
if (item) {
|
||||
// Set flag before calling onItemSelected to prevent onChange from overwriting
|
||||
isSubmittingOptionRef.current = true;
|
||||
setSearch(value);
|
||||
}}
|
||||
onClear={() => setSearch('')}
|
||||
// onBlur={() => (!isMobile ? onClear?.() : undefined)}
|
||||
onOptionSubmit={(value) => {
|
||||
const item = getItemFromValue(value);
|
||||
if (item) {
|
||||
// Set flag before calling onItemSelected to prevent onChange from overwriting
|
||||
isSubmittingOptionRef.current = true;
|
||||
|
||||
onItemSelected(
|
||||
{
|
||||
entityId: item.hit.id,
|
||||
entityType: SearchIndexEntityTypes[searchIndexMap[indexName]],
|
||||
},
|
||||
item.hit as any
|
||||
);
|
||||
onItemSelected(
|
||||
{
|
||||
entityId: item.hit.id,
|
||||
entityType: SearchIndexEntityTypes[searchIndexMap[indexName]],
|
||||
},
|
||||
item.hit as any
|
||||
);
|
||||
|
||||
setSearch('');
|
||||
}
|
||||
}}
|
||||
renderOption={renderOption}
|
||||
// prevent default filtering behavior
|
||||
filter={({ options }) => options}
|
||||
clearable={query.length > 0}
|
||||
loading={loading}
|
||||
{...autocompleteProps}
|
||||
/>
|
||||
</Group>
|
||||
setSearch('');
|
||||
}
|
||||
}}
|
||||
renderOption={renderOption}
|
||||
// prevent default filtering behavior
|
||||
filter={({ options }) => options}
|
||||
clearable={query.length > 0}
|
||||
loading={loading}
|
||||
{...autocompleteProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,24 @@ const stripComments = (source: string) =>
|
||||
* fallback reinstates the wrong-label lie the clamp exists to prevent. Neither is visible to a
|
||||
* check on the `.some(…)` call.
|
||||
*
|
||||
* ⚠️ Spelling-pinned, and measured at EXACTLY 100 characters in both files against
|
||||
* `printWidth: 100`. A rename of `indexNameProp`/`enabledTargets`, or one more level of
|
||||
* indentation, makes prettier break the expression across lines and this goes red for a pure
|
||||
* formatting change. Re-pin the new spelling; do not loosen it back to the predicate alone.
|
||||
* Compared with `containsIgnoringWhitespace`, never `toContain`. This is one expression against
|
||||
* `printWidth: 100`, and it has already sat at EXACTLY 100 characters once: a rename, or one more
|
||||
* level of indentation, makes prettier re-wrap it and a literal check then goes red about a clamp
|
||||
* that is still correct. Only the whitespace is forgiven — every identifier, operator and branch
|
||||
* still has to be there, in order. Re-pin a renamed spelling; do not loosen it to the predicate.
|
||||
*/
|
||||
const SELECTOR_VALUE_CLAMP =
|
||||
'value={enabledTargets.some(({ value }) => value === indexNameProp) ? indexNameProp : null}';
|
||||
'value={enabledTargets.some(({ value }) => value === targetIndex) ? targetIndex : null}';
|
||||
|
||||
/**
|
||||
* Containment with every whitespace character removed from BOTH sides. Prettier breaks a long JSX
|
||||
* attribute inside its own braces as well as between attributes, so collapsing runs to a single
|
||||
* space does not survive a wrap — removing whitespace entirely does, and for an expression pinned
|
||||
* character-for-character it gives up nothing: no mutation of this kind is reachable by adding or
|
||||
* removing whitespace alone.
|
||||
*/
|
||||
const containsIgnoringWhitespace = (source: string, needle: string) =>
|
||||
source.replace(/\s+/g, '').includes(needle.replace(/\s+/g, ''));
|
||||
|
||||
/**
|
||||
* Every `<InstantSearch>` root in the app, and what each is required to do about the index it
|
||||
@@ -221,8 +232,12 @@ describe('the dropdown roots carry the typed text across that remount', () => {
|
||||
// WIRED to the input below. Calling it and seeding from `useState(query)` beside it, or
|
||||
// leaving the declaration in place and rendering `value={query}`, each revert the whole
|
||||
// mechanism while a check on the call alone stays green.
|
||||
expect(source).toContain(
|
||||
'const [search, setSearch] = useCarriedSearchText(carriedSearchText, query)'
|
||||
//
|
||||
// The third binding is optional because only one of the two takes it: `AutocompleteSearch`
|
||||
// needs the display-only clear for its blur handler, and `QuickSearchDropdown` has no blur
|
||||
// clear to give it to. Both spellings are correct; pinning one would reject the other.
|
||||
expect(source).toMatch(
|
||||
/const \[search, setSearch(?:, clearDisplayedText)?\] = useCarriedSearchText\(\s*carriedSearchText,\s*query\s*\)/
|
||||
);
|
||||
expect(source).toContain('value={search}');
|
||||
expect(source).toContain('setSearch(value)');
|
||||
@@ -257,30 +272,51 @@ describe('the dropdown roots carry the typed text across that remount', () => {
|
||||
// keeps calling `setTargetIndex` — and the exactly-one-writer count cannot see it, because
|
||||
// `onTargetChange(v as TKey)` is not that pattern.
|
||||
// ⚠️ Every assertion here except `setTargetIndex(value ?? fallbackIndex)` is an INVARIANT
|
||||
// GUARD: all four handler spellings are present unchanged at the PR base. Keying the provider
|
||||
// is what put them at risk — a consolidation of the two `setTargetIndex` writers this change
|
||||
// created would take one of them out — so they are worth pinning, but the red-at-base of this
|
||||
// test is attributable to the fallback spelling alone, not to the claim in its title.
|
||||
// GUARD: an equivalent handler chain is present at the PR base. Keying the provider is what
|
||||
// put it at risk — a consolidation of the two `setTargetIndex` writers this change created
|
||||
// would take one of them out — so it is worth pinning, but the red-at-base of this test is
|
||||
// attributable to the fallback spelling alone, not to the claim in its title.
|
||||
//
|
||||
// The selector now lives in the same component as the handler (it was lifted out of the keyed
|
||||
// subtree so a key change cannot destroy the control mid-click), so the chain is one hop, not
|
||||
// the three it used to be: `onChange` → `handleTargetChange` → `setTargetIndex`.
|
||||
const autocomplete = stripComments(
|
||||
read('src/components/AutocompleteSearch/AutocompleteSearch.tsx')
|
||||
);
|
||||
expect(autocomplete).toContain('onChange={(v: string | null) => onTargetChange(v as TKey)}');
|
||||
expect(autocomplete).toContain('onTargetChange={handleTargetChange}');
|
||||
|
||||
// …and the hop between them, which was pinned at both ends and open in the middle on this
|
||||
// component only. The sibling's middle hop is asserted below as a free-floating substring —
|
||||
// weaker, since it is not tied to the handler, and deliberately left that way: tightening it
|
||||
// symmetrically would reject a correct consolidation into a shared handler factory.
|
||||
expect(autocomplete).toContain(
|
||||
'onChange={(v: string | null) => handleTargetChange(v as SearchIndexKey)}'
|
||||
);
|
||||
expect(autocomplete).toMatch(
|
||||
/const handleTargetChange = \(value: SearchIndexKey\) => \{\s*setTargetIndex\(value\);\s*\};/
|
||||
);
|
||||
|
||||
const quickSearch = stripComments(read('src/components/Search/QuickSearchDropdown.tsx'));
|
||||
expect(quickSearch).toContain('onChange={(value) => onIndexNameChange(value as TIndex)}');
|
||||
expect(quickSearch).toContain('onIndexNameChange={handleTargetChange}');
|
||||
expect(quickSearch).toContain(
|
||||
'onChange={(value) => handleTargetChange(value as SearchIndexKey)}'
|
||||
);
|
||||
expect(quickSearch).toContain('setTargetIndex(value ?? fallbackIndex)');
|
||||
});
|
||||
|
||||
it('both selectors are rendered ABOVE the provider a target switch rebuilds', () => {
|
||||
// `<InstantSearch>` returns `null` until its own start effect has run, so the render a key
|
||||
// change commits has NO subtree at all. A selector inside it is therefore unmounted and
|
||||
// rebuilt by the very click that switched the index, and the focus that click put on it lands
|
||||
// on `<body>`. Above the provider it survives its own change handler.
|
||||
//
|
||||
// File-order, like the carrier check above: a spelling-and-position claim, not a tree one.
|
||||
// That is what this tier can see, and it is the property that broke.
|
||||
for (const relPath of dropdowns) {
|
||||
const source = stripComments(read(relPath));
|
||||
const selector = source.indexOf('<Select');
|
||||
const provider = source.indexOf('<InstantSearch');
|
||||
|
||||
expect(selector, `${relPath}: no <Select> found`).toBeGreaterThan(-1);
|
||||
expect(provider, `${relPath}: <Select> is not written above <InstantSearch>`).toBeGreaterThan(
|
||||
selector
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('both refine gates go through the one predicate, negation included', () => {
|
||||
// The leading `!` is the whole gate. Dropping it inverts both effects — they return early
|
||||
// exactly when they should refine — which kills the feature with every other assertion here
|
||||
@@ -329,7 +365,10 @@ describe('the dropdown roots carry the typed text across that remount', () => {
|
||||
|
||||
// …and the selector reads the target rather than holding its own copy of it, which a remount
|
||||
// would reset while the search really had moved.
|
||||
expect(source).toContain(SELECTOR_VALUE_CLAMP);
|
||||
expect(
|
||||
containsIgnoringWhitespace(source, SELECTOR_VALUE_CLAMP),
|
||||
`AutocompleteSearch: selector value clamp not found — expected ${SELECTOR_VALUE_CLAMP}`
|
||||
).toBe(true);
|
||||
expect(source).toContain('data={enabledTargets}');
|
||||
expect(source).not.toContain('defaultValue={searchTarget}');
|
||||
|
||||
@@ -341,12 +380,45 @@ describe('the dropdown roots carry the typed text across that remount', () => {
|
||||
expect(source).toContain('allowDeselect={false}');
|
||||
});
|
||||
|
||||
it('AutocompleteSearch blurs without emptying the carrier, and clears it on navigation', () => {
|
||||
// MEASURED DEFECT, and the reason the carry did nothing on this component: reaching the
|
||||
// category selector requires blurring the input, and the blur handler was the CLEAR handler —
|
||||
// so `''` was written through the carrier a moment before every selector-driven switch.
|
||||
//
|
||||
// Two halves, and each is wrong without the other. The blur now empties the display only; the
|
||||
// URL-follow effect empties the carrier, so the text survives a pick from the selector and
|
||||
// nothing else. Without the second half, text abandoned at a blur reappears — and is searched
|
||||
// again — the next time navigation moves the target.
|
||||
const source = stripComments(read('src/components/AutocompleteSearch/AutocompleteSearch.tsx'));
|
||||
|
||||
expect(source).toContain('onBlur={handleBlur}');
|
||||
expect(source).toContain('onClear={handleClear}');
|
||||
expect(source).not.toContain('onBlur={handleClear}');
|
||||
|
||||
// `onClear?.()` in BOTH, and that is not a duplication to tidy away: on mobile `AppHeader`
|
||||
// passes `onSearchDone`, so it is what closes the search overlay. A refactor that routes the
|
||||
// blur past it leaves the overlay stuck open.
|
||||
expect(source).toMatch(
|
||||
/const handleClear = \(\) => \{\s*setSearch\(''\);\s*onClear\?\.\(\);\s*\};/
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/const handleBlur = \(\) => \{\s*clearDisplayedText\(\);\s*onClear\?\.\(\);\s*\};/
|
||||
);
|
||||
|
||||
// The bound: the carrier is emptied when the URL moves the target, immediately before the
|
||||
// writer that triggers that remount, so only a selector pick re-seeds.
|
||||
expect(source).toMatch(/carriedSearchText\.current = '';\s*setTargetIndex\(searchTarget\);/);
|
||||
});
|
||||
|
||||
it('QuickSearchDropdown drives its index selector from the target it is searching', () => {
|
||||
const source = stripComments(read('src/components/Search/QuickSearchDropdown.tsx'));
|
||||
|
||||
expect(source).toContain(SELECTOR_VALUE_CLAMP);
|
||||
expect(
|
||||
containsIgnoringWhitespace(source, SELECTOR_VALUE_CLAMP),
|
||||
`QuickSearchDropdown: selector value clamp not found — expected ${SELECTOR_VALUE_CLAMP}`
|
||||
).toBe(true);
|
||||
expect(source).toContain('data={enabledTargets}');
|
||||
expect(source).not.toContain('defaultValue={availableIndexes[0]}');
|
||||
expect(source).not.toContain('defaultValue={enabledTargets[0]}');
|
||||
|
||||
// DELIBERATELY UNCOVERED, said out loud rather than left as a silent omission: the
|
||||
// `startingIndex ?? supportedIndexes[0] ?? 'models'` fallback. Its INITIAL-value arm is
|
||||
@@ -388,6 +460,8 @@ type Harness = {
|
||||
) => Promise<void>;
|
||||
text: () => string;
|
||||
type: (value: string) => Promise<void>;
|
||||
/** What the input's blur handler does: empty the display, leave the carrier alone. */
|
||||
blur: () => Promise<void>;
|
||||
refinedWith: () => string[];
|
||||
};
|
||||
|
||||
@@ -405,6 +479,7 @@ function mount(carried: boolean): Harness {
|
||||
roots.push(root);
|
||||
|
||||
let write: ((value: string) => void) | null = null;
|
||||
let clearDisplay: (() => void) | null = null;
|
||||
const refined: string[] = [];
|
||||
|
||||
function Child({
|
||||
@@ -420,6 +495,10 @@ function mount(carried: boolean): Harness {
|
||||
const viaState = React.useState(helperQuery);
|
||||
const [text, setText] = carried ? viaCarrier : viaState;
|
||||
write = setText;
|
||||
// The blur path, per arm. Carried: the hook's display-only clear. The negative-control arm has
|
||||
// no carrier to spare, so its blur is just an empty write — which is also what the CARRIED arm
|
||||
// did before this was split, and what made the carry inert on `AutocompleteSearch`.
|
||||
clearDisplay = carried ? viaCarrier[2] : () => viaState[1]('');
|
||||
|
||||
// The helper's own query. Per-mount, so a keyed remount hands the child a rebuilt helper
|
||||
// reporting whatever it was constructed with — `''` in production.
|
||||
@@ -456,6 +535,9 @@ function mount(carried: boolean): Harness {
|
||||
type: async (value) => {
|
||||
await act(async () => write?.(value));
|
||||
},
|
||||
blur: async () => {
|
||||
await act(async () => clearDisplay?.());
|
||||
},
|
||||
refinedWith: () => [...refined],
|
||||
};
|
||||
}
|
||||
@@ -486,6 +568,46 @@ describe('useCarriedSearchText', () => {
|
||||
expect(harness.refinedWith()).toEqual(['dreamshaper', 'dreamshaper']);
|
||||
});
|
||||
|
||||
it('survives the blur that reaching the category selector requires', async () => {
|
||||
// THE PATH THE FEATURE EXISTS FOR, and the one it did not cover. Clicking the selector blurs
|
||||
// the input first, so the blur happens BEFORE the index switch, every time. A blur that wrote
|
||||
// `''` through the carrier therefore emptied it a moment before the remount that was supposed
|
||||
// to restore it, and the whole carry was inert on `AutocompleteSearch`.
|
||||
//
|
||||
// The input still empties on blur — that is unchanged, and asserted here — but the carried
|
||||
// copy is what the remount reads.
|
||||
const harness = mount(true);
|
||||
await harness.render('models_v9');
|
||||
await harness.type('dreamshaper');
|
||||
|
||||
await harness.blur();
|
||||
expect(harness.text()).toBe('');
|
||||
|
||||
await harness.render('articles_v6');
|
||||
|
||||
expect(harness.text()).toBe('dreamshaper');
|
||||
// …and it is pushed into the rebuilt helper, so the new index is actually searched for it
|
||||
// rather than the text merely reappearing. The `''` in the middle is the blur reaching the
|
||||
// helper, which is what empties the results behind a blurred input today.
|
||||
expect(harness.refinedWith()).toEqual(['dreamshaper', '', 'dreamshaper']);
|
||||
});
|
||||
|
||||
it('NEGATIVE CONTROL — the same sequence with the blur written THROUGH the carrier loses the text', async () => {
|
||||
// Identical to the test above except for one step: the empty value goes through the ordinary
|
||||
// setter instead of the display-only clear. That is exactly what the blur handler used to do,
|
||||
// and it drops the text on the very switch the carry exists for — so the difference the test
|
||||
// above measures is the split itself, not something the carrier gave you either way. Same
|
||||
// carrier, same remount, same assertions; one setter apart.
|
||||
const harness = mount(true);
|
||||
await harness.render('models_v9');
|
||||
await harness.type('dreamshaper');
|
||||
|
||||
await harness.type('');
|
||||
await harness.render('articles_v6');
|
||||
|
||||
expect(harness.text()).toBe('');
|
||||
});
|
||||
|
||||
it('NEGATIVE CONTROL — the same remount drops the text, and refines nothing, without the carrier', async () => {
|
||||
// Proves the remount in the tests above is real: without it `viaState` would still hold the
|
||||
// text and this would fail. Read it together with the two tests above — this one shows the
|
||||
|
||||
@@ -21,14 +21,28 @@ import { useCallback, useState } from 'react';
|
||||
* fire again. That is what re-RUNS the search on the new index rather than only re-displaying the
|
||||
* text.
|
||||
*
|
||||
* @returns the current text, and a setter that writes the carrier as well as the state. Every write
|
||||
* has to go through that setter — a bare `setState` leaves the carrier holding stale text, which
|
||||
* the next remount would then restore over the newer value.
|
||||
* @returns three things: the current text; a setter that writes the carrier as well as the state;
|
||||
* and a display-only clear that empties the visible text and LEAVES the carrier alone.
|
||||
*
|
||||
* Every write of a value goes through the setter — a bare `setState` would leave the carrier
|
||||
* holding stale text, which the next remount would restore over the newer value.
|
||||
*
|
||||
* The display-only clear is the deliberate exception, and it exists for one caller: the blur
|
||||
* handler on `AutocompleteSearch`'s input. Clicking the category selector blurs that input, so a
|
||||
* blur that wrote `''` through the setter would empty the carrier a moment BEFORE the switch it
|
||||
* is meant to survive — which is what made the carry inert on that component. A blur is the
|
||||
* browser moving focus, not the user asking to discard what they typed; an explicit clear (the
|
||||
* input's clear button) still goes through the setter and does discard it.
|
||||
*
|
||||
* The cost is a window where the input reads empty while the carrier still holds text, so the
|
||||
* NEXT remount re-seeds text the user last saw cleared. The owner of `carriedRef` is what bounds
|
||||
* that window: `AutocompleteSearch` empties the ref itself when a navigation — rather than a pick
|
||||
* from the selector — changes the target, so only a selector-driven remount re-seeds.
|
||||
*/
|
||||
export function useCarriedSearchText(
|
||||
carriedRef: MutableRefObject<string>,
|
||||
refinedQuery: string
|
||||
): [string, (value: string) => void] {
|
||||
): [string, (value: string) => void, () => void] {
|
||||
const [text, setText] = useState(() => seedCarriedSearchText(carriedRef.current, refinedQuery));
|
||||
|
||||
const write = useCallback(
|
||||
@@ -39,7 +53,9 @@ export function useCarriedSearchText(
|
||||
[carriedRef]
|
||||
);
|
||||
|
||||
return [text, write];
|
||||
const clearDisplayedText = useCallback(() => setText(''), []);
|
||||
|
||||
return [text, write, clearDisplayedText];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user