New user search & dialog (#2270)

* From scrims

* wip

* wip

* wip

* wip

* WIP

* wip

* wip

* wip

* wip

* wip

* import ordering
This commit is contained in:
Kalle
2025-05-12 22:53:35 +03:00
committed by GitHub
parent f3e4ea2115
commit 4d730e5d8b
69 changed files with 1093 additions and 1027 deletions

View 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}
</>
);
}

View File

@@ -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 (

View File

@@ -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>

View 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);
}

View 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>
);
}

View File

@@ -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}

View File

@@ -123,6 +123,10 @@
border: none;
}
[data-empty] .searchClearButton {
visibility: hidden;
}
.noResults {
font-size: var(--fonts-md);
font-weight: var(--bold);

View File

@@ -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 ? (

View File

@@ -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 {

View 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;
}

View 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,
};
}