Add search spotlight with registration hook (#82)

* wip: add spotlight

* feat: add spotlight with registration hook and group chips

* chore: address pull request feedback

* docs: add documentation for usage of spotlight actions

* fix: deepsource issue JS-0415

* feat: add support for dependencies of spotlight actions

* fix: lockfile broken

* feat: add hover effect for spotlight action

* docs: Add documentation about dependency array

* refactor: remove test spotlight actions, disallow all as group for actions

* fix: type issues

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-02-17 14:11:23 +01:00
committed by GitHub
parent 3577bd6ac3
commit d5025da789
25 changed files with 2833 additions and 5243 deletions

View File

@@ -0,0 +1,54 @@
import { useScopedI18n } from "@homarr/translation/client";
import { Chip } from "@homarr/ui";
import {
selectNextAction,
selectPreviousAction,
spotlightStore,
triggerSelectedAction,
} from "./spotlight-store";
import type { SpotlightActionGroup } from "./type";
const disableArrowUpAndDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
selectNextAction(spotlightStore);
e.preventDefault();
} else if (e.key === "ArrowUp") {
selectPreviousAction(spotlightStore);
e.preventDefault();
} else if (e.key === "Enter") {
triggerSelectedAction(spotlightStore);
}
};
const focusActiveByDefault = (e: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = e.relatedTarget;
const isPreviousTargetRadio =
relatedTarget && "type" in relatedTarget && relatedTarget.type === "radio";
if (isPreviousTargetRadio) return;
const group = e.currentTarget.parentElement?.parentElement;
if (!group) return;
const label = group.querySelector<HTMLLabelElement>("label[data-checked]");
if (!label) return;
label.focus();
};
interface Props {
group: SpotlightActionGroup;
}
export const GroupChip = ({ group }: Props) => {
const t = useScopedI18n("common.search.group");
return (
<Chip
key={group}
value={group}
onFocus={focusActiveByDefault}
onKeyDown={disableArrowUpAndDown}
>
{t(group)}
</Chip>
);
};

View File

@@ -0,0 +1,7 @@
.spotlightAction:hover {
background-color: alpha(var(--mantine-primary-color-filled), 0.1);
}
.spotlightAction[data-selected="true"] {
background-color: alpha(var(--mantine-primary-color-filled), 0.3);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useCallback, useState } from "react";
import Link from "next/link";
import {
Spotlight as MantineSpotlight,
SpotlightAction,
} from "@mantine/spotlight";
import { useAtomValue } from "jotai";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import {
Center,
Chip,
Divider,
Flex,
Group,
IconSearch,
Text,
} from "@homarr/ui";
import { GroupChip } from "./chip-group";
import classes from "./component.module.css";
import { actionsAtomRead, groupsAtomRead } from "./data-store";
import { setSelectedAction, spotlightStore } from "./spotlight-store";
import type { SpotlightActionData } from "./type";
export const Spotlight = () => {
const [query, setQuery] = useState("");
const [group, setGroup] = useState("all");
const groups = useAtomValue(groupsAtomRead);
const actions = useAtomValue(actionsAtomRead);
const t = useI18n();
const preparedActions = actions.map((action) => prepareAction(action, t));
const items = preparedActions
.filter(
(item) =>
(item.ignoreSearchAndOnlyShowInGroup
? item.group === group
: item.title.toLowerCase().includes(query.toLowerCase().trim())) &&
(group === "all" || item.group === group),
)
.map((item) => {
const renderRoot =
item.type === "link"
? (props: Record<string, unknown>) => (
<Link href={prepareHref(item.href, query)} {...props} />
)
: undefined;
return (
<SpotlightAction
key={item.id}
renderRoot={renderRoot}
onClick={item.type === "button" ? item.onClick : undefined}
className={classes.spotlightAction}
>
<Group wrap="nowrap" w="100%">
{item.icon && (
<Center w={50} h={50}>
{typeof item.icon !== "string" && <item.icon size={24} />}
{typeof item.icon === "string" && (
<img
src={item.icon}
alt={item.title}
width={24}
height={24}
/>
)}
</Center>
)}
<Flex direction="column">
<Text>{item.title}</Text>
{item.description && (
<Text opacity={0.6} size="xs">
{item.description}
</Text>
)}
</Flex>
</Group>
</SpotlightAction>
);
});
const onGroupChange = useCallback(
(group: string) => {
setSelectedAction(-1, spotlightStore);
setGroup(group);
},
[setGroup, setSelectedAction],
);
return (
<MantineSpotlight.Root
query={query}
onQueryChange={setQuery}
store={spotlightStore}
>
<MantineSpotlight.Search
placeholder={t("common.search.placeholder")}
leftSection={<IconSearch stroke={1.5} />}
/>
<Divider />
<Group wrap="nowrap" p="sm">
<Chip.Group multiple={false} value={group} onChange={onGroupChange}>
<Group justify="start">
{groups.map((group) => (
<GroupChip key={group} group={group} />
))}
</Group>
</Chip.Group>
</Group>
<MantineSpotlight.ActionsList>
{items.length > 0 ? (
items
) : (
<MantineSpotlight.Empty>
{t("common.search.nothingFound")}
</MantineSpotlight.Empty>
)}
</MantineSpotlight.ActionsList>
</MantineSpotlight.Root>
);
};
const prepareHref = (href: string, query: string) => {
return href.replace("%s", query);
};
const translateIfNecessary = (
value: string | ((t: TranslationFunction) => string),
t: TranslationFunction,
) => {
if (typeof value === "function") {
return value(t);
}
return value;
};
const prepareAction = (
action: SpotlightActionData,
t: TranslationFunction,
) => ({
...action,
title: translateIfNecessary(action.title, t),
description: translateIfNecessary(action.description, t),
});

View File

@@ -0,0 +1,72 @@
import { useEffect } from "react";
import { atom, useSetAtom } from "jotai";
import useDeepCompareEffect from "use-deep-compare-effect";
import type { SpotlightActionData, SpotlightActionGroup } from "./type";
const defaultGroups = ["all", "web", "action"] as const;
const reversedDefaultGroups = [...defaultGroups].reverse();
const actionsAtom = atom<Record<string, readonly SpotlightActionData[]>>({});
export const actionsAtomRead = atom((get) =>
Object.values(get(actionsAtom)).flatMap((item) => item),
);
export const groupsAtomRead = atom((get) =>
Array.from(
new Set(
get(actionsAtomRead)
.map((item) => item.group as SpotlightActionGroup) // Allow "all" group to be included in the list of groups
.concat(...defaultGroups),
),
)
.sort((groupA, groupB) => {
const groupAIndex = reversedDefaultGroups.indexOf(groupA);
const groupBIndex = reversedDefaultGroups.indexOf(groupB);
// if both groups are not in the default groups, sort them by name (here reversed because we reverse the array afterwards)
if (groupAIndex === -1 && groupBIndex === -1) {
return groupB.localeCompare(groupA);
}
return groupAIndex - groupBIndex;
})
.reverse(),
);
const registrations = new Map<string, number>();
export const useRegisterSpotlightActions = (
key: string,
actions: SpotlightActionData[],
dependencies: readonly unknown[] = [],
) => {
const setActions = useSetAtom(actionsAtom);
// Use deep compare effect if there are dependencies for the actions, this supports deep compare of the action dependencies
const useSpecificEffect =
dependencies.length >= 1 ? useDeepCompareEffect : useEffect;
useSpecificEffect(() => {
if (!registrations.has(key) || dependencies.length >= 1) {
setActions((prev) => ({
...prev,
[key]: actions,
}));
}
registrations.set(key, (registrations.get(key) ?? 0) + 1);
return () => {
if (registrations.get(key) === 1) {
setActions((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
}
registrations.set(key, (registrations.get(key) ?? 0) - 1);
if (registrations.get(key) === 0) {
registrations.delete(key);
}
};
}, [key, dependencies.length >= 1 ? dependencies : undefined]);
};

View File

@@ -1 +1,8 @@
export const name = "spotlight";
"use client";
import { spotlightActions } from "./spotlight-store";
export { Spotlight } from "./component";
const openSpotlight = spotlightActions.open;
export { openSpotlight };

View File

@@ -0,0 +1,45 @@
"use client";
import { clamp } from "@mantine/hooks";
import type { SpotlightStore } from "@mantine/spotlight";
import { createSpotlight } from "@mantine/spotlight";
export const [spotlightStore, spotlightActions] = createSpotlight();
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
};
export const selectAction = (index: number, store: SpotlightStore): number => {
const state = store.getState();
const actionsList = document.getElementById(state.listId);
const selected =
actionsList?.querySelector<HTMLButtonElement>("[data-selected]");
const actions =
actionsList?.querySelectorAll<HTMLButtonElement>("[data-action]") ?? [];
const nextIndex =
index === -1 ? actions.length - 1 : index === actions.length ? 0 : index;
const selectedIndex = clamp(nextIndex, 0, actions.length - 1);
selected?.removeAttribute("data-selected");
actions[selectedIndex]?.scrollIntoView({ block: "nearest" });
actions[selectedIndex]?.setAttribute("data-selected", "true");
setSelectedAction(selectedIndex, store);
return selectedIndex;
};
export const selectNextAction = (store: SpotlightStore) => {
return selectAction(store.getState().selected + 1, store);
};
export const selectPreviousAction = (store: SpotlightStore) => {
return selectAction(store.getState().selected - 1, store);
};
export const triggerSelectedAction = (store: SpotlightStore) => {
const state = store.getState();
const selected = document.querySelector<HTMLButtonElement>(
`#${state.listId} [data-selected]`,
);
selected?.click();
};

View File

@@ -0,0 +1,31 @@
import type {
TranslationFunction,
TranslationObject,
} from "@homarr/translation";
import type { TablerIconsProps } from "@homarr/ui";
export type SpotlightActionGroup =
keyof TranslationObject["common"]["search"]["group"];
interface BaseSpotlightAction {
id: string;
title: string | ((t: TranslationFunction) => string);
description: string | ((t: TranslationFunction) => string);
group: Exclude<SpotlightActionGroup, "all">; // actions can not be assigned to the "all" group
icon: ((props: TablerIconsProps) => JSX.Element) | string;
ignoreSearchAndOnlyShowInGroup?: boolean;
}
interface SpotlightActionLink extends BaseSpotlightAction {
type: "link";
href: string;
}
type MaybePromise<T> = T | Promise<T>;
interface SpotlightActionButton extends BaseSpotlightAction {
type: "button";
onClick: () => MaybePromise<void>;
}
export type SpotlightActionData = SpotlightActionLink | SpotlightActionButton;