mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
26
apps/web-react/app/components/elements/BottomTexts.tsx
Normal file
26
apps/web-react/app/components/elements/BottomTexts.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
apps/web-react/app/components/elements/Button.module.css
Normal file
152
apps/web-react/app/components/elements/Button.module.css
Normal 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);
|
||||
}
|
||||
190
apps/web-react/app/components/elements/Button.tsx
Normal file
190
apps/web-react/app/components/elements/Button.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
116
apps/web-react/app/components/elements/Calendar.module.css
Normal file
116
apps/web-react/app/components/elements/Calendar.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
apps/web-react/app/components/elements/Calendar.tsx
Normal file
65
apps/web-react/app/components/elements/Calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/web-react/app/components/elements/ChipRadio.module.css
Normal file
52
apps/web-react/app/components/elements/ChipRadio.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
61
apps/web-react/app/components/elements/ChipRadio.tsx
Normal file
61
apps/web-react/app/components/elements/ChipRadio.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
apps/web-react/app/components/elements/DatePicker.module.css
Normal file
52
apps/web-react/app/components/elements/DatePicker.module.css
Normal 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);
|
||||
}
|
||||
80
apps/web-react/app/components/elements/DatePicker.tsx
Normal file
80
apps/web-react/app/components/elements/DatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
apps/web-react/app/components/elements/Dialog.module.css
Normal file
100
apps/web-react/app/components/elements/Dialog.module.css
Normal 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);
|
||||
}
|
||||
154
apps/web-react/app/components/elements/Dialog.tsx
Normal file
154
apps/web-react/app/components/elements/Dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web-react/app/components/elements/FieldError.tsx
Normal file
16
apps/web-react/app/components/elements/FieldError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web-react/app/components/elements/FieldMessage.tsx
Normal file
14
apps/web-react/app/components/elements/FieldMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/web-react/app/components/elements/Label.module.css
Normal file
7
apps/web-react/app/components/elements/Label.module.css
Normal 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;
|
||||
}
|
||||
16
apps/web-react/app/components/elements/Label.tsx
Normal file
16
apps/web-react/app/components/elements/Label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/web-react/app/components/elements/Menu.module.css
Normal file
85
apps/web-react/app/components/elements/Menu.module.css
Normal 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);
|
||||
}
|
||||
110
apps/web-react/app/components/elements/Menu.tsx
Normal file
110
apps/web-react/app/components/elements/Menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/web-react/app/components/elements/OrganizationSearch.tsx
Normal file
122
apps/web-react/app/components/elements/OrganizationSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/web-react/app/components/elements/Popover.module.css
Normal file
17
apps/web-react/app/components/elements/Popover.module.css
Normal 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;
|
||||
}
|
||||
49
apps/web-react/app/components/elements/Popover.tsx
Normal file
49
apps/web-react/app/components/elements/Popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
164
apps/web-react/app/components/elements/SearchSelect.tsx
Normal file
164
apps/web-react/app/components/elements/SearchSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
194
apps/web-react/app/components/elements/Select.module.css
Normal file
194
apps/web-react/app/components/elements/Select.module.css
Normal 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);
|
||||
}
|
||||
237
apps/web-react/app/components/elements/Select.tsx
Normal file
237
apps/web-react/app/components/elements/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/web-react/app/components/elements/Switch.module.css
Normal file
60
apps/web-react/app/components/elements/Switch.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/web-react/app/components/elements/Switch.tsx
Normal file
18
apps/web-react/app/components/elements/Switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
apps/web-react/app/components/elements/Tabs.module.css
Normal file
162
apps/web-react/app/components/elements/Tabs.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
150
apps/web-react/app/components/elements/Tabs.tsx
Normal file
150
apps/web-react/app/components/elements/Tabs.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.logoPlaceholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
97
apps/web-react/app/components/elements/TeamSearch.tsx
Normal file
97
apps/web-react/app/components/elements/TeamSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
85
apps/web-react/app/components/elements/Toast.module.css
Normal file
85
apps/web-react/app/components/elements/Toast.module.css
Normal 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;
|
||||
}
|
||||
77
apps/web-react/app/components/elements/Toast.tsx
Normal file
77
apps/web-react/app/components/elements/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/web-react/app/components/elements/TournamentSearch.tsx
Normal file
120
apps/web-react/app/components/elements/TournamentSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/web-react/app/components/elements/UserSearch.tsx
Normal file
144
apps/web-react/app/components/elements/UserSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/web-react/app/components/elements/useEntitySearch.ts
Normal file
139
apps/web-react/app/components/elements/useEntitySearch.ts
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user