mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-20 10:04:57 -05:00
New user search & dialog (#2270)
* From scrims * wip * wip * wip * wip * WIP * wip * wip * wip * wip * wip * import ordering
This commit is contained in:
23
app/components/elements/BottomTexts.tsx
Normal file
23
app/components/elements/BottomTexts.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SendouFieldError } from "~/components/elements/FieldError";
|
||||
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
|
||||
|
||||
export function SendouBottomTexts({
|
||||
bottomText,
|
||||
errorText,
|
||||
}: {
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{errorText ? (
|
||||
<SendouFieldError>{errorText}</SendouFieldError>
|
||||
) : (
|
||||
<SendouFieldError />
|
||||
)}
|
||||
{bottomText && !errorText ? (
|
||||
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type ButtonVariant =
|
||||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
|
||||
interface MyDatePickerProps extends ReactAriaButtonProps {
|
||||
interface SendouButtonProps extends ReactAriaButtonProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: "miniscule" | "small" | "medium" | "big";
|
||||
icon?: JSX.Element;
|
||||
@@ -31,7 +31,7 @@ export function SendouButton({
|
||||
className,
|
||||
icon,
|
||||
...rest
|
||||
}: MyDatePickerProps) {
|
||||
}: SendouButtonProps) {
|
||||
const variantClassname = variant ? variantToClassname(variant) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Popover,
|
||||
DatePicker as ReactAriaDatePicker,
|
||||
} from "react-aria-components";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import {
|
||||
type FormFieldSize,
|
||||
formFieldSizeToClassName,
|
||||
@@ -21,8 +22,6 @@ import {
|
||||
import { ArrowLeftIcon } from "../icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "../icons/ArrowRight";
|
||||
import { CalendarIcon } from "../icons/Calendar";
|
||||
import { SendouFieldError } from "./FieldError";
|
||||
import { SendouFieldMessage } from "./FieldMessage";
|
||||
import { SendouLabel } from "./Label";
|
||||
|
||||
interface SendouDatePickerProps<T extends DateValue>
|
||||
@@ -52,10 +51,7 @@ export function SendouDatePicker<T extends DateValue>({
|
||||
<CalendarIcon />
|
||||
</Button>
|
||||
</Group>
|
||||
{errorText && <SendouFieldError>{errorText}</SendouFieldError>}
|
||||
{bottomText && !errorText ? (
|
||||
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
|
||||
) : null}
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Calendar>
|
||||
|
||||
90
app/components/elements/Dialog.module.css
Normal file
90
app/components/elements/Dialog.module.css
Normal file
@@ -0,0 +1,90 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
overflow-y: auto;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px); /* Adjust blur value as needed */
|
||||
}
|
||||
|
||||
.fullScreenOverlay {
|
||||
padding: 0;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.overlay[data-entering] {
|
||||
animation: fade-in 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--bg-lighter-solid);
|
||||
border: 2.5px solid var(--border);
|
||||
padding: var(--s-6);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px
|
||||
rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.fullScreenModal {
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal[data-entering] {
|
||||
animation: zoom-in-95 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes zoom-in-95 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
outline: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.headingContainer {
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-block-end: var(--s-2);
|
||||
margin-block-end: var(--s-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-block-start: -3px;
|
||||
}
|
||||
|
||||
.noHeading {
|
||||
margin-block-start: -14px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
144
app/components/elements/Dialog.tsx
Normal file
144
app/components/elements/Dialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { ModalOverlayProps } from "react-aria-components";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Heading,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components";
|
||||
import { Modal } from "react-aria-components";
|
||||
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import styles from "./Dialog.module.css";
|
||||
|
||||
interface SendouDialogProps extends ModalOverlayProps {
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
heading?: string;
|
||||
showHeading?: boolean;
|
||||
onClose?: () => void;
|
||||
/** When closing the modal which URL to navigate to */
|
||||
onCloseTo?: string;
|
||||
overlayClassName?: string;
|
||||
"aria-label"?: string;
|
||||
/** If true, the modal takes over the full screen with the content below hidden */
|
||||
isFullScreen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows you to create a dialog with a customizable trigger and content.
|
||||
* It supports both controlled and uncontrolled modes for managing the dialog's open state.
|
||||
*
|
||||
* @example
|
||||
* // Example usage with implicit isOpen
|
||||
* return (
|
||||
* <SendouDialog
|
||||
* heading="Dialog Title"
|
||||
* onCloseTo={previousPageUrl()}
|
||||
* >
|
||||
* This is the dialog content.
|
||||
* </SendouDialog>
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Example usage with a SendouButton as the trigger
|
||||
* return (
|
||||
* <SendouDialog
|
||||
* heading="Dialog Title"
|
||||
* trigger={<SendouButton>Open Dialog</SendouButton>}
|
||||
* >
|
||||
* This is the dialog content.
|
||||
* </SendouDialog>
|
||||
* );
|
||||
*/
|
||||
export function SendouDialog({
|
||||
trigger,
|
||||
children,
|
||||
...rest
|
||||
}: SendouDialogProps) {
|
||||
if (!trigger) {
|
||||
const props =
|
||||
typeof rest.isOpen === "boolean" ? rest : { isOpen: true, ...rest };
|
||||
return <DialogModal {...props}>{children}</DialogModal>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<DialogModal {...rest}>{children}</DialogModal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogModal({
|
||||
children,
|
||||
heading,
|
||||
showHeading = true,
|
||||
className,
|
||||
...rest
|
||||
}: Omit<SendouDialogProps, "trigger">) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showCloseButton = rest.onClose || rest.onCloseTo;
|
||||
const onClose = () => {
|
||||
if (rest.onCloseTo) {
|
||||
navigate(rest.onCloseTo);
|
||||
} else if (rest.onClose) {
|
||||
rest.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (rest.onCloseTo) {
|
||||
navigate(rest.onCloseTo);
|
||||
} else if (rest.onClose) {
|
||||
rest.onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={clsx(rest.overlayClassName, styles.overlay, {
|
||||
[styles.fullScreenOverlay]: rest.isFullScreen,
|
||||
})}
|
||||
onOpenChange={rest.onOpenChange ?? onOpenChange}
|
||||
{...rest}
|
||||
>
|
||||
<Modal
|
||||
className={clsx(className, styles.modal, {
|
||||
[styles.fullScreenModal]: rest.isFullScreen,
|
||||
})}
|
||||
>
|
||||
<Dialog className={styles.dialog} aria-label={rest["aria-label"]}>
|
||||
{showHeading ? (
|
||||
<div
|
||||
className={clsx(styles.headingContainer, {
|
||||
[styles.noHeading]: !heading,
|
||||
})}
|
||||
>
|
||||
{heading ? (
|
||||
<Heading slot="title" className={styles.heading}>
|
||||
{heading}
|
||||
</Heading>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<SendouButton
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal-destructive"
|
||||
className="ml-auto"
|
||||
slot="close"
|
||||
onPress={onClose}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FieldError as ReactAriaFieldError } from "react-aria-components";
|
||||
|
||||
export function SendouFieldError({ children }: { children: React.ReactNode }) {
|
||||
export function SendouFieldError({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ReactAriaFieldError className="error-message">
|
||||
{children}
|
||||
|
||||
@@ -123,6 +123,10 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-empty] .searchClearButton {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--bold);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import type {
|
||||
ListBoxItemProps,
|
||||
SelectProps,
|
||||
ValidationResult,
|
||||
} from "react-aria-components";
|
||||
import type { ListBoxItemProps, SelectProps } from "react-aria-components";
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
ListBox,
|
||||
@@ -17,11 +12,11 @@ import {
|
||||
SearchField,
|
||||
Select,
|
||||
SelectValue,
|
||||
Text,
|
||||
Virtualizer,
|
||||
useFilter,
|
||||
} from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
||||
import { CrossIcon } from "../icons/Cross";
|
||||
import { SearchIcon } from "../icons/Search";
|
||||
@@ -31,7 +26,8 @@ interface SendouSelectProps<T extends object>
|
||||
extends Omit<SelectProps<T>, "children"> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
errorMessage?: string | ((validation: ValidationResult) => string);
|
||||
errorText?: string;
|
||||
bottomText?: string;
|
||||
items?: Iterable<T>;
|
||||
children: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
search?: {
|
||||
@@ -42,7 +38,8 @@ interface SendouSelectProps<T extends object>
|
||||
export function SendouSelect<T extends object>({
|
||||
label,
|
||||
description,
|
||||
errorMessage,
|
||||
errorText,
|
||||
bottomText,
|
||||
children,
|
||||
items,
|
||||
search,
|
||||
@@ -60,8 +57,7 @@ export function SendouSelect<T extends object>({
|
||||
<ChevronUpDownIcon className={styles.icon} />
|
||||
</span>
|
||||
</Button>
|
||||
{description && <Text slot="description">{description}</Text>}
|
||||
<FieldError>{errorMessage}</FieldError>
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover className={styles.popover}>
|
||||
<Autocomplete filter={contains}>
|
||||
{search ? (
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 55px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
||||
57
app/components/elements/UserSearch.module.css
Normal file
57
app/components/elements/UserSearch.module.css
Normal file
@@ -0,0 +1,57 @@
|
||||
.item {
|
||||
font-size: var(--fonts-xsm);
|
||||
font-weight: var(--semi-bold);
|
||||
white-space: pre-wrap;
|
||||
padding: var(--s-1-5);
|
||||
border-radius: var(--rounded-sm);
|
||||
height: 33px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.popover {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.itemTextsContainer {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.selectValue {
|
||||
max-width: calc(var(--select-width) - 55px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
button:disabled .selectValue {
|
||||
color: var(--text-lighter);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 162px;
|
||||
margin-block: var(--s-4);
|
||||
}
|
||||
|
||||
.itemAdditionalText {
|
||||
font-size: var(--fonts-xxsm);
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
button .itemAdditionalText {
|
||||
display: none;
|
||||
}
|
||||
248
app/components/elements/UserSearch.tsx
Normal file
248
app/components/elements/UserSearch.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
type Key,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
SearchField,
|
||||
Select,
|
||||
type SelectProps,
|
||||
SelectValue,
|
||||
} from "react-aria-components";
|
||||
import { Autocomplete } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "react-use";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouLabel } from "~/components/elements/Label";
|
||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { SearchIcon } from "../icons/Search";
|
||||
|
||||
import selectStyles from "./Select.module.css";
|
||||
import userSearchStyles from "./UserSearch.module.css";
|
||||
|
||||
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||
|
||||
interface UserSearchProps<T extends object>
|
||||
extends Omit<SelectProps<T>, "children"> {
|
||||
name?: string;
|
||||
label?: string;
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
initialUserId?: number;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
}
|
||||
|
||||
export const UserSearch = React.forwardRef(function UserSearch<
|
||||
T extends object,
|
||||
>(
|
||||
{
|
||||
name,
|
||||
label,
|
||||
bottomText,
|
||||
errorText,
|
||||
initialUserId,
|
||||
onChange,
|
||||
...rest
|
||||
}: UserSearchProps<T>,
|
||||
ref?: React.Ref<HTMLButtonElement>,
|
||||
) {
|
||||
const [selectedKey, setSelectedKey] = React.useState(initialUserId ?? null);
|
||||
const { initialUser, ...list } = useUserSearch(setSelectedKey, initialUserId);
|
||||
|
||||
const onSelectionChange = (userId: number) => {
|
||||
setSelectedKey(userId);
|
||||
onChange?.(
|
||||
list.items.find((user) => user.id === userId) as UserSearchUserItem,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
name={name}
|
||||
placeholder=""
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={onSelectionChange as (key: Key) => void}
|
||||
{...rest}
|
||||
>
|
||||
{label ? (
|
||||
<SendouLabel required={rest.isRequired}>{label}</SendouLabel>
|
||||
) : null}
|
||||
<Button className={selectStyles.button} ref={ref}>
|
||||
<SelectValue className={userSearchStyles.selectValue} />
|
||||
<span aria-hidden="true">
|
||||
<ChevronUpDownIcon className={selectStyles.icon} />
|
||||
</span>
|
||||
</Button>
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover className={clsx(selectStyles.popover, userSearchStyles.popover)}>
|
||||
<Autocomplete
|
||||
inputValue={list.filterText}
|
||||
onInputChange={list.setFilterText}
|
||||
>
|
||||
<SearchField
|
||||
aria-label="Search"
|
||||
autoFocus
|
||||
className={selectStyles.searchField}
|
||||
>
|
||||
<SearchIcon aria-hidden className={selectStyles.smallIcon} />
|
||||
<Input
|
||||
className={clsx("plain", selectStyles.searchInput)}
|
||||
data-testid="user-search-input"
|
||||
/>
|
||||
<Button className={selectStyles.searchClearButton}>
|
||||
<CrossIcon className={selectStyles.smallIcon} />
|
||||
</Button>
|
||||
</SearchField>
|
||||
<ListBox
|
||||
items={[initialUser, ...list.items].filter(
|
||||
(user) => user !== undefined,
|
||||
)}
|
||||
className={selectStyles.listBox}
|
||||
>
|
||||
{(item) => <UserItem item={item as UserSearchUserItem} />}
|
||||
</ListBox>
|
||||
</Autocomplete>
|
||||
</Popover>
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
function UserItem({
|
||||
item,
|
||||
}: {
|
||||
item:
|
||||
| UserSearchUserItem
|
||||
| {
|
||||
id: "NO_RESULTS";
|
||||
}
|
||||
| {
|
||||
id: "PLACEHOLDER";
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// for some reason the `renderEmptyState` on ListBox is not working
|
||||
// so doing this as a workaround
|
||||
if (typeof item.id === "string") {
|
||||
return (
|
||||
<ListBoxItem
|
||||
id="PLACEHOLDER"
|
||||
textValue="PLACEHOLDER"
|
||||
isDisabled
|
||||
className={userSearchStyles.placeholder}
|
||||
>
|
||||
{item.id === "PLACEHOLDER"
|
||||
? t("common:forms.userSearch.placeholder")
|
||||
: t("common:forms.userSearch.noResults")}
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
const additionalText = () => {
|
||||
const plusServer = item.plusTier ? `+${item.plusTier}` : "";
|
||||
const profileUrl = item.customUrl ? `/u/${item.customUrl}` : "";
|
||||
|
||||
if (plusServer && profileUrl) {
|
||||
return `${plusServer} • ${profileUrl}`;
|
||||
}
|
||||
|
||||
if (plusServer) {
|
||||
return plusServer;
|
||||
}
|
||||
|
||||
if (profileUrl) {
|
||||
return profileUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<ListBoxItem
|
||||
id={item.id}
|
||||
textValue={item.username}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
clsx(userSearchStyles.item, {
|
||||
[selectStyles.itemFocused]: isFocused,
|
||||
[selectStyles.itemSelected]: isSelected,
|
||||
})
|
||||
}
|
||||
data-testid="user-search-item"
|
||||
>
|
||||
<Avatar user={item} size="xxs" />
|
||||
<div className={userSearchStyles.itemTextsContainer}>
|
||||
{item.username}
|
||||
{additionalText() ? (
|
||||
<div className={userSearchStyles.itemAdditionalText}>
|
||||
{additionalText()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function useUserSearch(
|
||||
setSelectedKey: (userId: number | null) => void,
|
||||
initialUserId?: number,
|
||||
) {
|
||||
const [filterText, setFilterText] = React.useState("");
|
||||
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialUserId !== undefined) {
|
||||
setSelectedKey(initialUserId);
|
||||
}
|
||||
}, [initialUserId, setSelectedKey]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!filterText) return;
|
||||
queryFetcher.load(`/u?q=${filterText}&limit=6`);
|
||||
setSelectedKey(null);
|
||||
},
|
||||
500,
|
||||
[filterText],
|
||||
);
|
||||
|
||||
const items = () => {
|
||||
// data fetched for the query user has currently typed
|
||||
if (queryFetcher.data && queryFetcher.data.query === filterText) {
|
||||
if (queryFetcher.data.users.length === 0) {
|
||||
return [{ id: "NO_RESULTS" }];
|
||||
}
|
||||
return queryFetcher.data.users;
|
||||
}
|
||||
|
||||
return [{ id: "PLACEHOLDER" }];
|
||||
};
|
||||
|
||||
const initialUser = initialUserFetcher.data?.users[0];
|
||||
|
||||
return {
|
||||
filterText,
|
||||
setFilterText,
|
||||
items: items(),
|
||||
initialUser,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user