Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/ui/commandPalettes/SpotlightSearchDialogView.tsx
T

395 lines
16 KiB
TypeScript

import { useTranslation } from "react-i18next";
import {
FeatherBuilding2,
FeatherCheckSquare,
FeatherChevronDown,
FeatherClock,
FeatherFile,
FeatherFolder,
FeatherLoader,
FeatherSearch,
FeatherShield,
FeatherTrendingUp,
FeatherUser,
FeatherUsers,
} from "@subframe/core";
import {
SPOTLIGHT_SEARCH_ENTITY_ORDER,
type SpotlightSearchDialogVM,
type SpotlightSearchEntityType,
type SpotlightSearchItemVM,
type SpotlightSearchRecentVM,
} from "@hivegrid/view-models";
import {
UI_ACTION_IDS,
UI_ID_PATTERNS,
UI_SURFACE_IDS,
getCommandPaletteActionId,
getCommandPaletteRecentId,
getCommandPaletteResultId,
} from "~/application/ui/uiRegistry";
import { CommandDialog, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import {
ArrowLeftRightIcon,
ChevronRightIcon,
CodeIcon,
CreditCard,
FileTextIcon,
HomeIcon,
LogOut,
PlusIcon,
ShieldCheckIcon,
StoreIcon,
UserIcon,
} from "~/components/ui/icons/vendor";
import { useLocaleFormatters } from "~/locale/format";
import { BadgeShortcut } from "~/ui/components/BadgeShortcut";
import { Button } from "~/ui/components/Button";
import { IconWithBackground } from "~/ui/components/IconWithBackground";
export type SpotlightSearchDialogActions = {
readonly onOpenChange: (open: boolean) => void;
readonly onQueryChange: (query: string) => void;
readonly onToggleEntityType: (entityType: SpotlightSearchEntityType) => void;
readonly onSelectRecent: (recent: SpotlightSearchRecentVM) => void;
readonly onPrepareResultSelection: () => void;
readonly onSelectResult: (item: SpotlightSearchItemVM) => void;
readonly onLoadMore: () => void;
readonly onRetry: () => void;
readonly onSelectAction: (actionId: string) => void;
readonly onCommandKeyDownCapture: React.KeyboardEventHandler<HTMLDivElement>;
readonly onCloseAutoFocus: (event: Event) => void;
};
/** Presentational AD-28 interim UI. Data and side effects enter only through vm/actions. */
export function SpotlightSearchDialogView({ vm, actions }: { vm: SpotlightSearchDialogVM; actions: SpotlightSearchDialogActions }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<CommandDialog
open={vm.open}
onOpenChange={actions.onOpenChange}
dataUiId={UI_SURFACE_IDS.commandPalettePortal}
shouldFilter={false}
onCommandKeyDownCapture={actions.onCommandKeyDownCapture}
onCloseAutoFocus={actions.onCloseAutoFocus}
>
<CommandInput
data-ui-id={UI_SURFACE_IDS.commandPaletteSearch}
value={vm.query}
onValueChange={actions.onQueryChange}
placeholder={t("search.command.placeholder")}
aria-label={t("search.form.label")}
/>
{vm.canUseGlobalSearch ? (
<div
className="border-border-default flex flex-nowrap gap-1 overflow-x-auto border-b px-3 py-2 [scrollbar-width:none] sm:px-4 [&::-webkit-scrollbar]:hidden"
data-ui-id={UI_SURFACE_IDS.commandPaletteFilters}
aria-label={t("search.command.filters")}
>
{SPOTLIGHT_SEARCH_ENTITY_ORDER.map((entityType) => {
const active = vm.selectedEntityTypes.includes(entityType);
return (
<button
key={entityType}
type="button"
aria-pressed={active}
className={
active
? "bg-surface-feedback-solid-neutral text-body-extra-small-medium-0 font-body-extra-small-medium-0 text-text-neutral-on-solid shrink-0 rounded-md px-2.5 py-1 transition-colors"
: "border-border-default bg-surface-neutral-primary text-body-extra-small-medium-0 font-body-extra-small-medium-0 text-text-neutral-secondary hover:bg-surface-neutral-secondary shrink-0 rounded-md border px-2.5 py-1 transition-colors"
}
onMouseDown={(event) => event.preventDefault()}
onClick={() => actions.onToggleEntityType(entityType)}
>
{t(`search.entities.${entityType}`)}
</button>
);
})}
</div>
) : null}
<CommandList data-ui-id={UI_SURFACE_IDS.commandPaletteResults} className="max-h-none flex-1 pb-2 sm:max-h-[min(52vh,30rem)] sm:flex-none">
{vm.canUseGlobalSearch && !vm.isSearchQuery ? (
<SearchStatus icon={<FeatherSearch />} dataUiId={UI_SURFACE_IDS.commandPaletteQueryHint}>
{t("search.command.minimumCharacters")}
</SearchStatus>
) : null}
{vm.canUseGlobalSearch && vm.isSearchQuery && vm.searchState === "loading" ? (
<SearchStatus icon={<FeatherLoader className="animate-spin" />} dataUiId={UI_SURFACE_IDS.commandPaletteSearchStatus} live>
{t("search.command.loading")}
</SearchStatus>
) : null}
{vm.canUseGlobalSearch && vm.isSearchQuery && vm.searchState === "error" ? (
<SearchStatus icon={<FeatherSearch />} dataUiId={UI_SURFACE_IDS.commandPaletteSearchStatus} live>
<span className="flex flex-col items-center gap-2">
<span>{t("search.command.error")}</span>
<Button variant="tertiary" size="small" onMouseDown={(event) => event.preventDefault()} onClick={actions.onRetry}>
{t("shared.retry")}
</Button>
</span>
</SearchStatus>
) : null}
{vm.canUseGlobalSearch && vm.isSearchQuery && vm.searchState === "empty" ? (
<SearchStatus icon={<FeatherSearch />} dataUiId={UI_SURFACE_IDS.commandPaletteSearchStatus} live>
{t("search.results.empty")}
</SearchStatus>
) : null}
{vm.canUseGlobalSearch && !vm.isSearchQuery && vm.searchState !== "loading" && vm.recentSearches.length > 0 ? (
<CommandGroup heading={t("search.command.recent")}>
{vm.recentSearches.map((recent) => (
<CommandItem
key={recent.id}
data-ui-id={getCommandPaletteRecentId(recent.id)}
data-ui-id-pattern={UI_ID_PATTERNS.commandPaletteRecent}
data-ui-id-param-recentid={recent.id}
value={recent.queryText}
onSelect={() => actions.onSelectRecent(recent)}
>
<IconWithBackground variant="secondary" size="small" icon={<FeatherClock />} className="size-8 shrink-0" />
<div className="min-w-0 grow">
<div className="text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-primary truncate">{recent.queryText}</div>
<div className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary">
{t("search.results.title", { count: recent.resultCount })}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{vm.canUseGlobalSearch && !vm.isSearchQuery && vm.searchState !== "loading" && vm.hasResolvedResponse && vm.recentSearches.length === 0 ? (
<SearchStatus icon={<FeatherClock />} dataUiId={UI_SURFACE_IDS.commandPaletteRecentStatus}>
{t("search.recent.empty")}
</SearchStatus>
) : null}
{vm.canUseGlobalSearch && vm.isSearchQuery && vm.searchState !== "error"
? vm.searchGroups.map((group) => (
<CommandGroup key={group.entityType} heading={t(group.labelKey)}>
{group.items.map((item) => (
<CommandItem
key={item.key}
data-ui-id={getCommandPaletteResultId(item.entityType, item.id)}
data-ui-id-pattern={UI_ID_PATTERNS.commandPaletteResult}
data-ui-id-param-entitytype={item.entityType}
data-ui-id-param-entityid={item.id}
value={item.searchValue}
onPointerDownCapture={actions.onPrepareResultSelection}
onSelect={() => actions.onSelectResult(item)}
>
<IconWithBackground variant="secondary" size="small" icon={entityIcon(item.entityType)} className="size-9 shrink-0" />
<div className="min-w-0 grow">
<div className="text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-primary truncate">
{highlightMatch(item.title, vm.query.trim())}
</div>
{item.subtitle ? (
<div className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary truncate">
{highlightMatch(item.subtitle, vm.query.trim())}
</div>
) : null}
</div>
{item.updatedAt ? (
<span className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary hidden shrink-0 sm:inline">
{formatters.relativeDate(item.updatedAt)}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
))
: null}
{vm.canLoadMore ? (
<div className="flex justify-center px-4 py-2">
<Button
variant="tertiary"
size="small"
iconRight={<FeatherChevronDown />}
data-ui-id={UI_ACTION_IDS.commandPaletteLoadMore}
onMouseDown={(event) => event.preventDefault()}
onClick={actions.onLoadMore}
>
{t("search.command.loadMore")}
</Button>
</div>
) : null}
{vm.canUseGlobalSearch && vm.isSearchQuery && vm.searchState !== "loading" && vm.hiddenEntityTypes.length > 0 ? (
<SearchStatus icon={<FeatherShield />} dataUiId={UI_SURFACE_IDS.commandPalettePermissionHint} subtle>
{t("search.results.hidden", {
entities: vm.hiddenEntityTypes.map((type) => t(`search.entities.${type}`)).join(", "),
})}
</SearchStatus>
) : null}
{vm.visibleActions.length > 0 ? (
<CommandGroup heading={t(vm.isSearchQuery ? "search.command.actions" : "search.command.quickAccess")}>
{vm.visibleActions.map((action) => (
<CommandItem key={action.id} {...commandActionUiIdAttrs(action.id)} value={action.searchValue} onSelect={() => actions.onSelectAction(action.id)}>
<div className="flex w-full min-w-0 items-center gap-2">
<IconWithBackground
data-slot="command-action-icon"
variant="neutral"
size="small"
icon={getActionIcon(action.id)}
className="size-7 shrink-0 p-1.5"
/>
<div className="text-text-neutral-secondary text-body-small-medium-150 font-body-small-medium-150 min-w-0 grow truncate">{action.name}</div>
{action.subtitle ? (
<div className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary hidden max-w-40 truncate sm:block">
{action.subtitle}
</div>
) : null}
<ChevronRightIcon className="text-icon-neutral-tertiary size-4 shrink-0 opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100" />
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{!vm.canUseGlobalSearch && vm.visibleActions.length === 0 ? (
<SearchStatus icon={<FeatherSearch />} dataUiId={UI_SURFACE_IDS.commandPaletteSearchStatus}>
{t("app.commands.empty.title")}
</SearchStatus>
) : null}
</CommandList>
<div
className="border-border-default bg-surface-neutral-secondary text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary flex items-center justify-between gap-2 border-t px-3 py-2 sm:px-4"
data-ui-id={UI_SURFACE_IDS.commandPaletteFooter}
>
<span className="flex items-center gap-1.5">
<BadgeShortcut icon={null} size="small">
↑↓
</BadgeShortcut>
<span className="hidden sm:inline">{t("search.command.navigate")}</span>
</span>
<span className="flex items-center gap-1.5">
<BadgeShortcut icon={null} size="small">
↵
</BadgeShortcut>
<span>{t("search.command.open")}</span>
</span>
<span className="flex items-center gap-1.5">
<BadgeShortcut icon={null} size="small">
⌘↵
</BadgeShortcut>
<span className="hidden sm:inline">{t("search.command.openNewTab")}</span>
</span>
</div>
</CommandDialog>
);
}
function commandActionUiIdAttrs(actionId: string) {
return {
"data-ui-id": getCommandPaletteActionId(actionId),
"data-ui-id-pattern": UI_ID_PATTERNS.commandPaletteAction,
"data-ui-id-param-actionid": actionId,
};
}
function getActionIcon(actionId: string) {
switch (actionId) {
case "dashboard":
case "registry-dashboard":
return <HomeIcon />;
case "profile":
case "members":
return <UserIcon />;
case "subscription":
return <CreditCard />;
case "account":
return <StoreIcon />;
case "apiKeys":
return <CodeIcon />;
case "auditTrails":
case "events":
return <FileTextIcon />;
case "switch account":
return <ArrowLeftRightIcon />;
case "create account":
return <PlusIcon />;
case "switch to admin":
return <ShieldCheckIcon />;
case "logout":
return <LogOut />;
default:
return <ChevronRightIcon />;
}
}
function SearchStatus({
children,
icon,
dataUiId,
live = false,
subtle = false,
}: {
children: React.ReactNode;
icon: React.ReactNode;
dataUiId: string;
live?: boolean;
subtle?: boolean;
}) {
return (
<CommandItem disabled value={`status:${dataUiId}`} className="cursor-default p-0 data-[disabled=true]:opacity-100" data-ui-id={dataUiId}>
<div
className={
subtle
? "bg-surface-neutral-secondary text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary mx-3 my-2 flex w-full items-center gap-2 rounded-md px-3 py-2 sm:mx-4"
: "text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary flex w-full items-center justify-center gap-2 px-4 py-5 text-center"
}
role={live ? "status" : undefined}
aria-live={live ? "polite" : undefined}
>
<span className="text-icon-neutral-tertiary shrink-0">{icon}</span>
<span>{children}</span>
</div>
</CommandItem>
);
}
function entityIcon(entityType: SpotlightSearchEntityType) {
switch (entityType) {
case "contact":
return <FeatherUser />;
case "company":
return <FeatherBuilding2 />;
case "lead":
return <FeatherTrendingUp />;
case "project":
return <FeatherFolder />;
case "file":
return <FeatherFile />;
case "task":
return <FeatherCheckSquare />;
case "employee":
return <FeatherUsers />;
case "user":
return <FeatherUsers />;
}
}
function highlightMatch(value: string, query: string) {
if (!query) return value;
const index = value.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
if (index < 0) return value;
const before = value.slice(0, index);
const match = value.slice(index, index + query.length);
const after = value.slice(index + query.length);
return (
<>
{before}
<mark className="bg-surface-feedback-subtle-warning rounded-sm px-0.5 text-inherit">{match}</mark>
{after}
</>
);
}