Skip to content

Commit

Permalink
refactor(Cmdk): support loading results externally
Browse files Browse the repository at this point in the history
  • Loading branch information
ribeirojose committed Mar 19, 2024
1 parent 3b9994e commit 6489f97
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 44 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"scripts": {
"dev": "concurrently \"yarn build:dev\" \"yarn storybook\" \"yarn test\" ",
"build:dev": "rollup -cw --bundleConfigAsCjs --watch.onBundleEnd \"yalc push\"",
"build:dev": "rollup -cw --bundleConfigAsCjs --watch.onBundleEnd \"yalc push --sig --changed\"",
"build": "yarn build:js && yalc push",
"build:js": "yarn build:types && rollup -c --bundleConfigAsCjs",
"build:types": "tspc -p tsconfig.build.json",
Expand Down
107 changes: 89 additions & 18 deletions src/components/Cmdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { CircleIcon, FileIcon } from "@radix-ui/react-icons";
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { CommandLoading } from "cmdk";
import useSWR from "swr";
import {
Button,
ButtonProps,
CommandDialog,
CommandEmpty,
CommandGroup,
Expand All @@ -13,35 +16,70 @@ import {
CommandSeparator,
} from "#/components/ui";
import { cn } from "#/lib/utils";
import { useDebounceValue } from "#/hooks/useDebounceValue";

export function CommandMenu({ commands, ...props }) {
interface Command {
href: string;
id: string;
result_type: string;
title: string;
type: string;
}

interface CommandMenuProps {
commands: {
mainNav: Command[];
sidebarNav?: { items: Command[]; title: string }[];
};
fetcher: (query: string) => Promise<Command[]>;
icons: Record<string, React.ComponentType>;
}

export function CommandMenu({
commands,
fetcher,
className,
icons,
...props
}: CommandMenuProps & ButtonProps) {
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = useDebounceValue("", 300);
const { data: searchResults, isLoading: loading } = useSWR<Command[]>(
debouncedSearch,
fetcher
);

React.useEffect(() => {
setDebouncedSearch(search);
}, [search]);

const { t } = useTranslation();

React.useEffect(() => {
const down = (e) => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
// eslint-disable-next-line no-shadow
setOpen((open) => !open);
setOpen((prevOpen) => !prevOpen);
}
};

document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);

const runCommand = React.useCallback((command) => {
const runCommand = (command: () => void) => {
setOpen(false);
command();
}, []);
};

return (
<>
<Button
variant="outline"
className={cn(
className,
"text-muted-foreground relative w-full justify-start text-sm sm:pr-12 md:w-40 lg:w-64"
)}
onClick={() => setOpen(true)}
Expand All @@ -52,27 +90,60 @@ export function CommandMenu({ commands, ...props }) {
<span className="text-xs"></span>K
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t("Type a command or search")} />
<CommandDialog open={open} onOpenChange={setOpen} loading={loading}>
<CommandInput
placeholder={t("Type a command or search")}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
<Trans>No results found</Trans>.
</CommandEmpty>
<CommandGroup heading="Links">
{commands.mainNav
.filter((navitem) => !navitem.external)
.map((navItem) => (

<CommandGroup heading={t("Search results")}>
{loading && (
<CommandLoading>
<p className="text-muted-foreground">
<Trans>Loading...</Trans>
</p>
</CommandLoading>
)}{" "}
{searchResults?.map((result) => {
const Icon = icons[result.type] ?? FileIcon;

return (
<CommandItem
key={navItem.href}
value={navItem.title}
key={result.href}
value={result.title}
onSelect={() => {
runCommand(() => navigate(navItem.href));
runCommand(() => navigate(result.href));
}}
>
<FileIcon className="mr-2 h-2 w-2" />
{navItem.title}
<p>
<Icon className="inline mr-1 h-2 w-2 stroke-1" />

Check failure on line 124 in src/components/Cmdk.tsx

View workflow job for this annotation

GitHub Actions / Run Type Check & Linters

Type '{ className: string; }' is not assignable to type 'IntrinsicAttributes'.
<b className="inline">{result.result_type}</b> |{" "}
{result.title}
</p>
</CommandItem>
))}
);
})}
</CommandGroup>
<CommandSeparator />

<CommandGroup heading="Links">
{commands.mainNav.map((navItem) => (
<CommandItem
key={navItem.href}
value={navItem.title}
onSelect={() => {
runCommand(() => navigate(navItem.href));
}}
>
<FileIcon className="mr-2 h-2 w-2" />
{navItem.title}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
{commands.sidebarNav?.map((group) => (
Expand Down
4 changes: 2 additions & 2 deletions src/components/FormBuilder/buildForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ export function buildForm(
) {
// @ts-ignore
const formElements = fields.map((field) => {
// @ts-ignore
const FieldComponent: FieldComponentType =
const FieldComponent: FieldComponentType | undefined =

Check failure on line 24 in src/components/FormBuilder/buildForm.tsx

View workflow job for this annotation

GitHub Actions / Run Type Check & Linters

Type 'ComponentType<CommonFieldProps<BaseField>> | ((props: CommonFieldProps<InputFieldProps>) => Element | null) | ... 13 more ... | undefined' is not assignable to type 'FieldComponentType | undefined'.
field?.component ||
{ ...fieldComponents, ...customComponents }[field.type];

if (!FieldComponent) {
throw new Error(`Invalid field type: ${field.type}`);
}
Expand Down
34 changes: 31 additions & 3 deletions src/components/ui/Command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,39 @@ const Command = React.forwardRef<
));
Command.displayName = CommandPrimitive.displayName;

type CommandDialogProps = DialogProps;
type CommandDialogProps = DialogProps & { loading: React.ReactNode };

const CommandDialog = ({ children, ...props }: CommandDialogProps) => (
const CommandDialog = ({ children, loading, ...props }: CommandDialogProps) => (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<DialogContent
className="overflow-hidden p-0 shadow-lg"
closeButton={
loading ? (
<p className="text-muted-foreground justify-end">
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</p>
) : undefined
}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
Expand Down
50 changes: 30 additions & 20 deletions src/components/ui/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,36 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
closeButton?: React.ReactNode;
}
>(({ className, children, closeButton, ...props }, ref) => {
const defaultCloseButton = (
<>
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</>
);

return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
{closeButton || defaultCloseButton}
</DialogClose>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;

const DialogHeader = ({
Expand Down
60 changes: 60 additions & 0 deletions src/hooks/useDebounceCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useMemo, useRef } from "react";

import debounce from "lodash.debounce";

import { useUnmount } from "./useUnmount";

type DebounceOptions = {
leading?: boolean;
maxWait?: number;
trailing?: boolean;
};

type ControlFunctions = {
cancel: () => void;
flush: () => void;
isPending: () => boolean;
};

export type DebouncedState<T extends (...args: any) => ReturnType<T>> = ((
...args: Parameters<T>
) => ReturnType<T> | undefined) &
ControlFunctions;

export function useDebounceCallback<T extends (...args: any) => ReturnType<T>>(
func: T,
delay = 500,
options: DebounceOptions = {}
): DebouncedState<T> {
const debouncedFunc = useRef<ReturnType<typeof debounce>>();

useUnmount(() => {
if (debouncedFunc.current) {
debouncedFunc.current.cancel();
}
});

const debounced = useMemo(() => {
const debouncedFuncInstance = debounce(func, delay, options);

const wrappedFunc: DebouncedState<T> = (...args: Parameters<T>) =>
debouncedFuncInstance(...args);

wrappedFunc.cancel = () => {
debouncedFuncInstance.cancel();
};

wrappedFunc.isPending = () => !!debouncedFunc.current;

wrappedFunc.flush = () => debouncedFuncInstance.flush();

return wrappedFunc;
}, [func, delay, options]);

// Update the debounced function ref whenever func, wait, or options change
useEffect(() => {
debouncedFunc.current = debounce(func, delay, options);
}, [func, delay, options]);

return debounced;
}
39 changes: 39 additions & 0 deletions src/hooks/useDebounceValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useRef, useState } from "react";

import type { DebouncedState } from "./useDebounceCallback";
import { useDebounceCallback } from "./useDebounceCallback";

type UseDebounceValueOptions<T> = {
equalityFn?: (left: T, right: T) => boolean;
leading?: boolean;
maxWait?: number;
trailing?: boolean;
};

export function useDebounceValue<T>(
initialValue: T | (() => T),
delay: number,
options?: UseDebounceValueOptions<T>
): [T, DebouncedState<(value: T) => void>] {
const eq = options?.equalityFn ?? ((left: T, right: T) => left === right);
const unwrappedInitialValue =
initialValue instanceof Function ? initialValue() : initialValue;
const [debouncedValue, setDebouncedValue] = useState<T>(
unwrappedInitialValue
);
const previousValueRef = useRef<T | undefined>(unwrappedInitialValue);

const updateDebouncedValue = useDebounceCallback(
setDebouncedValue,
delay,
options
);

// Update the debounced value if the initial value changes
if (!eq(previousValueRef.current as T, unwrappedInitialValue)) {
updateDebouncedValue(unwrappedInitialValue);
previousValueRef.current = unwrappedInitialValue;
}

return [debouncedValue, updateDebouncedValue];
}
14 changes: 14 additions & 0 deletions src/hooks/useUnmount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useEffect, useRef } from "react";

export function useUnmount(func: () => void) {
const funcRef = useRef(func);

funcRef.current = func;

useEffect(
() => () => {
funcRef.current();
},
[]
);
}

0 comments on commit 6489f97

Please sign in to comment.