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

@@ -19,6 +19,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.0.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
@@ -40,15 +41,15 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dayjs": "^1.11.10",
"@homarr/gridstack": "^1.0.0",
"jotai": "^2.6.4",
"mantine-modal-manager": "^7.5.2",
"next": "^14.1.1-canary.58",
"postcss-preset-mantine": "^1.13.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"sass": "^1.71.0",
"superjson": "2.2.1"
"superjson": "2.2.1",
"use-deep-compare-effect": "^1.8.1",
"sass": "^1.71.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,8 @@
"use client";
import type { PropsWithChildren } from "react";
import { Provider } from "jotai";
export const JotaiProvider = ({ children }: PropsWithChildren) => {
return <Provider>{children}</Provider>;
};

View File

@@ -0,0 +1,18 @@
import React from "react";
type PropsWithChildren = Required<React.PropsWithChildren>;
export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => {
return wrappers
.reverse()
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
// eslint-disable-next-line react/display-name
return (props) => (
<Current>
<Acc {...props} />
</Current>
);
});
};

View File

@@ -30,7 +30,6 @@ export const InitUserForm = () => {
});
const handleSubmit = async (values: FormType) => {
console.log(values);
await mutateAsync(values, {
onSuccess: () => {
showSuccessNotification({

View File

@@ -12,9 +12,11 @@ import {
uiConfiguration,
} from "@homarr/ui";
import { JotaiProvider } from "./_client-providers/jotai";
import { ModalsProvider } from "./_client-providers/modals";
import { NextInternationalProvider } from "./_client-providers/next-international";
import { TRPCReactProvider } from "./_client-providers/trpc";
import { composeWrappers } from "./compose";
const fontSans = Inter({
subsets: ["latin"],
@@ -51,25 +53,32 @@ export default function Layout(props: {
}) {
const colorScheme = "dark";
const StackedProvider = composeWrappers([
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => (
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
),
(innerProps) => (
<MantineProvider
{...innerProps}
defaultColorScheme={colorScheme}
{...uiConfiguration}
/>
),
(innerProps) => <ModalsProvider {...innerProps} />,
]);
return (
<html lang="en" suppressHydrationWarning>
<head>
<ColorSchemeScript defaultColorScheme={colorScheme} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<TRPCReactProvider>
<NextInternationalProvider locale={props.params.locale}>
<MantineProvider
defaultColorScheme={colorScheme}
{...uiConfiguration}
>
<ModalsProvider>
<Notifications />
{props.children}
</ModalsProvider>
</MantineProvider>
</NextInternationalProvider>
</TRPCReactProvider>
<StackedProvider>
<Notifications />
{props.children}
</StackedProvider>
</body>
</html>
);

View File

@@ -34,26 +34,28 @@ interface Props {
export const SectionContent = ({ items, refs }: Props) => {
return (
<>
{items.map((item) => (
<div
key={item.id}
className="grid-stack-item"
data-id={item.id}
gs-x={item.xOffset}
gs-y={item.yOffset}
gs-w={item.width}
gs-h={item.height}
gs-min-w={1}
gs-min-h={1}
gs-max-w={4}
gs-max-h={4}
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
>
<Card className="grid-stack-item-content" withBorder>
<BoardItem item={item} />
</Card>
</div>
))}
{items.map((item) => {
return (
<div
key={item.id}
className="grid-stack-item"
data-id={item.id}
gs-x={item.xOffset}
gs-y={item.yOffset}
gs-w={item.width}
gs-h={item.height}
gs-min-w={1}
gs-min-h={1}
gs-max-w={4}
gs-max-h={4}
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
>
<Card className="grid-stack-item-content" withBorder>
<BoardItem item={item} />
</Card>
</div>
);
})}
</>
);
};

View File

@@ -107,7 +107,7 @@ export const useGridstack = ({
// Add listener for moving items in config from one wrapper to another
currentGrid?.on("added", (_, nodes) => {
nodes.forEach((node) => onAdd(node));
nodes.forEach(onAdd);
});
return () => {
@@ -192,8 +192,6 @@ const useCssVariableConfiguration = ({
const widgetWidth = mainRef.current.clientWidth / sectionColumnCount;
// widget width is used to define sizes of gridstack items within global.scss
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
console.log("widgetWidth", widgetWidth);
console.log(gridRef.current);
gridRef.current?.cellHeight(widgetWidth);
// gridRef.current is required otherwise the cellheight is run on production as undefined
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,11 +1,11 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Spotlight } from "@homarr/spotlight";
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { ClientSpotlight } from "./header/spotlight";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
@@ -38,7 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
<UserButton />
</Group>
</Group>
<ClientSpotlight />
<Spotlight />
</AppShellHeader>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { spotlight } from "@homarr/spotlight";
import { openSpotlight } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
@@ -17,7 +17,7 @@ export const DesktopSearchInput = () => {
w={400}
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={spotlight.open}
onClick={openSpotlight}
>
{t("placeholder")}
</TextInput>
@@ -26,7 +26,7 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => {
return (
<HeaderButton onClick={spotlight.open} className={classes.mobileSearch}>
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
);

View File

@@ -1,22 +0,0 @@
"use client";
import { Spotlight } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { IconSearch } from "@homarr/ui";
export const ClientSpotlight = () => {
const t = useScopedI18n("common.search");
return (
<Spotlight
actions={[]}
nothingFound={t("nothingFound")}
highlightQuery
searchProps={{
leftSection: <IconSearch size={20} stroke={1.5} />,
placeholder: `${t("placeholder")}`,
}}
yOffset={12}
/>
);
};

View File

@@ -0,0 +1,146 @@
# Spotlight
Spotlight is the search functionality of Homarr. It can be opened by pressing `Ctrl + K` or `Cmd + K` on Mac. It is a quick way to search for anything in Homarr.
## API
### SpotlightActionData
The [SpotlightActionData](./src/type.ts) is the data structure that is used to define the actions that are shown in the spotlight.
#### Common properties
| Name | Type | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| id | `string` | The id of the action. |
| title | `string \| (t: TranslationFunction) => string` | The title of the action. Either static or generated with translation function |
| description | `string \| (t: TranslationFunction) => string` | The description of the action. Either static or generated with translation function |
| icon | `string \| (props: TablerIconProps) => JSX.Element` | The icon of the action. Either a url to an image or a TablerIcon |
| group | `string` | The group of the action. By default the groups all, web and action exist. |
| ignoreSearchAndOnlyShowInGroup | `boolean` | If true, the action will only be shown in the group and not in the search results. |
| type | `'link' \| 'button'` | The type of the action. Either link or button |
#### Properties for links
| Name | Type | Description |
| ---- | -------- | ---------------------------------------------------------------------------------------------------------- |
| href | `string` | The url the link should navigate to. If %s is contained it will be replaced with the current search query. |
#### Properties for buttons
| Name | Type | Description |
| ------- | -------------------------- | ----------------------------------------------------------------------------------------- |
| onClick | `() => MaybePromise<void>` | The function that should be called when the button is clicked. It can be async if needed. |
### useRegisterSpotlightActions
The [useRegisterSpotlightActions](./src/data-store.ts) hook is used to register actions to the spotlight. It takes an unique key and the array of [SpotlightActionData](#SpotlightActionData).
#### Usage
The following example shows how to use the `useRegisterSpotlightActions` hook to register an action to the spotlight.
```tsx
"use client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const MyComponent = () => {
useRegisterSpotlightActions("my-component", [
{
id: "my-action",
title: "My Action",
description: "This is my action",
icon: "https://example.com/icon.png",
group: "web",
type: "link",
href: "https://example.com",
},
]);
return <div>My Component</div>;
};
```
##### Using translation function
```tsx
"use client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const MyComponent = () => {
useRegisterSpotlightActions("my-component", [
{
id: "my-action",
title: (t) => t("some.path.to.translation.key"),
description: (t) => t("some.other.path.to.translation.key"),
icon: "https://example.com/icon.png",
group: "web",
type: "link",
href: "https://example.com",
},
]);
return <div>Component implementation</div>;
};
```
##### Using TablerIcon
```tsx
"use client";
import { IconUserCog } from "tabler-react";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const UserMenu = () => {
useRegisterSpotlightActions("header-user-menu", [
{
id: "user-preferences",
title: (t) => t("user.preferences.title"),
description: (t) => t("user.preferences.description"),
icon: IconUserCog,
group: "action",
type: "link",
href: "/user/preferences",
},
]);
return <div>Component implementation</div>;
};
```
##### Using dependency array
```tsx
"use client";
import { IconUserCog } from "tabler-react";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
const ColorSchemeButton = () => {
const { colorScheme, toggleColorScheme } = useColorScheme();
useRegisterSpotlightActions(
"toggle-color-scheme",
[
{
id: "toggle-color-scheme",
title: (t) => t("common.colorScheme.toggle.title"),
description: (t) =>
t(`common.colorScheme.toggle.${colorScheme}.description`),
icon: colorScheme === "light" ? IconSun : IconMoon,
group: "action",
type: "button",
onClick: toggleColorScheme,
},
],
[colorScheme],
);
return <div>Component implementation</div>;
};
```

View File

@@ -1,2 +1 @@
export * from "./src";
export { spotlight, Spotlight } from "@mantine/spotlight";

View File

@@ -34,6 +34,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/spotlight": "^7.5.3"
"@mantine/spotlight": "^7.5.3",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
}
}

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;

View File

@@ -1,3 +1,5 @@
export * from "./type";
export const supportedLanguages = ["en", "de"] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];

View File

@@ -154,6 +154,11 @@ export default {
search: {
placeholder: "Search for anything...",
nothingFound: "Nothing found",
group: {
all: "All",
web: "Web",
action: "Actions",
},
},
userAvatar: {
menu: {

View File

@@ -0,0 +1,5 @@
import type { useI18n } from "./client";
import type enTranslation from "./lang/en";
export type TranslationFunction = ReturnType<typeof useI18n>;
export type TranslationObject = typeof enTranslation;

View File

@@ -55,7 +55,6 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetKind>> = ({
)}
{Object.entries(definition.options).map(
([key, value]: [string, OptionsBuilderResult[string]]) => {
console.log(value);
const Input = getInputForType(value.type);
if (!Input || value.shouldHide?.(form.values.options as never)) {

7393
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff