Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,26 @@
import { SendouFieldError } from "~/components/elements/FieldError";
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
// TODO: deprecate in favor of FormMessage
export function SendouBottomTexts({
bottomText,
errorText,
errorId,
}: {
bottomText?: string;
errorText?: string;
errorId?: string;
}) {
return (
<>
{errorText ? (
<SendouFieldError id={errorId}>{errorText}</SendouFieldError>
) : (
<SendouFieldError />
)}
{bottomText ? (
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
) : null}
</>
);
}

View File

@@ -0,0 +1,152 @@
.button {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border-radius: var(--radius-field);
appearance: none;
background: var(--color-text-accent);
color: var(--color-text-inverse);
cursor: pointer;
font-size: var(--font-sm);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
user-select: none;
outline-color: var(--color-text-accent);
height: var(--field-size);
white-space: nowrap;
&[data-focus-visible],
&:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: 1px;
}
&[data-pressed],
&:active {
transform: translateY(1px);
}
&[data-disabled],
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
}
.outlined {
background-color: transparent;
color: var(--color-text-accent);
}
.outlinedSuccess {
border-color: var(--color-success);
background-color: transparent;
color: var(--color-success);
outline-color: var(--color-success);
}
.outlinedDestructive {
border-color: var(--color-error);
background-color: transparent;
color: var(--color-error);
outline-color: var(--color-error);
}
.small {
font-size: var(--font-xs);
height: var(--field-size-sm);
}
.miniscule {
font-size: var(--font-2xs);
height: var(--field-size-xs);
}
.big {
font-size: var(--font-md);
height: var(--field-size-lg);
}
.square {
aspect-ratio: 1 / 1;
padding: 0;
}
.circle {
border-radius: 50%;
aspect-ratio: 1 / 1;
padding: 0;
}
.minimal {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-text-accent);
outline: initial;
&[data-focus-visible] {
outline: var(--focus-ring);
}
}
.minimalSuccess {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-success);
outline-color: var(--color-success);
}
.success {
border-color: var(--color-success);
background-color: var(--color-success);
outline-color: var(--color-success);
}
.destructive {
border-color: var(--color-error);
background-color: var(--color-error);
color: var(--color-text-inverse);
outline-color: var(--color-error);
}
.minimalDestructive {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-error);
outline-color: var(--color-error);
}
.buttonIcon {
min-width: 20px;
max-width: 20px;
margin-inline-end: var(--s-1-5);
&.lonely {
margin-inline-end: 0 !important;
}
}
.buttonIconSmall {
min-width: 18px;
max-width: 18px;
margin-inline-end: var(--s-1);
}
.buttonIconMiniscule {
min-width: 14px;
max-width: 14px;
margin-inline-end: var(--s-1);
}
.buttonIconBig {
min-width: 28px;
max-width: 28px;
margin-inline-end: var(--s-2);
}

View File

@@ -0,0 +1,190 @@
import clsx from "clsx";
import type { JSX } from "react";
import * as React from "react";
import {
Button as ReactAriaButton,
type ButtonProps as ReactAriaButtonProps,
} from "react-aria-components";
import { Link, type LinkProps } from "react-router";
import { assertUnreachable } from "~/utils/types";
import styles from "./Button.module.css";
type ButtonVariant =
| "primary"
| "success"
| "destructive"
| "outlined"
| "outlined-success"
| "outlined-destructive"
| "minimal"
| "minimal-success"
| "minimal-destructive";
export interface SendouButtonProps
extends Omit<ReactAriaButtonProps, "onClick" | "className"> {
className?: string;
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
shape?: "circle" | "square";
icon?: JSX.Element;
children?: React.ReactNode;
testId?: string;
}
export function SendouButton({
children,
variant,
size,
shape,
className,
icon,
testId,
...rest
}: SendouButtonProps) {
return (
<ReactAriaButton
data-testid={testId}
{...rest}
className={buttonClassName({ className, variant, size, shape })}
>
{icon &&
React.cloneElement(icon, {
className: iconClassName(icon.props.className, children, size),
})}
{children}
</ReactAriaButton>
);
}
export interface LinkButtonProps {
to: LinkProps["to"];
prefetch?: LinkProps["prefetch"];
preventScrollReset?: LinkProps["preventScrollReset"];
state?: LinkProps["state"];
isExternal?: boolean;
className?: string;
variant?: SendouButtonProps["variant"];
size?: SendouButtonProps["size"];
shape?: SendouButtonProps["shape"];
icon?: JSX.Element;
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
testId?: string;
"aria-label"?: string;
}
export function LinkButton({
to,
prefetch,
preventScrollReset,
state,
isExternal,
className,
variant,
size,
shape,
icon,
children,
onClick,
testId,
"aria-label": ariaLabel,
}: LinkButtonProps) {
if (isExternal) {
return (
<a
className={buttonClassName({ className, variant, size, shape })}
href={to as string}
target="_blank"
rel="noreferrer"
onClick={onClick}
data-testid={testId}
aria-label={ariaLabel}
>
{icon &&
React.cloneElement(icon, {
className: iconClassName(icon.props.className, children, size),
})}
{children}
</a>
);
}
return (
<Link
className={buttonClassName({ className, variant, size, shape })}
to={to}
data-testid={testId}
prefetch={prefetch}
preventScrollReset={preventScrollReset}
state={state}
onClick={onClick}
aria-label={ariaLabel}
>
{icon &&
React.cloneElement(icon, {
className: iconClassName(icon.props.className, children, size),
})}
{children}
</Link>
);
}
function buttonClassName({
className,
variant,
size,
shape,
}: Pick<SendouButtonProps, "className" | "variant" | "size" | "shape">) {
const variantToClassname = (variant: ButtonVariant) => {
switch (variant) {
case "primary":
return styles.primary;
case "success":
return styles.success;
case "destructive":
return styles.destructive;
case "outlined":
return styles.outlined;
case "outlined-success":
return styles.outlinedSuccess;
case "outlined-destructive":
return styles.outlinedDestructive;
case "minimal":
return styles.minimal;
case "minimal-success":
return styles.minimalSuccess;
case "minimal-destructive":
return styles.minimalDestructive;
default:
return assertUnreachable(variant);
}
};
return clsx(
className,
variant ? variantToClassname(variant) : null,
styles.button,
{
[styles.small]: size === "small",
[styles.big]: size === "big",
[styles.miniscule]: size === "miniscule",
},
{
[styles.circle]: shape === "circle",
[styles.square]: shape === "square",
},
);
}
function iconClassName(
baseClassName: string | undefined,
children: React.ReactNode,
size: SendouButtonProps["size"],
) {
return clsx(baseClassName, styles.buttonIcon, {
[styles.lonely]: !children,
[styles.buttonIconSmall]: size === "small",
[styles.buttonIconMiniscule]: size === "miniscule",
[styles.buttonIconBig]: size === "big",
});
}

View File

@@ -0,0 +1,116 @@
.root {
background-color: var(--color-bg);
border-radius: var(--radius-box);
padding: var(--s-4);
border: var(--border-style);
max-width: fit-content;
}
.header {
display: flex;
gap: var(--s-2);
justify-content: space-between;
align-items: center;
margin-block-end: var(--s-1);
min-width: 250px;
}
.heading {
font-size: var(--font-lg);
}
.navButton {
background-color: transparent;
color: var(--color-text-accent);
border: none;
padding: 0;
border-radius: 100%;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.navIcon {
width: 27.5px;
}
.grid {
width: 100%;
}
.headerCell {
color: var(--color-text-high);
}
.cell {
display: grid;
place-items: center;
font-size: var(--font-sm);
padding: var(--s-1-5);
width: 35px;
height: 35px;
border-radius: var(--radius-field);
outline-color: var(--color-accent);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&[data-outside-month] {
display: none;
}
&[data-disabled] {
opacity: 0.5;
background-color: transparent !important;
}
&[data-selected] {
background-color: var(--color-bg-high);
color: var(--color-text-accent);
}
&:hover {
background-color: var(--color-bg-high);
outline: initial;
}
}
.weekSelection {
& .grid {
/* the week band has to run unbroken across the row, including the empty cells of a month's first and last week */
border-spacing: 0;
}
& tr:hover td,
& tr:has([data-selected]) td {
background-color: var(--color-bg-high);
}
& tr:hover td:first-child,
& tr:has([data-selected]) td:first-child {
border-start-start-radius: var(--radius-field);
border-end-start-radius: var(--radius-field);
}
& tr:hover td:last-child,
& tr:has([data-selected]) td:last-child {
border-start-end-radius: var(--radius-field);
border-end-end-radius: var(--radius-field);
}
& .cell {
&:hover {
background-color: transparent;
}
/* the week is what gets selected, so the day it was picked from is not called out */
&[data-selected] {
background-color: transparent;
color: inherit;
}
}
}

View File

@@ -0,0 +1,65 @@
import clsx from "clsx";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
Button,
Calendar,
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarGridHeader,
CalendarHeaderCell,
type CalendarProps,
type DateValue,
Heading,
} from "react-aria-components";
import styles from "./Calendar.module.css";
export interface SendouCalendarProps<T extends DateValue>
extends CalendarProps<T> {
className?: string;
/** Highlights the whole week row rather than a single day, for pickers where choosing a day means choosing the week it belongs to. */
weekSelection?: boolean;
}
export function SendouCalendar<T extends DateValue>({
className,
weekSelection,
...rest
}: SendouCalendarProps<T>) {
return (
<Calendar
className={clsx(className, styles.root, {
[styles.weekSelection]: weekSelection,
})}
{...rest}
>
<header className={styles.header}>
<Button slot="previous" className={styles.navButton}>
<ChevronLeft className={styles.navIcon} />
</Button>
<Heading className={styles.heading} />
<Button slot="next" className={styles.navButton}>
<ChevronRight className={styles.navIcon} />
</Button>
</header>
<CalendarGrid className={styles.grid}>
<CalendarGridHeader>
{(day) => (
<CalendarHeaderCell className={styles.headerCell}>
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeader>
<CalendarGridBody>
{(date) => (
<CalendarCell
date={date}
className={styles.cell}
data-testid="choose-date-button"
/>
)}
</CalendarGridBody>
</CalendarGrid>
</Calendar>
);
}

View File

@@ -0,0 +1,52 @@
.group {
display: flex;
gap: var(--s-1);
width: max-content;
&[data-orientation="vertical"] {
flex-direction: column;
align-items: stretch;
}
&[data-wrap="true"] {
flex-wrap: wrap;
width: auto;
}
}
.label {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
padding: 0 var(--s-2);
height: var(--selector-size);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
color: var(--color-text);
cursor: pointer;
transition: background-color 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.radio {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
&:focus-visible + .label {
outline: var(--focus-ring);
outline-offset: 2px;
}
&:checked + .label {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
}
}

View File

@@ -0,0 +1,61 @@
import styles from "./ChipRadio.module.css";
interface SendouChipRadioGroupProps {
children: React.ReactNode;
orientation?: "horizontal" | "vertical";
wrap?: boolean;
className?: string;
}
interface SendouChipRadioProps {
name: string;
value: string;
checked: boolean;
onChange: (value: string) => void;
children: React.ReactNode;
}
export function SendouChipRadioGroup({
children,
orientation = "horizontal",
wrap = false,
className,
}: SendouChipRadioGroupProps) {
return (
<div
className={className ? `${styles.group} ${className}` : styles.group}
data-orientation={orientation}
data-wrap={wrap}
role="radiogroup"
>
{children}
</div>
);
}
export function SendouChipRadio({
name,
value,
checked,
onChange,
children,
}: SendouChipRadioProps) {
const id = `chip-radio-${name}-${value}`;
return (
<>
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
onChange={() => onChange(value)}
className={styles.radio}
/>
<label htmlFor={id} className={styles.label}>
{children}
</label>
</>
);
}

View File

@@ -0,0 +1,52 @@
.group {
height: var(--field-size);
padding: 0 var(--field-padding);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
outline: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-1-5);
width: 100%;
cursor: pointer;
}
.root {
&[data-open] .group {
border-color: transparent;
outline: var(--focus-ring);
}
}
.dateInput {
display: flex;
}
.segment {
&:not([data-type="literal"]) {
padding: 0 var(--s-0-5);
border-radius: 5px;
}
&:focus-visible {
background-color: var(--color-bg-high);
color: var(--color-text-accent);
outline: none;
}
}
.button {
padding: 0;
margin-inline-start: auto;
background-color: transparent;
border: none;
outline: none;
}
.icon {
color: var(--color-text-high);
width: var(--field-size-icon);
height: var(--field-size-icon);
}

View File

@@ -0,0 +1,80 @@
import { Calendar } from "lucide-react";
import {
Button,
DateInput,
type DatePickerProps,
DateSegment,
type DateValue,
Dialog,
Group,
Popover,
DatePicker as ReactAriaDatePicker,
} from "react-aria-components";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouCalendar } from "~/components/elements/Calendar";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./DatePicker.module.css";
import { SendouLabel } from "./Label";
interface SendouDatePickerProps<T extends DateValue>
extends DatePickerProps<T> {
label: string;
bottomText?: string;
errorText?: string;
errorId?: string;
}
export function SendouDatePicker<T extends DateValue>({
label,
errorText,
errorId,
bottomText,
isRequired,
...rest
}: SendouDatePickerProps<T>) {
const isHydrated = useHydrated();
if (!isHydrated) {
return (
<div>
<SendouLabel required={isRequired}>{label}</SendouLabel>
<input type="text" disabled />
<SendouBottomTexts
bottomText={bottomText}
errorText={errorText}
errorId={errorId}
/>
</div>
);
}
return (
<ReactAriaDatePicker
{...rest}
validationBehavior="aria"
className={styles.root}
>
<SendouLabel required={isRequired}>{label}</SendouLabel>
<Group className={styles.group}>
<DateInput className={styles.dateInput}>
{(segment) => (
<DateSegment segment={segment} className={styles.segment} />
)}
</DateInput>
<Button data-testid="open-calendar-button" className={styles.button}>
<Calendar className={styles.icon} />
</Button>
</Group>
<SendouBottomTexts
bottomText={bottomText}
errorText={errorText}
errorId={errorId}
/>
<Popover>
<Dialog>
<SendouCalendar />
</Dialog>
</Popover>
</ReactAriaDatePicker>
);
}

View File

@@ -0,0 +1,100 @@
.overlay {
position: fixed;
inset: 0;
z-index: 20;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.25);
display: flex;
min-height: 100%;
align-items: flex-start;
justify-content: center;
padding: 1rem;
text-align: center;
backdrop-filter: blur(10px); /* Adjust blur value as needed */
&[data-entering] {
animation: fade-in 300ms ease-out;
}
}
.fullScreenOverlay {
padding: 0;
display: initial;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
width: 100%;
max-width: 28rem;
max-height: min(80dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem));
overflow-x: hidden;
overflow-y: auto;
border-radius: 1rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg);
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);
margin: auto;
&[data-entering] {
animation: zoom-in-95 300ms ease-out;
}
}
:global(html[data-fuse="true"]) .modal {
max-height: min(72dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem));
}
.fullScreenModal {
min-width: 100%;
min-height: 100%;
border-radius: 0;
padding-block-start: calc(env(safe-area-inset-top) + var(--s-6));
padding-block-end: var(--s-32);
}
@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: var(--border-style);
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(--font-lg);
}

View File

@@ -0,0 +1,154 @@
import clsx from "clsx";
import { X } from "lucide-react";
import type { ModalOverlayProps } from "react-aria-components";
import {
Dialog,
DialogTrigger,
Heading,
Modal,
ModalOverlay,
} from "react-aria-components";
import { useNavigate } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
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;
/** If true, shows the close button even if onClose is not provided */
showCloseButton?: 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} isControlledByTrigger>
{children}
</DialogModal>
</DialogTrigger>
);
}
function DialogModal({
children,
heading,
showHeading = true,
className,
showCloseButton: showCloseButtonProp,
isControlledByTrigger,
...rest
}: Omit<SendouDialogProps, "trigger"> & { isControlledByTrigger?: boolean }) {
const navigate = useNavigate();
const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo;
const onClose = () => {
if (rest.onCloseTo) {
navigate(rest.onCloseTo);
} else if (rest.onClose) {
rest.onClose();
}
};
const defaultOnOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (rest.onCloseTo) {
navigate(rest.onCloseTo);
} else if (rest.onClose) {
rest.onClose();
}
}
};
const overlayProps = isControlledByTrigger
? R.omit(rest, ["onOpenChange"])
: { ...rest, onOpenChange: rest.onOpenChange ?? defaultOnOpenChange };
return (
<ModalOverlay
className={clsx(rest.overlayClassName, styles.overlay, {
[styles.fullScreenOverlay]: rest.isFullScreen,
})}
{...overlayProps}
>
<Modal
className={clsx(className, styles.modal, "scrollbar", {
[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={<X />}
shape="circle"
variant="minimal-destructive"
className="ml-auto"
slot="close"
onPress={onClose}
/>
) : null}
</div>
) : null}
{children}
</Dialog>
</Modal>
</ModalOverlay>
);
}

View File

@@ -0,0 +1,16 @@
import { FieldError as ReactAriaFieldError } from "react-aria-components";
import styles from "../FormMessage.module.css";
export function SendouFieldError({
children,
id,
}: {
children?: React.ReactNode;
id?: string;
}) {
return (
<ReactAriaFieldError className={styles.error} id={id}>
{children}
</ReactAriaFieldError>
);
}

View File

@@ -0,0 +1,14 @@
import { Text } from "react-aria-components";
import styles from "../FormMessage.module.css";
export function SendouFieldMessage({
children,
}: {
children: React.ReactNode;
}) {
return (
<Text slot="description" className={styles.info}>
{children}
</Text>
);
}

View File

@@ -0,0 +1,7 @@
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin: 0;
display: block;
text-box: trim-start cap alphabetic;
}

View File

@@ -0,0 +1,16 @@
import { Label as ReactAriaLabel } from "react-aria-components";
import styles from "./Label.module.css";
export function SendouLabel({
children,
required,
}: {
children: React.ReactNode;
required?: boolean;
}) {
return (
<ReactAriaLabel className={styles.label}>
{children} {required ? <span className="text-error">*</span> : null}
</ReactAriaLabel>
);
}

View File

@@ -0,0 +1,85 @@
.popover {
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
border: var(--border-style);
width: max-content;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-2);
}
.popoverOpensLeft {
right: 0;
}
.scrolling {
max-height: 300px !important;
overflow-y: auto;
}
.itemsContainer {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
&:focus-visible {
outline: none;
}
}
.item {
display: flex;
align-items: center;
font-weight: var(--weight-bold);
font-size: var(--font-xs);
color: var(--color-text);
white-space: nowrap;
gap: var(--s-2);
border-radius: var(--radius-field);
padding: var(--s-1-5) var(--s-3);
background-color: var(--color-bg-high);
width: 100%;
border: 0;
outline: none;
justify-content: flex-start;
transition: background-color 0.15s;
cursor: pointer;
&[data-focused] {
background-color: var(--color-bg-higher);
}
}
.itemDisabled {
color: var(--color-text-high);
cursor: not-allowed;
}
.itemSelected {
background-color: var(--color-text-accent);
font-weight: var(--weight-extra);
}
.itemActive {
color: var(--color-text-accent);
}
.itemDestructive {
color: var(--color-error);
}
.itemIcon {
width: 18px;
}
.itemImg {
min-width: 20px;
min-height: 20px;
}
.menuHeader {
font-size: var(--font-2xs);
color: var(--color-text-high);
padding: var(--s-1) var(--s-3);
font-weight: var(--weight-semi);
}

View File

@@ -0,0 +1,110 @@
import clsx from "clsx";
import {
Header,
Menu,
MenuItem,
type MenuItemProps,
MenuTrigger,
Popover,
type PopoverProps,
Section,
} from "react-aria-components";
import { Image } from "../Image";
import styles from "./Menu.module.css";
interface SendouMenuProps {
trigger: React.ReactNode;
scrolling?: boolean;
opensLeft?: boolean;
children: React.ReactNode;
popoverClassName?: string;
placement?: PopoverProps["placement"];
}
export function SendouMenu({
children,
trigger,
opensLeft,
scrolling,
placement,
}: SendouMenuProps) {
return (
<MenuTrigger>
{trigger}
<Popover
placement={placement}
className={clsx(styles.popover, "scrollbar", {
[styles.scrolling]: scrolling,
[styles.popoverOpensLeft]: !opensLeft,
})}
>
<Menu className={styles.itemsContainer}>{children}</Menu>
</Popover>
</MenuTrigger>
);
}
export interface SendouMenuItemProps extends MenuItemProps {
icon?: React.ReactNode;
imagePath?: string;
isActive?: boolean;
isDestructive?: boolean;
}
export function SendouMenuSection({
children,
headerText,
headerClassName,
}: {
children: React.ReactNode;
headerText?: React.ReactNode;
headerClassName?: string;
}) {
return (
<Section>
{headerText ? (
<Header className={clsx(styles.menuHeader, headerClassName)}>
{headerText}
</Header>
) : null}
{children}
</Section>
);
}
export function SendouMenuItem(props: SendouMenuItemProps) {
const textValue =
props.textValue ??
(typeof props.children === "string" ? props.children : undefined);
return (
<MenuItem
{...props}
textValue={textValue}
className={({ isSelected, isDisabled }) =>
clsx(styles.item, {
[styles.itemSelected]: isSelected,
[styles.itemDisabled]: isDisabled,
[styles.itemActive]: props.isActive,
[styles.itemDestructive]: props.isDestructive,
})
}
>
{/** biome-ignore lint/complexity/noUselessFragments: Biome v2 migration */}
<>
{props.icon ? (
<span className={styles.itemIcon}>{props.icon}</span>
) : null}
{props.imagePath ? (
<Image
path={props.imagePath}
alt=""
width={20}
height={20}
className={styles.itemImg}
/>
) : null}
{props.children}
</>
</MenuItem>
);
}

View File

@@ -0,0 +1,122 @@
import clsx from "clsx";
import * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import { useFetcher } from "react-router";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import { useEntitySearch } from "./useEntitySearch";
export type OrganizationSearchResult = Extract<
NonNullable<SearchLoaderData>["results"][number],
{ type: "organization" }
>;
interface OrganizationSearchProps<T extends object>
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialOrganizationId?: number;
onChange?: (organization: OrganizationSearchResult | null) => void;
}
export const OrganizationSearch = React.forwardRef(function OrganizationSearch<
T extends object,
>(
{
name,
label,
bottomText,
errorText,
initialOrganizationId,
onChange,
...rest
}: OrganizationSearchProps<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
const initialOrganization = useInitialOrganization(initialOrganizationId);
const search = useEntitySearch<OrganizationSearchResult>({
buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`,
parseResults: (data, query) =>
parseOrganizationResults(data, query, initialOrganization),
initialItem: initialOrganization,
initialSelectedId: initialOrganizationId,
onChange,
});
return (
<SearchSelect
{...rest}
name={name}
label={label}
bottomText={bottomText}
errorText={errorText}
ariaLabel="Organization search"
inputTestId="organization-search-input"
i18nKey="organizationSearch"
search={search}
buttonRef={ref}
renderItem={(item) => <OrganizationItem item={item} />}
/>
);
});
function parseOrganizationResults(
data: unknown,
query: string,
initialOrganization?: OrganizationSearchResult,
): OrganizationSearchResult[] | null {
const searchData = data as SearchLoaderData;
if (!searchData || searchData.query !== query) return null;
return searchData.results
.filter(
(result): result is OrganizationSearchResult =>
result.type === "organization",
)
.filter((org) => org.id !== initialOrganization?.id);
}
function useInitialOrganization(initialOrganizationId?: number) {
const fetcher = useFetcher<SearchLoaderData>();
React.useEffect(() => {
if (!initialOrganizationId || fetcher.state !== "idle" || fetcher.data) {
return;
}
fetcher.load(
`/search?q=${initialOrganizationId}&type=organizations&limit=1`,
);
}, [initialOrganizationId, fetcher]);
return fetcher.data?.results.find(
(result): result is OrganizationSearchResult =>
result.type === "organization",
);
}
function OrganizationItem({ item }: { item: OrganizationSearchResult }) {
return (
<ListBoxItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="organization-search-item"
>
<div className={searchSelectStyles.itemTextsContainer}>
{item.name}
<div className={searchSelectStyles.itemAdditionalText}>
/{item.slug}
</div>
</div>
</ListBoxItem>
);
}

View File

@@ -0,0 +1,17 @@
.content {
max-width: min(20rem, calc(100vw - var(--s-4)));
/* react-aria clamps the popover's max-height/width to the available viewport
space; scroll the overflow instead of letting it spill past the border */
overflow: auto;
padding: var(--s-2);
border: var(--border-style);
border-radius: var(--radius-box);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
white-space: pre-wrap;
background-color: var(--color-bg);
}
.dialog {
outline: none;
}

View File

@@ -0,0 +1,49 @@
import clsx from "clsx";
import {
Dialog,
DialogTrigger,
Popover,
type PopoverProps,
} from "react-aria-components";
import styles from "./Popover.module.css";
/**
* A reusable popover component that wraps around a trigger element (SendouButton or Button from React Aria Components library).
* Supports controlled and uncontrolled open states.
*
* @example
* ```tsx
* <SendouPopover
* trigger={<SendouButton>Click me</SendouButton>}
* >
* Popover content goes here!
* </SendouPopover>
* ```
*/
export function SendouPopover({
children,
trigger,
popoverClassName,
placement,
onOpenChange,
isOpen,
}: {
children: React.ReactNode;
trigger: React.ReactNode;
popoverClassName?: string;
placement?: PopoverProps["placement"];
onOpenChange?: PopoverProps["onOpenChange"];
isOpen?: boolean;
}) {
return (
<DialogTrigger isOpen={isOpen} onOpenChange={onOpenChange}>
{trigger}
<Popover
className={clsx(styles.content, popoverClassName)}
placement={placement}
>
<Dialog className={styles.dialog}>{children}</Dialog>
</Popover>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,71 @@
.item {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-1-5);
border-radius: var(--radius-field);
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;
font-size: var(--font-sm);
& span {
max-width: 175px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
}
}
.selectValue {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
gap: var(--s-2);
}
button:disabled .selectValue {
color: var(--color-text-high);
font-style: italic;
}
.placeholder {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
text-align: center;
display: grid;
place-items: center;
height: 162px;
margin-block: var(--s-4);
}
.itemAdditionalText {
font-size: var(--font-xs);
color: var(--color-text-high);
}
button .itemAdditionalText {
display: none;
}
.logo {
width: 24px;
height: 24px;
border-radius: var(--radius-field);
object-fit: cover;
}

View File

@@ -0,0 +1,164 @@
import clsx from "clsx";
import { ChevronsUpDown, Search, X } from "lucide-react";
import type * as React from "react";
import {
Autocomplete,
Button,
Input,
type Key,
ListBox,
ListBoxItem,
Popover,
SearchField,
Select,
type SelectProps,
SelectValue,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouLabel } from "~/components/elements/Label";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import type { EntitySearch } from "./useEntitySearch";
const PLACEHOLDER_TEXTS = {
organizationSearch: {
placeholder: "common:forms.organizationSearch.placeholder",
noResults: "common:forms.organizationSearch.noResults",
},
teamSearch: {
placeholder: "common:forms.teamSearch.placeholder",
noResults: "common:forms.teamSearch.noResults",
},
tournamentSearch: {
placeholder: "common:forms.tournamentSearch.placeholder",
noResults: "common:forms.tournamentSearch.noResults",
},
userSearch: {
placeholder: "common:forms.userSearch.placeholder",
noResults: "common:forms.userSearch.noResults",
},
} as const;
interface SearchSelectProps<
TItem extends { id: number; name: string },
T extends object,
> extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
ariaLabel: string;
inputTestId: string;
inputClassName?: string;
i18nKey: keyof typeof PLACEHOLDER_TEXTS;
search: EntitySearch<TItem>;
buttonRef?: React.Ref<HTMLButtonElement>;
renderItem: (item: TItem) => React.ReactElement;
}
/**
* Presentational autocomplete select shared by the entity search components
* (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Wire up data fetching
* with `useEntitySearch` and pass its result as `search`.
*/
export function SearchSelect<
TItem extends { id: number; name: string },
T extends object,
>({
name,
label,
bottomText,
errorText,
ariaLabel,
inputTestId,
inputClassName,
i18nKey,
search,
buttonRef,
renderItem,
...rest
}: SearchSelectProps<TItem, T>) {
return (
<Select
name={name}
placeholder=""
selectedKey={search.selectedKey}
onSelectionChange={(key: Key | null) => {
if (key != null) {
search.onSelectionChange(Number(key));
}
}}
className={selectStyles.select}
aria-label={ariaLabel}
{...rest}
>
{label ? (
<SendouLabel required={rest.isRequired}>{label}</SendouLabel>
) : null}
<Button className={selectStyles.button} ref={buttonRef}>
<SelectValue className={searchSelectStyles.selectValue} />
<span aria-hidden="true">
<ChevronsUpDown className={selectStyles.icon} />
</span>
</Button>
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover
className={clsx(selectStyles.popover, searchSelectStyles.popover)}
>
<Autocomplete
inputValue={search.filterText}
onInputChange={search.setFilterText}
>
<SearchField
aria-label="Search"
autoFocus
className={selectStyles.searchField}
>
<Search aria-hidden className={selectStyles.icon} />
<Input
className={clsx(inputClassName, selectStyles.searchInput)}
data-testid={inputTestId}
/>
<Button className={selectStyles.searchClearButton}>
<X className={selectStyles.icon} />
</Button>
</SearchField>
<ListBox items={search.items} className={selectStyles.listBox}>
{(item) =>
typeof item.id === "string" ? (
<PlaceholderItem id={item.id} i18nKey={i18nKey} />
) : (
renderItem(item as TItem)
)
}
</ListBox>
</Autocomplete>
</Popover>
</Select>
);
}
function PlaceholderItem({
id,
i18nKey,
}: {
id: "PLACEHOLDER" | "NO_RESULTS";
i18nKey: keyof typeof PLACEHOLDER_TEXTS;
}) {
const { t } = useTranslation(["common"]);
// for some reason the `renderEmptyState` on ListBox is not working
// so doing this as a workaround
return (
<ListBoxItem
textValue="PLACEHOLDER"
isDisabled
className={searchSelectStyles.placeholder}
>
{id === "PLACEHOLDER"
? t(PLACEHOLDER_TEXTS[i18nKey].placeholder)
: t(PLACEHOLDER_TEXTS[i18nKey].noResults)}
</ListBoxItem>
);
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import {
SendouSelect,
SendouSelectItem,
SendouSelectItemSection,
} from "./Select";
const SECTIONS = [
{
heading: "2023",
key: "2023",
items: [
{ id: 2, name: "Season 2" },
{ id: 1, name: "Season 1" },
{ id: 0, name: "Season 0" },
],
},
];
function GroupedSelect(props: { search?: { placeholder?: string } }) {
return (
<SendouSelect label="Season" items={SECTIONS} search={props.search}>
{({ heading, items, key }: (typeof SECTIONS)[number]) => (
<SendouSelectItemSection heading={heading} key={key}>
{items.map((item) => (
<SendouSelectItem key={item.id} id={item.id}>
{item.name}
</SendouSelectItem>
))}
</SendouSelectItemSection>
)}
</SendouSelect>
);
}
describe("SendouSelect", () => {
test("renders an item with a falsy (0) key when there is no search", async () => {
const screen = await render(<GroupedSelect />);
await screen.getByRole("button").click();
await expect
.element(screen.getByRole("option", { name: "Season 0" }))
.toBeVisible();
});
test("filters items when search is enabled", async () => {
const screen = await render(<GroupedSelect search={{}} />);
await screen.getByRole("button").click();
await screen.getByRole("searchbox").fill("Season 1");
await expect
.element(screen.getByRole("option", { name: "Season 1" }))
.toBeVisible();
await expect
.element(screen.getByRole("option", { name: "Season 2" }))
.not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,194 @@
.button {
height: var(--field-size);
padding: 0 var(--field-padding);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
outline: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-1-5);
width: 100%;
cursor: pointer;
&[data-focus-visible],
&[aria-expanded="true"] {
outline: var(--focus-ring);
outline-offset: 1px;
}
&[data-disabled] {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
}
.selectValue {
font-size: var(--font-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--color-text);
&[data-placeholder] {
color: var(--color-text-high);
}
}
.icon {
min-width: 18px;
max-width: 18px;
color: var(--color-text-high);
}
.popover {
padding: var(--s-1);
width: var(--trigger-width);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
outline: none;
display: flex;
flex-direction: column;
}
.listBox {
overflow: auto;
flex: 1;
}
.item {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-1-5);
border-radius: var(--radius-field);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&[data-disabled] {
color: var(--color-text-high);
}
}
.itemFocused {
background-color: var(--color-bg-high);
color: var(--color-text);
}
.itemSelected {
color: var(--color-text-accent);
font-weight: var(--weight-bold);
}
.searchField {
position: relative;
display: flex;
gap: var(--s-2);
border-bottom: 1px solid var(--color-border);
border-radius: 0;
accent-color: var(--color-accent);
color: var(--color-text);
outline: none;
padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2))
var(--s-1-5);
margin-block-end: var(--s-1-5);
}
.searchInput {
all: unset;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
letter-spacing: 0.5px;
flex: 1;
min-width: 0;
padding-inline-end: calc(18px + var(--s-2));
&::-webkit-search-cancel-button {
display: none;
}
&::placeholder {
color: var(--color-text-high);
}
}
.searchClearButton {
position: absolute;
right: var(--s-2);
top: 50%;
transform: translateY(-50%);
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
}
[data-empty] .searchClearButton {
visibility: hidden;
}
.noResults {
font-size: var(--font-md);
font-weight: var(--weight-bold);
text-align: center;
padding-block: var(--s-8);
color: var(--color-text-high);
}
.select {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin: 0;
display: block;
text-box: trim-start cap alphabetic;
}
.clearButton {
position: absolute;
bottom: -21px;
right: 9px;
}
.categoryHeading {
display: flex;
align-items: center;
gap: var(--s-2);
font-weight: var(--weight-extra);
color: var(--color-text-high);
text-transform: uppercase;
font-size: var(--font-2xs);
padding-block-start: var(--s-3);
padding-block-end: var(--s-1);
padding-inline: var(--s-1-5);
white-space: nowrap;
& img {
border-radius: 100%;
background-color: var(--color-bg-higher);
padding: var(--s-1);
min-width: 28px;
}
}
.categoryDivider {
background-color: var(--color-border);
width: 100%;
height: 2px;
margin-block: var(--s-2);
}

View File

@@ -0,0 +1,237 @@
import clsx from "clsx";
import { ChevronsUpDown, Search, X } from "lucide-react";
import * as React from "react";
import type {
AutocompleteProps,
ListBoxItemProps,
SelectProps,
} from "react-aria-components";
import {
Autocomplete,
Button,
Header,
Input,
Label,
ListBox,
ListBoxItem,
ListBoxSection,
ListLayout,
Popover,
SearchField,
Select,
SelectStateContext,
SelectValue,
useFilter,
Virtualizer,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
import { Image } from "../Image";
import styles from "./Select.module.css";
const ROW_HEIGHT = 33;
export interface SendouSelectProps<T extends object>
extends Omit<SelectProps<T>, "children"> {
label?: string;
description?: string;
errorText?: string;
bottomText?: string;
items?: Iterable<T>;
children: React.ReactNode | ((item: T) => React.ReactNode);
search?: {
placeholder?: string;
};
popoverClassName?: string;
/** Value of the search input, used for controlled components */
searchInputValue?: string;
/** Callback for when the search input value changes. When defined `items` has to be filtered on the caller side (automatic filtering in component disabled). */
onSearchInputChange?: (value: string) => void;
clearable?: boolean;
filter?: AutocompleteProps<object>["filter"];
/** Measured height of the items, for when they are taller than a single line of text. */
estimatedRowHeight?: number;
}
/**
* A customizable select component with optional search functionality. Virtualizes the list of items for performance.
*
* @example
* ```tsx
* <SendouSelect items={items} search={{ placeholder: "Search for items..." }}>
* {({ key, ...item }) => (
* <SendouSelectItem key={key} {...item}>
* {item.name}
* </SendouSelectItem>
* )}
* </SendouSelect>
* ```
*/
export function SendouSelect<T extends object>({
label,
description,
errorText,
bottomText,
children,
items,
search,
popoverClassName,
searchInputValue,
onSearchInputChange,
clearable = false,
className,
filter,
estimatedRowHeight,
onOpenChange,
...props
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
const { contains } = useFilter({ sensitivity: "base" });
const isControlled = !!onSearchInputChange;
const handleOpenChange = (isOpen: boolean) => {
onOpenChange?.(isOpen);
if (!isControlled) return;
if (!isOpen) {
onSearchInputChange("");
}
};
const listBox = (
<Virtualizer
layout={ListLayout}
layoutOptions={
estimatedRowHeight ? { estimatedRowHeight } : { rowHeight: ROW_HEIGHT }
}
>
<ListBox
items={items}
className={clsx(styles.listBox, "scrollbar")}
renderEmptyState={() => (
<div className={styles.noResults}>{t("common:noResults")}</div>
)}
>
{children}
</ListBox>
</Virtualizer>
);
// The Autocomplete wrapper filters the collection, but its filtering drops
// items with a falsy key (e.g. `0`). When there is nothing to filter we skip
// it entirely so such items always render.
const filterable = !!search || isControlled || !!filter;
return (
<Select
{...props}
className={clsx(className, styles.select)}
onOpenChange={handleOpenChange}
>
{label ? <Label className={styles.label}>{label}</Label> : null}
<Button className={styles.button}>
<SelectValue className={styles.selectValue} />
<span aria-hidden="true">
<ChevronsUpDown className={styles.icon} />
</span>
</Button>
{clearable ? <SelectClearButton /> : null}
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover className={clsx(popoverClassName, styles.popover)}>
{filterable ? (
<Autocomplete
filter={filter ? filter : isControlled ? undefined : contains}
inputValue={searchInputValue}
onInputChange={onSearchInputChange}
>
{search ? (
<SearchField
aria-label="Search"
autoFocus
className={styles.searchField}
>
<Search aria-hidden className={styles.icon} />
<Input
placeholder={search.placeholder}
className={clsx(styles.searchInput, "in-container")}
/>
<Button className={styles.searchClearButton}>
<X className={styles.icon} />
</Button>
</SearchField>
) : null}
{listBox}
</Autocomplete>
) : (
listBox
)}
</Popover>
</Select>
);
}
interface SendouSelectItemProps extends ListBoxItemProps {}
export function SendouSelectItem(props: SendouSelectItemProps) {
return (
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx(styles.item, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})
}
/>
);
}
interface SendouSelectItemSectionProps {
heading: string;
headingImgPath?: string;
children: React.ReactNode;
className?: string;
}
export function SendouSelectItemSection({
heading,
headingImgPath,
children,
className,
}: SendouSelectItemSectionProps) {
return (
<ListBoxSection>
<Header className={clsx(className, styles.categoryHeading)}>
{headingImgPath ? (
<Image path={headingImgPath} size={28} alt="" />
) : null}
{heading}
<div className={styles.categoryDivider} />
</Header>
{children}
</ListBoxSection>
);
}
function SelectClearButton() {
const state = React.useContext(SelectStateContext);
if (!state?.selectedKey) return null;
return (
<SendouButton
// Don't inherit behavior from Select.
slot={null}
variant="minimal-destructive"
size="miniscule"
icon={<X />}
onPress={() => state?.setSelectedKey(null)}
className={styles.clearButton}
>
Clear
</SendouButton>
);
}

View File

@@ -0,0 +1,60 @@
.indicator {
width: calc(var(--height) * 1.75);
height: var(--height);
background: var(--color-bg);
border: var(--border-style);
border-radius: calc(var(--radius-selector) * 2);
padding: 2px;
display: inline-grid;
justify-items: center;
grid-template-columns: 0fr 1fr 1fr;
transition: grid-template-columns 200ms;
&:before {
content: "";
height: 100%;
aspect-ratio: 1 / 1;
background: var(--color-border);
border-radius: var(--radius-selector);
grid-row-start: 1;
grid-column-start: 2;
}
}
.root {
--height: var(--selector-size-sm);
cursor: pointer;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.571rem;
color: var(--text-color);
forced-color-adjust: none;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin-block-end: 0;
&[data-selected] .indicator {
background: var(--color-text-accent);
border-color: var(--color-text-accent);
grid-template-columns: 1fr 1fr 0fr;
&:before {
background: var(--color-text-inverse);
}
}
&[data-focus-visible] .indicator {
outline: var(--focus-ring);
outline-offset: 1px;
}
&[data-disabled] {
cursor: not-allowed;
& .indicator {
opacity: 0.65;
}
}
}

View File

@@ -0,0 +1,18 @@
import {
Switch as ReactAriaSwitch,
type SwitchProps as ReactAriaSwitchProps,
} from "react-aria-components";
import styles from "./Switch.module.css";
interface SendouSwitchProps extends ReactAriaSwitchProps {
children?: React.ReactNode;
}
export function SendouSwitch({ children, ...rest }: SendouSwitchProps) {
return (
<ReactAriaSwitch {...rest} className={styles.root}>
<div className={styles.indicator} />
{children}
</ReactAriaSwitch>
);
}

View File

@@ -0,0 +1,162 @@
.tabListContainer {
overflow-x: auto;
overflow-y: hidden;
}
.tabList {
display: flex;
flex-direction: row;
border-bottom: 2px solid var(--color-border);
min-width: fit-content;
& svg {
--icon-size: 16px;
min-width: var(--icon-size);
min-height: var(--icon-size);
max-width: var(--icon-size);
max-height: var(--icon-size);
margin-inline-end: var(--s-1-5);
}
}
.tabContainer {
min-width: fit-content;
margin-bottom: -2px;
& .tabButton {
background-color: transparent;
border: none;
font-size: var(--font-xs);
border-radius: 0;
border-bottom: 2px solid transparent;
color: var(--color-text-high);
white-space: nowrap;
flex: 1;
transform: none !important;
& img {
min-width: 16px;
min-height: 16px;
margin-inline-end: var(--s-1-5);
}
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: -2px;
}
&[data-selected] .tabButton {
border-color: var(--color-text-accent);
color: var(--color-text);
}
&[data-focus-visible] .tabButton {
color: var(--color-text-accent) !important;
outline: none;
}
}
.fullWidth {
width: 100%;
min-width: 100%;
& .tabContainer {
flex: 1;
min-width: 0;
}
}
.root {
--tabs-gap: var(--s-6);
}
.root:not(.vertical) {
display: flex;
flex-direction: column;
}
.padded:not(.vertical) {
gap: var(--tabs-gap);
}
.disappearing {
&:has(.tabList > div:only-child) {
gap: 0;
}
& .tabList:has(> div:only-child) {
display: none;
}
}
.tabNumber {
color: var(--color-text-accent);
margin-inline-start: var(--s-2);
}
.tabAlert {
margin-inline-start: var(--s-1);
margin-inline-end: 0 !important;
color: var(--color-warning);
}
.sticky {
position: sticky;
top: 47px;
z-index: 1;
background-color: var(--color-bg);
}
.vertical {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--tabs-gap);
align-items: start;
& .tabListContainer {
overflow: visible;
position: sticky;
top: var(--layout-sticky-top);
align-self: start;
background-color: var(--color-bg);
z-index: 1;
}
& .tabList {
flex-direction: column;
border-bottom: none;
border-inline-end: 2px solid var(--color-border);
min-width: 0;
}
& .tabContainer {
margin-bottom: 0;
margin-inline-end: -2px;
&[data-selected] .tabButton {
border-bottom-color: transparent;
border-inline-end-color: var(--color-text-accent);
}
}
& .tabButton {
justify-content: flex-start;
border-bottom: none;
border-inline-end: 2px solid transparent;
text-align: start;
flex: none;
width: 100%;
padding: var(--s-2) var(--s-3);
padding-inline-end: var(--s-6);
}
& .tabNumber {
margin-inline-start: auto;
padding-inline-start: var(--s-3);
}
& .tabPanel {
min-width: 0;
}
}

View File

@@ -0,0 +1,150 @@
import clsx from "clsx";
import { TriangleAlert } from "lucide-react";
import {
Tab,
TabList,
type TabListProps,
TabPanel,
type TabPanelProps,
type TabProps,
Tabs,
type TabsProps,
} from "react-aria-components";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import buttonStyles from "./Button.module.css";
import styles from "./Tabs.module.css";
interface SendouTabsProps extends TabsProps {
/** Should there be padding above the panels. Defaults to true, pass in false if the panel content is managing its own padding. */
padded?: boolean;
/** Hide tabs if only one tab shown? Defaults to true. */
disappearing?: boolean;
/** When orientation is "vertical", switch to horizontal once the main content width drops below this many pixels. */
horizontalBelow?: number;
}
/**
* Renders a set of accessible tabs using the provided props.
*
* This component is a wrapper around the `Tabs` component, forwarding all props.
*
* @param props - The properties to pass to the underlying `Tabs` component.
* @returns The rendered tab interface.
*
* @url https://react-spectrum.adobe.com/react-aria/Tabs.html
*
* @example
* <SendouTabs>
* <SendouTabList>
* <Tab id="shooter">Shooter</Tab>
* <Tab id="roller">Roller</Tab>
* <Tab id="charger">Charger</Tab>
* </SendouTabList>
* <SendouTabPanel id="shooter">
* Splattershot, Aerospray, etc.
* </SendouTabPanel>
* <SendouTabPanel id="roller">
* Splat Roller, Dynamo Roller, etc.
* </SendouTabPanel>
* <SendouTabPanel id="charger">
* Splat Charger, E-liter, etc.
* </SendouTabPanel>
* </SendouTabs>
*/
export function SendouTabs({
padded = true,
disappearing = true,
horizontalBelow,
className,
orientation,
onSelectionChange,
...rest
}: SendouTabsProps) {
const mainWidth = useMainContentWidth();
const collapsedToHorizontal =
orientation === "vertical" &&
typeof horizontalBelow === "number" &&
mainWidth > 0 &&
mainWidth < horizontalBelow;
const effectiveOrientation = collapsedToHorizontal
? "horizontal"
: orientation;
const isVertical = effectiveOrientation === "vertical";
return (
<Tabs
orientation={effectiveOrientation}
onSelectionChange={onSelectionChange}
className={clsx(className, styles.root, {
[styles.padded]: padded,
[styles.disappearing]: disappearing,
[styles.vertical]: isVertical,
})}
{...rest}
/>
);
}
interface SendouTabProps extends TabProps {
icon?: React.ReactNode;
number?: number;
/** Render a warning-colored alert icon to draw attention to this tab. */
alert?: boolean;
children?: React.ReactNode;
}
export function SendouTab({
icon,
children,
number,
alert,
...rest
}: SendouTabProps) {
return (
<Tab className={styles.tabContainer} {...rest}>
<div className={clsx(buttonStyles.button, styles.tabButton)}>
{icon}
{children}
{typeof number === "number" && number !== 0 && (
<span className={styles.tabNumber}>{number}</span>
)}
{alert ? <TriangleAlert className={styles.tabAlert} /> : null}
</div>
</Tab>
);
}
interface SendouTabListProps<T extends object> extends TabListProps<T> {
sticky?: boolean;
/** Should tabs take 100% width with equal distribution? */
fullWidth?: boolean;
}
export function SendouTabList<T extends object>({
sticky,
fullWidth,
...rest
}: SendouTabListProps<T>) {
return (
<div className={clsx(styles.tabListContainer, "scrollbar")}>
<TabList
className={clsx(styles.tabList, {
// invisible: cantSwitchTabs && !disappearing,
// hidden: cantSwitchTabs && disappearing,
[styles.sticky]: sticky,
[styles.fullWidth]: fullWidth,
})}
{...rest}
/>
</div>
);
}
interface SendouTabPanelProps extends TabPanelProps {
className?: string;
}
export function SendouTabPanel({ className, ...rest }: SendouTabPanelProps) {
return <TabPanel className={clsx(className, styles.tabPanel)} {...rest} />;
}

View File

@@ -0,0 +1,6 @@
.logoPlaceholder {
width: 24px;
height: 24px;
border-radius: var(--radius-field);
background-color: var(--bg-lighter);
}

View File

@@ -0,0 +1,97 @@
import clsx from "clsx";
import type * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import teamSearchStyles from "./TeamSearch.module.css";
import { useEntitySearch } from "./useEntitySearch";
export type TeamSearchResult = Extract<
NonNullable<SearchLoaderData>["results"][number],
{ type: "team" }
>;
interface TeamSearchProps<T extends object>
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
/** Team to preselect and display on mount (e.g. when editing a linked team). */
initialTeam?: { id: number; name: string; avatarUrl?: string | null };
onChange?: (team: TeamSearchResult | null) => void;
ref?: React.Ref<HTMLButtonElement>;
}
export function TeamSearch<T extends object>({
name,
label,
bottomText,
errorText,
initialTeam,
onChange,
ref,
...rest
}: TeamSearchProps<T>) {
const search = useEntitySearch<TeamSearchResult>({
buildUrl: (query) => `/search?q=${query}&type=teams&limit=6`,
parseResults: parseTeamResults,
initialItem: initialTeam as TeamSearchResult | undefined,
initialSelectedId: initialTeam?.id,
onChange,
});
return (
<SearchSelect
{...rest}
name={name}
label={label}
bottomText={bottomText}
errorText={errorText}
ariaLabel="Team search"
inputTestId="team-search-input"
i18nKey="teamSearch"
search={search}
buttonRef={ref}
renderItem={(item) => <TeamItem item={item} />}
/>
);
}
function parseTeamResults(
data: unknown,
query: string,
): TeamSearchResult[] | null {
const searchData = data as SearchLoaderData;
if (!searchData || searchData.query !== query) return null;
return searchData.results.filter(
(result): result is TeamSearchResult => result.type === "team",
);
}
function TeamItem({ item }: { item: TeamSearchResult }) {
return (
<ListBoxItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="team-search-item"
>
{item.avatarUrl ? (
<img src={item.avatarUrl} alt="" className={searchSelectStyles.logo} />
) : (
<div className={teamSearchStyles.logoPlaceholder} />
)}
<div className={searchSelectStyles.itemTextsContainer}>
<span>{item.name}</span>
</div>
</ListBoxItem>
);
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { SendouToastRegion, toastQueue } from "./Toast";
function Wrapper() {
return <SendouToastRegion />;
}
describe("Toast", () => {
test("renders success toast", async () => {
const screen = await render(<Wrapper />);
toastQueue.add(
{ message: "Operation completed", variant: "success" },
{ timeout: 5000 },
);
await expect.element(screen.getByText("Operation completed")).toBeVisible();
});
test("renders error toast", async () => {
const screen = await render(<Wrapper />);
toastQueue.add({ message: "Something went wrong", variant: "error" });
await expect
.element(screen.getByText("Something went wrong"))
.toBeVisible();
});
test("dismisses toast when close button is clicked", async () => {
const screen = await render(<Wrapper />);
toastQueue.add({ message: "Dismiss me", variant: "info" }, { timeout: 0 });
const toast = screen.getByRole("alertdialog", { name: "Dismiss me" });
await expect.element(toast).toBeVisible();
await screen.getByLabelText("Close").first().click();
await expect.element(toast).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,85 @@
.toastRegion {
flex-direction: column;
gap: 8px;
display: flex;
position: fixed;
top: calc(var(--layout-nav-height) + var(--s-2));
right: 10px;
z-index: 10;
}
.toast {
background: var(--color-info-low);
border-radius: var(--radius-box);
padding: var(--s-1-5) var(--s-3);
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
gap: var(--s-1);
border: 2px solid var(--color-info);
width: 250px;
outline: none;
view-transition-class: toast;
}
::view-transition-new(.toast):only-child {
animation: appear 400ms;
}
::view-transition-old(.toast):only-child {
animation: disappear 400ms;
}
@keyframes disappear {
to {
opacity: 0;
}
}
@keyframes appear {
from {
opacity: 0;
}
}
.topRow {
display: flex;
text-transform: uppercase;
color: var(--color-info-high);
font-size: var(--font-xs);
gap: var(--s-2);
align-items: center;
margin-block-end: var(--s-1-5);
}
.errorToast {
border-color: var(--color-error);
background-color: var(--color-error-low);
.topRow {
color: var(--color-error-high);
}
}
.successToast {
border-color: var(--color-success);
background-color: var(--color-success-low);
.topRow {
color: var(--color-success-high);
}
}
.closeButton {
margin-inline-start: auto;
outline-color: var(--color-text) !important;
& svg {
width: 1rem;
}
}
.alertIcon {
width: 1.25rem;
}

View File

@@ -0,0 +1,77 @@
import clsx from "clsx";
import { Check, CircleAlert, OctagonAlert, X } from "lucide-react";
import {
Text,
UNSTABLE_Toast as Toast,
UNSTABLE_ToastContent as ToastContent,
UNSTABLE_ToastQueue as ToastQueue,
UNSTABLE_ToastRegion as ToastRegion,
} from "react-aria-components";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { SendouButton } from "./Button";
import styles from "./Toast.module.css";
export interface SendouToast {
message: string;
variant: "error" | "success" | "info";
}
export const toastQueue = new ToastQueue<SendouToast>({
wrapUpdate(fn) {
if ("startViewTransition" in document) {
const transition = document.startViewTransition(() => {
flushSync(fn);
});
// rejects with AbortError if another transition interrupts this one
transition.ready.catch(() => {});
} else {
fn();
}
},
});
export function SendouToastRegion() {
const { t } = useTranslation(["common"]);
return (
<ToastRegion
queue={toastQueue}
className={clsx(styles.toastRegion, { hidden: IS_E2E_TEST_RUN })}
>
{({ toast }) => (
<Toast
style={{ viewTransitionName: toast.key }}
toast={toast}
className={clsx(styles.toast, {
[styles.errorToast]: toast.content.variant === "error",
[styles.successToast]: toast.content.variant === "success",
[styles.infoToast]: toast.content.variant === "info",
})}
>
<ToastContent>
<div className={styles.topRow}>
{toast.content.variant === "success" ? (
<Check className={styles.alertIcon} />
) : toast.content.variant === "error" ? (
<OctagonAlert className={styles.alertIcon} />
) : (
<CircleAlert className={styles.alertIcon} />
)}
{t(`common:toasts.${toast.content.variant}`)}
<SendouButton
variant="minimal-destructive"
icon={<X />}
className={styles.closeButton}
slot="close"
size="small"
/>
</div>
<Text slot="title">{toast.content.message}</Text>
</ToastContent>
</Toast>
)}
</ToastRegion>
);
}

View File

@@ -0,0 +1,120 @@
import clsx from "clsx";
import { sub } from "date-fns";
import type * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
import { tournamentSearchSearchParams } from "~/features/tournament/tournament-search-params";
import { LocaleTime } from "../LocaleTime";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import { useEntitySearch } from "./useEntitySearch";
export type TournamentSearchItem = NonNullable<
Extract<TournamentSearchLoaderData, { tournaments: unknown }>
>["tournaments"][number];
interface TournamentSearchProps<T extends object>
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialTournamentId?: number;
/**
* Restrict results to tournaments that have already started (finished/past)
* instead of the default recent + upcoming window. Useful e.g. for importing
* data from a previous tournament.
*/
pastOnly?: boolean;
onChange?: (tournament: TournamentSearchItem | null) => void;
ref?: React.Ref<HTMLButtonElement>;
}
export function TournamentSearch<T extends object>({
name,
label,
bottomText,
errorText,
initialTournamentId,
pastOnly,
onChange,
ref,
...rest
}: TournamentSearchProps<T>) {
const search = useEntitySearch<TournamentSearchItem>({
buildUrl: (query) =>
pastOnly
? tournamentSearchSearchParams.href("/to/search", {
q: query,
limit: 6,
maxStartTime: new Date(),
})
: tournamentSearchSearchParams.href("/to/search", {
q: query,
limit: 6,
minStartTime: sub(new Date(), { days: 7 }),
}),
parseResults: parseTournamentResults,
initialSelectedId: initialTournamentId,
onChange,
});
return (
<SearchSelect
{...rest}
name={name}
label={label}
bottomText={bottomText}
errorText={errorText}
ariaLabel="Tournament search"
inputTestId="tournament-search-input"
i18nKey="tournamentSearch"
search={search}
buttonRef={ref}
renderItem={(item) => <TournamentItem item={item} />}
/>
);
}
function parseTournamentResults(
data: unknown,
query: string,
): TournamentSearchItem[] | null {
const searchData = data as TournamentSearchLoaderData;
if (!searchData || Array.isArray(searchData) || searchData.query !== query) {
return null;
}
return searchData.tournaments;
}
function TournamentItem({ item }: { item: TournamentSearchItem }) {
return (
<ListBoxItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="tournament-search-item"
>
<img src={item.logoUrl} alt="" className={searchSelectStyles.logo} />
<div className={searchSelectStyles.itemTextsContainer}>
<span>{item.name}</span>
<LocaleTime
date={item.startsAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
inline
className={searchSelectStyles.itemAdditionalText}
/>
</div>
</ListBoxItem>
);
}

View File

@@ -0,0 +1,144 @@
import clsx from "clsx";
import * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import { useFetcher } from "react-router";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { Avatar } from "../Avatar";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import { useEntitySearch } from "./useEntitySearch";
export type UserSearchResult = Extract<
NonNullable<SearchLoaderData>["results"][number],
{ type: "user" }
>;
interface UserSearchProps<T extends object>
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialUserId?: number;
onChange?: (user: UserSearchResult | null) => void;
ref?: React.Ref<HTMLButtonElement>;
}
export function UserSearch<T extends object>({
name,
label,
bottomText,
errorText,
initialUserId,
onChange,
ref,
...rest
}: UserSearchProps<T>) {
const initialUser = useInitialUser(initialUserId);
const search = useEntitySearch<UserSearchResult>({
buildUrl: (query) => `/search?q=${query}&type=users&limit=6`,
parseResults: (data, query) => parseUserResults(data, query, initialUser),
initialItem: initialUser,
initialSelectedId: initialUserId,
onChange,
});
return (
<SearchSelect
{...rest}
name={name}
label={label}
bottomText={bottomText}
errorText={errorText}
ariaLabel="User search"
inputTestId="user-search-input"
inputClassName="in-container"
i18nKey="userSearch"
search={search}
buttonRef={ref}
renderItem={(item) => <UserItem item={item} />}
/>
);
}
function parseUserResults(
data: unknown,
query: string,
initialUser?: UserSearchResult,
): UserSearchResult[] | null {
const searchData = data as SearchLoaderData;
if (!searchData || searchData.query !== query) return null;
return searchData.results
.filter((result): result is UserSearchResult => result.type === "user")
.filter((user) => user.id !== initialUser?.id);
}
/**
* Resolves the full user object for a preselected id so it can be displayed.
* Loads at most once per field: later id changes come from the user picking a
* result, which already carries the full user object.
*/
function useInitialUser(initialUserId?: number) {
const fetcher = useFetcher<SearchLoaderData>();
const { load } = fetcher;
const hasLoadedRef = React.useRef(false);
React.useEffect(() => {
if (!initialUserId || hasLoadedRef.current) {
return;
}
hasLoadedRef.current = true;
load(`/search?q=${initialUserId}&type=users&limit=1`);
}, [initialUserId, load]);
return fetcher.data?.results.find(
(result): result is UserSearchResult => result.type === "user",
);
}
function UserItem({ item }: { item: UserSearchResult }) {
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.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="user-search-item"
>
<Avatar user={item} size="xxs" />
<div className={searchSelectStyles.itemTextsContainer}>
{item.name}
{additionalText() ? (
<div className={searchSelectStyles.itemAdditionalText}>
{additionalText()}
</div>
) : null}
</div>
</ListBoxItem>
);
}

View File

@@ -0,0 +1,139 @@
import * as React from "react";
import { useFetcher } from "react-router";
import { useDebounce } from "~/hooks/useDebounce";
/** Sentinel items rendered in place of real results while loading or when empty. */
export type EntitySearchPlaceholder =
| { id: "PLACEHOLDER" }
| { id: "NO_RESULTS" };
export type EntitySearchItem<TItem> = TItem | EntitySearchPlaceholder;
interface UseEntitySearchArgs<TItem extends { id: number }> {
/** Builds the loader URL queried (debounced) as the user types. */
buildUrl: (query: string) => string;
/**
* Turns raw loader data into result items. Return `null` when the data does
* not (yet) correspond to the current query so a placeholder is shown.
*/
parseResults: (data: unknown, query: string) => TItem[] | null;
/** Already resolved item to pin to the top of the list (e.g. when editing). */
initialItem?: TItem;
/** Id to preselect on mount even before its item is resolved. */
initialSelectedId?: number;
onChange?: (item: TItem | null) => void;
}
export interface EntitySearch<TItem extends { id: number }> {
filterText: string;
setFilterText: (text: string) => void;
items: EntitySearchItem<TItem>[];
selectedKey: number | null;
onSelectionChange: (key: number) => void;
}
/**
* Shared state + data fetching for the autocomplete search selects
* (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Pair with the
* presentational `SearchSelect` component, passing the returned value as its
* `search` prop.
*/
export function useEntitySearch<TItem extends { id: number }>({
buildUrl,
parseResults,
initialItem,
initialSelectedId,
onChange,
}: UseEntitySearchArgs<TItem>): EntitySearch<TItem> {
const [filterText, setFilterText] = React.useState("");
const [selectedKey, setSelectedKey] = React.useState<number | null>(
initialSelectedId ?? null,
);
const queryFetcher = useFetcher<unknown>();
const query = filterText.trim();
useDebounce(
() => {
if (!query) return;
queryFetcher.load(buildUrl(query));
setSelectedKey(null);
},
500,
[query],
);
const prevInitialSelectedId = React.useRef(initialSelectedId);
if (initialSelectedId !== prevInitialSelectedId.current) {
prevInitialSelectedId.current = initialSelectedId;
if (typeof initialSelectedId === "number") {
setSelectedKey(initialSelectedId);
}
}
const items = withInitialItem(
toEntitySearchItems(parseResults(queryFetcher.data, query)),
initialItem,
);
const realItems = items.filter(
(item): item is TItem => typeof item.id === "number",
);
// clear the selection when its item is no longer among the results
const isSelectionValid =
!selectedKey ||
selectedKey === initialSelectedId ||
realItems.length === 0 ||
realItems.some((item) => item.id === selectedKey);
const effectiveSelectedKey = isSelectionValid ? selectedKey : null;
const prevEffectiveSelectedKey = React.useRef(effectiveSelectedKey);
React.useEffect(() => {
const selectionInvalidated =
!isSelectionValid && prevEffectiveSelectedKey.current !== null;
prevEffectiveSelectedKey.current = effectiveSelectedKey;
if (selectionInvalidated) {
setSelectedKey(null);
onChange?.(null);
}
});
const onSelectionChange = (key: number) => {
setSelectedKey(key);
const item = realItems.find((item) => item.id === key);
if (item) {
onChange?.(item);
}
};
return {
filterText,
setFilterText,
items,
selectedKey: effectiveSelectedKey,
onSelectionChange,
};
}
function toEntitySearchItems<TItem extends { id: number }>(
parsed: TItem[] | null,
): EntitySearchItem<TItem>[] {
if (parsed === null) return [{ id: "PLACEHOLDER" }];
if (parsed.length === 0) return [{ id: "NO_RESULTS" }];
return parsed;
}
function withInitialItem<TItem extends { id: number }>(
items: EntitySearchItem<TItem>[],
initialItem?: TItem,
): EntitySearchItem<TItem>[] {
if (!initialItem) return items;
return [
initialItem,
...items.filter(
(item) => typeof item.id !== "number" || item.id !== initialItem.id,
),
];
}