feat(navigator): empty-state + skeleton views, fix double search fetch (P4 wave 1a)

Visual polish, first wave:
- NavigatorEmptyStateView: replaces the bare "No rooms found" text with a
  centered icon + message + a Create-room CTA. Reuses existing i18n keys
  (navigator.search.returned.no.results / .roomsettings.moderation.none /
  .createroom.create) so no new localization entries are needed.
- NavigatorSearchSkeletonView: animate-pulse placeholder rows shown while a
  search is in flight and no result is cached yet (matches the HK dashboard
  skeleton pattern). Replaces the NitroCard.Content spinner overlay for the
  result list.

Bug fix bundled in: NavigatorSearchView called useNavigatorSearch() a second
time purely to read searchResult for its input-sync effect. Since the hook is
not a useBetween singleton, that registered a duplicate NavigatorSearchEvent
listener AND fired a duplicate NavigatorSearchComposer on every search.
NavigatorView now owns the single useNavigatorSearch() call and passes
searchResult to NavigatorSearchView via prop.

Test maintenance: useNavigatorSearch.test.tsx was written for the original
useNitroQuery implementation, which upstream reverted (05d71dd1) to
useMessageEvent + useState. Removed the dead QueryClient scaffolding, fixed
case 1 (assert no fetch starts with empty tab), dropped case 7 (the query
invalidator no longer exists). 6 cases, all green.

Full suite 471/471. Typecheck: only the environmental renderer-mismatch
errors (soundboard / rare-values / floorplan APIs absent from the linked
renderer), none in navigator files.
This commit is contained in:
simoleo89
2026-05-28 18:02:48 +02:00
parent 772b6dd632
commit 3bce0c0191
5 changed files with 93 additions and 116 deletions
+7 -5
View File
@@ -14,8 +14,10 @@ import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView';
import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView';
import { NavigatorEmptyStateView } from './views/search/NavigatorEmptyStateView';
import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView';
import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView';
import { NavigatorSearchSkeletonView } from './views/search/NavigatorSearchSkeletonView';
import { NavigatorSearchView } from './views/search/NavigatorSearchView';
export const NavigatorView: FC<{}> = props =>
@@ -132,7 +134,7 @@ export const NavigatorView: FC<{}> = props =>
<FaPlus className="fa-icon" />
</NitroCard.TabItem>
</NitroCard.Tabs>
<NitroCard.Content isLoading={ isFetching }>
<NitroCard.Content>
{ !isCreatorOpen &&
<div className="flex h-full overflow-hidden gap-2">
{ isOpenSavesSearches &&
@@ -140,13 +142,13 @@ export const NavigatorView: FC<{}> = props =>
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
</div> }
<div className="flex flex-col w-full overflow-hidden gap-2">
<NavigatorSearchView />
<NavigatorSearchView searchResult={ searchResult } />
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
{ (isFetching && !searchResult) &&
<NavigatorSearchSkeletonView /> }
{ searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
</div> }
<NavigatorEmptyStateView code={ searchResult.code } onCreateRoom={ () => useNavigatorUiStore.getState().openCreator() } /> }
</div>
<Flex className="nitro-card-divider pt-2 border-t gap-2">
<Flex pointer alignItems="center" justifyContent="center"
@@ -0,0 +1,33 @@
import { FC } from 'react';
import { FaPlus, FaSearch } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { Button } from '../../../../common';
interface NavigatorEmptyStateViewProps
{
code: string;
onCreateRoom: () => void;
}
export const NavigatorEmptyStateView: FC<NavigatorEmptyStateViewProps> = props =>
{
const { code, onCreateRoom } = props;
const isMyWorld = (code === 'myworld_view');
const messageKey = isMyWorld ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results';
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-black/5 text-muted">
<FaSearch size={ 26 } className="opacity-40" />
</div>
<div className="text-sm text-muted max-w-[240px]">
{ LocalizeText(messageKey) }
</div>
<Button variant="primary" onClick={ onCreateRoom }>
<FaPlus className="fa-icon me-1" />
{ LocalizeText('navigator.createroom.create') }
</Button>
</div>
);
};
@@ -0,0 +1,25 @@
import { FC } from 'react';
interface NavigatorSearchSkeletonViewProps
{
rows?: number;
}
export const NavigatorSearchSkeletonView: FC<NavigatorSearchSkeletonViewProps> = props =>
{
const { rows = 5 } = props;
return (
<div className="flex flex-col gap-2" aria-hidden="true">
{ Array.from({ length: rows }).map((_, index) =>
<div key={ index } className="nitro-card-panel flex items-center gap-2 px-2 py-2">
<div className="h-10 w-10 shrink-0 rounded bg-black/10 animate-pulse" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-3 w-1/2 rounded bg-black/10 animate-pulse" />
<div className="h-2.5 w-1/3 rounded bg-black/10 animate-pulse" />
</div>
<div className="h-4 w-8 shrink-0 rounded bg-black/10 animate-pulse" />
</div>) }
</div>
);
};
@@ -1,15 +1,21 @@
import { NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
import { Button } from '../../../../common';
import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks';
import { useNavigatorData, useNavigatorUiStore } from '../../../../hooks';
export const NavigatorSearchView: FC<{}> = props =>
interface NavigatorSearchViewProps
{
searchResult: NavigatorSearchResultSet | null;
}
export const NavigatorSearchView: FC<NavigatorSearchViewProps> = props =>
{
const { searchResult } = props;
const [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
const [ inputText, setInputText ] = useState('');
const { topLevelContext } = useNavigatorData();
const { searchResult } = useNavigatorSearch();
// Sync the input text display when a server result arrives (e.g. on tab switch
// or deep-link navigation that sets the filter through the store directly).