Merge remote-tracking branch 'origin/main' into public-league

# Conflicts:
#	app/features/tournament-bracket/routes/to.$id.divisions.module.css
This commit is contained in:
Kalle
2026-09-23 17:49:44 +03:00
291 changed files with 7213 additions and 2194 deletions

View File

@@ -8,7 +8,7 @@
- note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command
- typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc.
- non-exported functions typically do not need JSDoc or at least it can be kept short
- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout) and [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories)
- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout), [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) and [overlays.md](./docs/dev/overlays.md) (popovers, menus, selects and dialogs: the floating layer, scroll lock and mobile keyboard handling)
## Commands

View File

@@ -10,6 +10,8 @@ import "~/styles/utils.css";
import "~/styles/flags.css";
document.documentElement.classList.add("dark");
document.documentElement.style.setProperty("--popover-boundary-top", "0px");
document.documentElement.style.setProperty("--popover-boundary-bottom", "0px");
i18next.use(initReactI18next).init({
...config,

View File

@@ -87,12 +87,12 @@ export function Chart({
const colors = useThemeColors({
// "high" variants for the curve lines so they stay legible on the dark chart
accentHigh: "--color-text-accent",
accentHigh: "--color-fg-accent",
infoHigh: "--color-info-high",
secondHigh: "--color-second-high",
// low variants for the highlight marker fills (paired with a light border)
accentLow: "--color-accent-low",
secondLow: "--color-second-low",
secondHigh: "--color-fg-second",
// tinted surfaces for the highlight marker fills (paired with a light border)
accentLow: "--color-bg-accent",
secondLow: "--color-bg-second",
border: "--color-border",
borderHigh: "--color-border-high",
text: "--color-text-high",

View File

@@ -84,7 +84,7 @@
}
.chatColorPreview {
color: oklch(from var(--color-text-accent) l c var(--_chat-h));
color: oklch(from var(--color-fg-accent) l c var(--_chat-h));
}
.themeShare {

View File

@@ -1,17 +1,11 @@
import { Check, Clipboard, PencilLine } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as v from "valibot";
import type { CustomTheme } from "~/db/tables-json";
import * as ThemePalette from "~/features/theme/core/ThemePalette";
import { CUSTOM_THEME_VARS } from "~/features/theme/theme-constants";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import {
ACCENT_CHROMA_MULTIPLIERS,
BASE_CHROMA_MULTIPLIERS,
clampThemeToGamut,
type ThemeInput,
} from "~/utils/oklch-gamut";
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema";
import { THEME_INPUT_LIMITS } from "~/utils/schema";
import styles from "./CustomThemeSelector.module.css";
import { Divider } from "./Divider";
import { LinkButton, SendouButton } from "./elements/Button";
@@ -55,6 +49,15 @@ const COLOR_SLIDERS = [
labelKey: "accentChroma",
isHue: false,
},
{
id: "bg-lightness",
inputKey: "bgLightness",
min: THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN,
max: THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX,
step: THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP,
labelKey: "bgLightness",
isHue: false,
},
] as const;
const RADIUS_SLIDERS = [
@@ -129,91 +132,10 @@ type ThemeInputKey =
| (typeof SIZE_SLIDERS)[number]["inputKey"]
| "chatHue";
const THEME_STRING_KEYS: readonly ThemeInputKey[] = [
...COLOR_SLIDERS.map((s) => s.inputKey),
...RADIUS_SLIDERS.map((s) => s.inputKey),
...BORDER_SLIDERS.map((s) => s.inputKey),
...SIZE_SLIDERS.map((s) => s.inputKey),
"chatHue",
];
function applyThemeInput(input: ThemePalette.ThemeInput) {
const theme = ThemePalette.build(input);
function themeInputToString(input: ThemeInput): string {
return THEME_STRING_KEYS.map((key) => {
const value = input[key];
return value === null ? "_" : String(value);
}).join(";");
}
function themeInputFromString(str: string): ThemeInput | null {
const parts = str.split(";");
if (parts.length !== THEME_STRING_KEYS.length) return null;
const raw: Record<string, number | null> = {};
for (let i = 0; i < THEME_STRING_KEYS.length; i++) {
const key = THEME_STRING_KEYS[i];
const part = parts[i].trim();
if (key === "chatHue" && part === "_") {
raw[key] = null;
continue;
}
const num = Number(part);
if (Number.isNaN(num)) return null;
raw[key] = num;
}
const parsed = v.safeParse(themeInputSchema, raw);
return parsed.success ? parsed.output : null;
}
const DEFAULT_THEME_INPUT: ThemeInput = {
baseHue: 268,
baseChroma: 0.05,
accentHue: 253,
accentChroma: 0.24,
chatHue: null,
radiusBox: 3,
radiusField: 2,
radiusSelector: 2,
borderWidth: 2,
sizeField: 1,
sizeSelector: 1,
sizeSpacing: 1,
};
function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput {
return {
baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue,
baseChroma:
typeof customTheme["--_base-c-2"] === "number"
? customTheme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2]
: DEFAULT_THEME_INPUT.baseChroma,
accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue,
accentChroma:
typeof customTheme["--_acc-c-2"] === "number"
? customTheme["--_acc-c-2"] / ACCENT_CHROMA_MULTIPLIERS[2]
: DEFAULT_THEME_INPUT.accentChroma,
chatHue: customTheme["--_chat-h"],
radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox,
radiusField:
customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField,
radiusSelector:
customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector,
borderWidth:
customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth,
sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField,
sizeSelector:
customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector,
sizeSpacing:
customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing,
};
}
function applyThemeInput(input: ThemeInput) {
const clampedTheme = clampThemeToGamut(input);
for (const [key, value] of Object.entries(clampedTheme)) {
for (const [key, value] of Object.entries(theme)) {
document.documentElement.style.setProperty(key, String(value));
}
}
@@ -268,7 +190,7 @@ export function CustomThemeSelector({
initialTheme: CustomTheme | null | undefined;
isSupporter: boolean;
isPersonalTheme: boolean;
onSave: (themeInput: ThemeInput) => void;
onSave: (themeInput: ThemePalette.ThemeInput) => void;
onReset: () => void;
hidePatreonInfo?: boolean;
fetcherState?: "idle" | "submitting" | "loading";
@@ -276,11 +198,11 @@ export function CustomThemeSelector({
const { t } = useTranslation(["common"]);
const initialThemeInput = initialTheme
? themeInputFromCustomTheme(initialTheme)
: DEFAULT_THEME_INPUT;
? ThemePalette.toThemeInput(initialTheme)
: ThemePalette.DEFAULT_THEME_INPUT;
const [themeInput, setThemeInput] =
React.useState<ThemeInput>(initialThemeInput);
React.useState<ThemePalette.ThemeInput>(initialThemeInput);
const handleSliderChange = (inputKey: ThemeInputKey, value: number) => {
const updatedInput = { ...themeInput, [inputKey]: value };
@@ -304,7 +226,7 @@ export function CustomThemeSelector({
};
const handleReset = () => {
setThemeInput(DEFAULT_THEME_INPUT);
setThemeInput(ThemePalette.DEFAULT_THEME_INPUT);
for (const varDef of CUSTOM_THEME_VARS) {
document.documentElement.style.removeProperty(varDef);
}
@@ -464,17 +386,17 @@ function ThemeShareInput({
themeInput,
onImport,
}: {
themeInput: ThemeInput;
onImport: (input: ThemeInput) => void;
themeInput: ThemePalette.ThemeInput;
onImport: (input: ThemePalette.ThemeInput) => void;
}) {
const { t } = useTranslation(["common"]);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
const themeString = themeInputToString(themeInput);
const themeString = ThemePalette.toShareCode(themeInput);
const handlePaste = async () => {
const text = await navigator.clipboard.readText();
const parsed = themeInputFromString(text);
const parsed = ThemePalette.fromShareCode(text);
if (parsed) onImport(parsed);
};

View File

@@ -2,7 +2,7 @@
display: flex;
width: 100%;
align-items: center;
color: var(--color-text-accent);
color: var(--color-fg-accent);
font-size: var(--font-lg);
text-align: center;

View File

@@ -21,7 +21,7 @@
}
.buttonActive {
color: var(--color-text-accent);
color: var(--color-fg-accent);
background-color: var(--color-bg-high);
border-color: var(--color-border-high);
}

View File

@@ -57,7 +57,7 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
<SendouSelectItemSection
className={idx === 0 ? "pt-0-5" : undefined}
heading={t(`game-misc:BRAND_${brandId}` as any)}
headingImgPath={brandImageUrl(brandId)}
headingImg={<Image path={brandImageUrl(brandId)} size={28} alt="" />}
key={key}
>
{gear.map(({ id, name }) => (

View File

@@ -6,3 +6,62 @@
grid-column: 1;
grid-row: 1;
}
/* the outline is the union of eight offset copies of the silhouette, which mask
layers composite with `add` by default. chained drop-shadows would instead
feed each shadow into the next and scatter ghost copies at every sum of the
offsets. the art is inset by the outline width so the outline stays inside
the box the icon was asked to fill */
.inkTinted {
--ink-outline-width: 1px;
--ink-outline-color: rgba(0, 0, 0, 0.25);
--_art-size: calc(100% - var(--ink-outline-width) * 2);
--_straight: var(--ink-outline-width);
--_diagonal: calc(var(--ink-outline-width) * 0.707);
display: block;
position: relative;
background-color: var(--ink-outline-color);
mask-image:
var(--ink-silhouette), var(--ink-silhouette), var(--ink-silhouette),
var(--ink-silhouette), var(--ink-silhouette), var(--ink-silhouette),
var(--ink-silhouette), var(--ink-silhouette);
mask-size: var(--_art-size);
mask-repeat: no-repeat;
mask-position:
calc(50% - var(--_straight)) 50%,
calc(50% + var(--_straight)) 50%,
50% calc(50% - var(--_straight)),
50% calc(50% + var(--_straight)),
calc(50% - var(--_diagonal)) calc(50% - var(--_diagonal)),
calc(50% + var(--_diagonal)) calc(50% - var(--_diagonal)),
calc(50% - var(--_diagonal)) calc(50% + var(--_diagonal)),
calc(50% + var(--_diagonal)) calc(50% + var(--_diagonal));
}
/* real elements rather than pseudo elements: snapdom (the image export) only
inlines mask images it finds on real ones, and a dropped mask paints the
whole layer over the icon */
.inkArt,
.inkHighlight {
position: absolute;
inset: 0;
mask-size: var(--_art-size);
mask-repeat: no-repeat;
mask-position: center;
}
.inkArt {
background-color: var(--color-accent);
background-image: var(--ink-detail);
background-size: var(--_art-size);
background-repeat: no-repeat;
background-position: center;
mask-image: var(--ink-silhouette);
}
.inkHighlight {
background-color: #fff;
mask-image: var(--ink-highlight);
}

View File

@@ -14,8 +14,12 @@ import {
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
outlinedTenStarMainWeaponImageUrl,
specialWeaponDetailImageUrl,
specialWeaponHighlightImageUrl,
specialWeaponImageUrl,
stageImageUrl,
subWeaponDetailImageUrl,
subWeaponHighlightImageUrl,
subWeaponImageUrl,
TIER_PLUS_URL,
tierImageUrl,
@@ -167,48 +171,109 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) {
type SubWeaponImageProps = {
subWeaponId: SubWeaponId;
alt?: string;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SubWeaponImage({
subWeaponId,
alt,
testId,
...rest
}: SubWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
const name = alt ?? t(`weapons:SUB_${subWeaponId}`);
return (
<Image
<InkTintedImage
{...rest}
alt={t(`weapons:SUB_${subWeaponId}`)}
title={t(`weapons:SUB_${subWeaponId}`)}
alt={name}
title={name || undefined}
testId={testId}
path={subWeaponImageUrl(subWeaponId)}
detailPath={subWeaponDetailImageUrl(subWeaponId)}
highlightPath={subWeaponHighlightImageUrl(subWeaponId)}
/>
);
}
type SpecialWeaponImageProps = {
specialWeaponId: SpecialWeaponId;
alt?: string;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SpecialWeaponImage({
specialWeaponId,
alt,
testId,
...rest
}: SpecialWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
const name = alt ?? t(`weapons:SPECIAL_${specialWeaponId}`);
return (
<Image
<InkTintedImage
{...rest}
alt={t(`weapons:SPECIAL_${specialWeaponId}`)}
title={t(`weapons:SPECIAL_${specialWeaponId}`)}
alt={name}
title={name || undefined}
testId={testId}
path={specialWeaponImageUrl(specialWeaponId)}
detailPath={specialWeaponDetailImageUrl(specialWeaponId)}
highlightPath={specialWeaponHighlightImageUrl(specialWeaponId)}
/>
);
}
type InkTintedImageProps = {
/** Icon whose alpha channel is the silhouette to fill with the accent color. */
path: string;
/** Overlay holding the teal parts of the icon. */
detailPath: string;
/** Mask of the white parts of the icon, painted in the text color. */
highlightPath: string;
} & Omit<ImageProps, "path" | "onClick" | "loading">;
function InkTintedImage({
path,
detailPath,
highlightPath,
alt,
title,
className,
containerClassName,
containerStyle,
width,
height,
size,
style,
testId,
}: InkTintedImageProps) {
return (
<div title={title} className={containerClassName} style={containerStyle}>
<span
role="img"
aria-label={alt}
data-testid={testId}
className={clsx(styles.inkTinted, className)}
style={
{
...style,
width: size ?? width,
height: size ?? height,
"--ink-silhouette": `url("${path}.avif")`,
"--ink-detail": `url("${detailPath}.avif")`,
"--ink-highlight": `url("${highlightPath}.avif")`,
} as React.CSSProperties
}
>
<span className={styles.inkArt} />
<span className={styles.inkHighlight} />
</span>
</div>
);
}
type TierImageProps = {
tier: { name: TierName; isPlus: boolean };
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;

View File

@@ -49,7 +49,7 @@
border: var(--border-style);
border-radius: var(--radius-full);
background-color: var(--color-bg);
color: var(--color-accent);
color: var(--color-fg-accent);
opacity: 1 !important;
outline: initial;

View File

@@ -3,7 +3,7 @@
position: fixed;
/* captured on its own so animating content slides under it instead of over it */
view-transition-name: layout-mobile-nav;
inset: auto 0 0 0;
inset: auto var(--scrollbar-width, 0px) 0 0;
width: auto;
height: auto;
margin: 0;
@@ -62,7 +62,7 @@
&:hover,
&[data-active="true"] {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
}
@@ -102,7 +102,7 @@
.panel {
position: fixed;
inset: auto 0 var(--mobile-nav-height) 0;
inset: auto var(--scrollbar-width, 0px) var(--mobile-nav-height) 0;
margin: 0;
padding: 0;
border: none;
@@ -176,7 +176,7 @@
.menuOverlay {
position: fixed;
inset: 0 0 var(--mobile-nav-height) 0;
inset: 0 var(--scrollbar-width, 0px) var(--mobile-nav-height) 0;
margin: 0;
padding: 0;
border: none;
@@ -282,7 +282,7 @@
text-align: center;
&:hover {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
}
@@ -380,8 +380,8 @@
right: -8px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);

View File

@@ -22,6 +22,7 @@ import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation";
import { useScrollLock } from "~/hooks/useScrollLock";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
import {
@@ -79,6 +80,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
PANEL_TYPES.map((panel) => [panel, panelDomId(uid, panel)]),
) as PanelIds;
useScrollLock(activePanel !== null);
useClosePopoversOnNavigation(rootRef);
const chatContextRef = React.useRef(chatContext);

View File

@@ -12,7 +12,7 @@
width: 100%;
height: 100%;
position: absolute;
background-color: var(--color-text-accent);
background-color: var(--color-fg-accent);
border-radius: 100%;
outline: 2px solid var(--color-bg);
}
@@ -25,7 +25,7 @@
top: -10px;
left: -10px;
border-radius: 100%;
background-color: var(--color-text-accent);
background-color: var(--color-fg-accent);
animation: pulse 2s infinite;
opacity: 0;
}

View File

@@ -167,8 +167,8 @@
margin-left: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
padding: 0 var(--s-1);
border-radius: var(--radius-selector);
height: var(--selector-size-xs);
@@ -178,7 +178,8 @@
}
.listLinkBadgeWarning {
background-color: var(--color-text-second);
color: var(--color-fg-on-second);
background-color: var(--color-fill-second);
}
.sideNavHeader {

View File

@@ -21,7 +21,7 @@
gap: var(--s-1-5);
&.active {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
}

View File

@@ -7,7 +7,12 @@ import {
SendouSelectItem,
SendouSelectItemSection,
} from "~/components/elements/Select";
import { Image, WeaponImage } from "~/components/Image";
import {
Image,
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { filterWeapon } from "~/modules/in-game-lists/utils";
@@ -20,11 +25,7 @@ import {
TRIZOOKA_ID,
weaponCategories,
} from "~/modules/in-game-lists/weapon-ids";
import {
specialWeaponImageUrl,
subWeaponImageUrl,
weaponCategoryUrl,
} from "~/utils/urls";
import { weaponCategoryUrl } from "~/utils/urls";
import styles from "./WeaponSelect.module.css";
@@ -124,14 +125,18 @@ export function WeaponSelect<
{({ key, items: weapons, name, idx }) => (
<SendouSelectItemSection
heading={name}
headingImgPath={
key === "quick-select"
? undefined
: name === "subs"
? subWeaponImageUrl(SPLAT_BOMB_ID)
: name === "specials"
? specialWeaponImageUrl(TRIZOOKA_ID)
: weaponCategoryUrl(name)
headingImg={
key === "quick-select" ? undefined : name === "subs" ? (
<SubWeaponImage subWeaponId={SPLAT_BOMB_ID} size={28} alt="" />
) : name === "specials" ? (
<SpecialWeaponImage
specialWeaponId={TRIZOOKA_ID}
size={28}
alt=""
/>
) : (
<Image path={weaponCategoryUrl(name)} size={28} alt="" />
)
}
className={idx === 0 ? "pt-0-5" : undefined}
key={key}
@@ -157,17 +162,15 @@ export function WeaponSelect<
className={styles.weaponImg}
/>
) : weapon.type === "SUB" ? (
<Image
path={subWeaponImageUrl(weapon.id)}
<SubWeaponImage
subWeaponId={weapon.id}
size={24}
alt=""
className={styles.weaponImg}
/>
) : (
<Image
path={specialWeaponImageUrl(weapon.id)}
<SpecialWeaponImage
specialWeaponId={weapon.id}
size={24}
alt=""
className={styles.weaponImg}
/>
)}

View File

@@ -3,17 +3,17 @@
width: auto;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border: var(--border-width) solid var(--color-fill-accent);
border-radius: var(--radius-field);
appearance: none;
background: var(--color-text-accent);
color: var(--color-text-inverse);
background: var(--color-fill-accent);
color: var(--color-fg-on-accent);
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);
outline-color: var(--color-fg-accent);
height: var(--field-size);
white-space: nowrap;
@@ -36,8 +36,9 @@
}
.outlined {
border-color: var(--color-fg-accent);
background-color: transparent;
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
.outlinedSuccess {
@@ -84,7 +85,7 @@
padding: 0;
border: none;
background-color: transparent;
color: var(--color-text-accent);
color: var(--color-fg-accent);
outline: initial;
&:focus-visible {
@@ -103,6 +104,7 @@
.success {
border-color: var(--color-success);
background-color: var(--color-success);
color: var(--color-text-inverse);
outline-color: var(--color-success);
}

View File

@@ -22,7 +22,7 @@
.navButton {
background-color: transparent;
color: var(--color-text-accent);
color: var(--color-fg-accent);
border: none;
padding: 0;
border-radius: 100%;
@@ -54,7 +54,7 @@
width: 35px;
height: 35px;
border-radius: var(--radius-field);
outline-color: var(--color-accent);
outline-color: var(--color-fg-accent);
border: none;
background: transparent;
color: var(--color-text);
@@ -68,7 +68,7 @@
&[data-selected] {
background-color: var(--color-bg-high);
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
&:hover {

View File

@@ -46,7 +46,7 @@
}
&:checked + .label {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
}
}

View File

@@ -3,7 +3,7 @@ import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { createMemoryRouter, RouterProvider } from "react-router";
import { afterEach, describe, expect, test, vi } from "vitest";
import { page } from "vitest/browser";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { SendouDialog } from "./Dialog";
@@ -29,13 +29,77 @@ function openDialog() {
return dialog;
}
function clickDialogAt(dialog: HTMLDialogElement, x: number, y: number) {
/** A press on the dialog element, the way one on its backdrop arrives; it starts where it ends unless `pressedAt` says otherwise. */
function clickDialogAt(
dialog: HTMLDialogElement,
x: number,
y: number,
{ pressedAt = { x, y } }: { pressedAt?: { x: number; y: number } } = {},
) {
dialog.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
clientX: pressedAt.x,
clientY: pressedAt.y,
}),
);
dialog.dispatchEvent(
new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }),
);
}
describe("SendouDialog", () => {
test("keeps a backdrop click from reaching the page underneath", async () => {
const onClose = vi.fn();
const onBehindClick = vi.fn();
await render(
withRouter(
<>
<button
type="button"
onClick={onBehindClick}
style={{ position: "fixed", top: 0, left: 0 }}
>
Behind
</button>
<SendouDialog heading="Hello" isDismissable onClose={onClose}>
Content
</SendouDialog>
</>,
),
);
await expect.element(page.getByText("Content")).toBeVisible();
// forced past the actionability check, as the backdrop covers the button;
// the press then lands on the backdrop at the button's spot
await userEvent.click(page.getByText("Behind"), { force: true });
await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce());
expect(onBehindClick).not.toHaveBeenCalled();
});
test("keeps a dismissable dialog open when a press starts inside its box and ends outside", async () => {
const onClose = vi.fn();
await render(
withRouter(
<SendouDialog heading="Hello" isDismissable onClose={onClose}>
Content
</SendouDialog>,
),
);
await expect.element(page.getByText("Content")).toBeVisible();
const dialog = openDialog();
const rect = dialog.getBoundingClientRect();
clickDialogAt(dialog, rect.right + 5, rect.bottom + 5, {
pressedAt: { x: rect.left + 1, y: rect.top + 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onClose).not.toHaveBeenCalled();
expect(dialog.open).toBe(true);
});
test("closes a dismissable dialog on a backdrop click", async () => {
const onClose = vi.fn();
await render(
@@ -120,6 +184,83 @@ describe("SendouDialog", () => {
expect(openDialog().open).toBe(true);
});
test("locks page scrolling while open without moving the page content", async () => {
const content = document.createElement("div");
content.style.height = "300vh";
content.style.width = "100%";
document.body.appendChild(content);
cleanupFns.push(() => content.remove());
const screen = await render(
withRouter(
<SendouDialog
heading="Hello"
trigger={<button type="button">Open</button>}
showCloseButton
>
Content
</SendouDialog>,
),
);
// after the render, whose container shares the body's flex row with the probe
const widthBefore = content.getBoundingClientRect().width;
await screen.getByRole("button", { name: "Open" }).click();
await expect.element(screen.getByText("Content")).toBeVisible();
await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden"));
expect(content.getBoundingClientRect().width).toBe(widthBefore);
await screen.getByRole("button", { name: "Close" }).click();
await vi.waitFor(() => expect(document.body.style.overflow).toBe(""));
expect(document.body.style.paddingRight).toBe("");
expect(content.getBoundingClientRect().width).toBe(widthBefore);
});
test("releases the scroll lock when an open dialog unmounts", async () => {
const screen = await render(
withRouter(
<SendouDialog heading="Hello" onClose={() => {}}>
Content
</SendouDialog>,
),
);
await expect.element(screen.getByText("Content")).toBeVisible();
await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden"));
await screen.unmount();
await vi.waitFor(() => expect(document.body.style.overflow).toBe(""));
});
test("lets tall content take the visible height less the mobile margins", async () => {
const { innerWidth, innerHeight } = window;
await page.viewport(375, 667);
try {
await render(
withRouter(
<SendouDialog heading="Hello" onClose={() => {}}>
<div style={{ height: 2000 }} />
</SendouDialog>,
),
);
await expect.element(page.getByRole("dialog")).toBeVisible();
const dialog = openDialog();
// the open animation scales the box, and the rect includes transforms
await Promise.all(
dialog.getAnimations().map((animation) => animation.finished),
);
const rect = dialog.getBoundingClientRect();
const maxHeight = Number.parseFloat(getComputedStyle(dialog).maxHeight);
expect(maxHeight).toBeGreaterThan(window.innerHeight * 0.9);
expect(rect.height).toBeCloseTo(maxHeight, 0);
expect(rect.top).toBeCloseTo((window.innerHeight - rect.height) / 2, 0);
} finally {
await page.viewport(innerWidth, innerHeight);
}
});
test("focuses the dialog itself instead of the close button on open", async () => {
await render(
withRouter(

View File

@@ -1,7 +1,19 @@
.modal {
--dialog-padding-block: var(--s-6);
width: calc(100% - 2rem);
max-width: 28rem;
max-height: min(80dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem));
inset-block-start: var(--visual-viewport-offset-top, 0px);
inset-block-end: calc(
100% -
var(--visual-viewport-offset-top, 0px) -
var(--visual-viewport-height, 100%)
);
max-height: calc(
var(--visual-viewport-height, 100dvh) -
2 *
var(--modal-margin-block)
);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
@@ -9,7 +21,7 @@
border: 1px solid var(--color-border);
background-color: var(--color-bg);
color: var(--color-text);
padding: var(--s-6);
padding: var(--dialog-padding-block) var(--s-6);
text-align: left;
vertical-align: middle;
box-shadow:
@@ -18,6 +30,10 @@
margin: auto;
outline: none;
animation: zoom-in-95 300ms ease-out;
@media (width < 600px) {
--dialog-padding-block: var(--s-4);
}
}
.blurredBackdrop::backdrop {
@@ -40,6 +56,7 @@
min-width: 100%;
height: 100dvh;
max-height: 100dvh;
inset-block: 0;
border-radius: 0;
margin: 0;
padding-block-start: calc(env(safe-area-inset-top) + var(--s-6));
@@ -67,7 +84,7 @@
}
.noHeading {
margin-block-start: -14px;
margin-block-start: calc(10px - var(--dialog-padding-block));
}
.heading {

View File

@@ -8,6 +8,7 @@ import {
type SendouButtonProps,
} from "~/components/elements/Button";
import { useHydrated } from "~/hooks/useHydrated";
import { useScrollLockWhileOpen } from "~/hooks/useScrollLock";
import {
useReportModalOpen,
useTopLayerViewTransitionStyle,
@@ -73,10 +74,20 @@ function DialogElement({
ref,
}: DialogElementProps) {
const topLayerStyle = useTopLayerViewTransitionStyle();
const dialogRef = React.useRef<HTMLDialogElement>(null);
const backdropPressHandlers = useBackdropDismiss(isDismissable);
useScrollLockWhileOpen(dialogRef);
return (
<dialog
ref={ref}
ref={(dialog) => {
dialogRef.current = dialog;
if (typeof ref === "function") {
ref(dialog);
} else if (ref) {
ref.current = dialog;
}
}}
id={id}
style={topLayerStyle}
className={clsx(className, {
@@ -85,27 +96,44 @@ function DialogElement({
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
tabIndex={-1}
closedby={isDismissable ? "any" : "closerequest"}
closedby="closerequest"
onClose={onClose}
onClick={isDismissable ? closeOnBackdropClick : undefined}
{...backdropPressHandlers}
>
{children}
</dialog>
);
}
// Safari 26 is missing `closedby`, close on backdrop clicks manually
function closeOnBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
if (event.target !== event.currentTarget) return;
// Native `closedby` closes on pointer up so the click can land on stuff like buttons underneath the backdrop
// We just roll our own click handler here because that can't "leak" through
function useBackdropDismiss(enabled: boolean | undefined) {
const pressStartedOnBackdropRef = React.useRef(false);
if (!enabled) return {};
return {
onPointerDown: (event: React.PointerEvent<HTMLDialogElement>) => {
pressStartedOnBackdropRef.current = isOnBackdrop(event);
},
onClick: (event: React.MouseEvent<HTMLDialogElement>) => {
if (pressStartedOnBackdropRef.current && isOnBackdrop(event)) {
event.currentTarget.close();
}
},
};
}
function isOnBackdrop(event: React.MouseEvent<HTMLDialogElement>) {
if (event.target !== event.currentTarget) return false;
const rect = event.currentTarget.getBoundingClientRect();
const outside =
return (
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom;
if (outside) {
event.currentTarget.close();
}
event.clientY > rect.bottom
);
}
/** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */

View File

@@ -1,38 +1,24 @@
.triggerContainer {
display: contents;
> * {
anchor-name: var(--menu-anchor);
}
}
.popover {
position: fixed;
position-area: block-end span-inline-end;
margin: var(--s-2) 0;
position: absolute;
margin: 0;
outline: none;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
border: var(--border-style);
width: max-content;
max-width: calc(100vw - var(--s-4));
max-width: var(--floating-available-width, 100vw);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-2);
color: var(--color-text);
&[data-placement="bottom end"],
&[data-placement="bottom right"] {
position-area: block-end span-inline-start;
}
}
.opensLeft {
position-area: block-end span-inline-start;
}
.scrolling {
max-height: 300px !important;
max-height: min(300px, var(--floating-available-height, 100vh));
overflow-y: auto;
}
@@ -76,7 +62,7 @@
}
.itemActive {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
.itemDestructive {

View File

@@ -8,15 +8,14 @@ import {
} from "~/utils/roving-focus";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import styles from "./Menu.module.css";
import {
focusLeftTo,
isOwnToggle,
useAnchorSafeId,
usePopoverTargetOnceHydrated,
useShowPopoverOnOpen,
} from "./Popover";
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
import { useFloatingLayer } from "./useFloatingLayer";
type MenuPlacement = "bottom start" | "bottom end" | "bottom right";
@@ -27,7 +26,7 @@ interface SendouMenuProps {
children: React.ReactNode;
popoverClassName?: string;
placement?: MenuPlacement;
/** Render the items while closed too, so the menu works before hydration (and without JavaScript). */
/** Render the items while closed too, so they are in the server markup and ready the moment the menu opens. */
eager?: boolean;
}
@@ -44,10 +43,9 @@ export function SendouMenu({
popoverClassName,
eager,
}: SendouMenuProps) {
const uid = useAnchorSafeId();
const popoverId = `${React.useId()}-menu`;
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
const topLayerStyle = useTopLayerViewTransitionStyle();
const popoverId = `${uid}-menu`;
const anchorName = `--menu-anchor-${uid}`;
const [open, setOpen] = React.useState(false);
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -63,12 +61,10 @@ export function SendouMenu({
open,
onOpen: () => setOpen(true),
});
useCloseOnScrollClip(open, popoverRef, () =>
popoverRef.current?.hidePopover(),
);
useAnchorPositioning({
useFloatingLayer({
isOpen: open,
popoverRef,
floatingRef: popoverRef,
getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null,
placement:
opensLeft || (placement && placement !== "bottom start")
@@ -115,11 +111,10 @@ export function SendouMenu({
<span
ref={triggerContainerRef}
className={styles.triggerContainer}
style={{ "--menu-anchor": anchorName } as React.CSSProperties}
onBlur={onBlur}
>
{React.cloneElement(trigger, {
popoverTarget: popoverId,
popoverTarget,
"aria-expanded": open,
"aria-haspopup": "menu",
})}
@@ -132,15 +127,8 @@ export function SendouMenu({
tabIndex={-1}
className={clsx(styles.popover, "scrollbar", popoverClassName, {
[styles.scrolling]: scrolling,
[styles.opensLeft]: opensLeft,
})}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
data-placement={placement}
style={topLayerStyle}
onBeforeToggle={onBeforeToggle}
onToggle={onToggle}
onKeyDown={onKeyDown}

View File

@@ -1,6 +1,6 @@
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { SendouPopover } from "./Popover";
@@ -93,4 +93,32 @@ describe("SendouPopover", () => {
await expect.element(page.getByText("Popover content")).toBeVisible();
expect(popover.matches(":popover-open")).toBe(true);
});
test("does not open before hydration, when nothing could place it", async () => {
const app = (
<SendouPopover trigger={<button type="button">Open</button>}>
Popover content
</SendouPopover>
);
const container = document.createElement("div");
container.innerHTML = renderToString(app);
document.body.appendChild(container);
cleanupFns.push(() => container.remove());
const trigger = container.querySelector("button");
const popover = container.querySelector<HTMLElement>("[popover]");
if (!trigger || !popover) throw new Error("no popover rendered");
trigger.click();
expect(popover.matches(":popover-open")).toBe(false);
const root = hydrateRoot(container, app);
cleanupFns.push(() => root.unmount());
await vi.waitFor(() =>
expect(trigger.getAttribute("popovertarget")).toBe(popover.id),
);
trigger.click();
await expect.element(page.getByText("Popover content")).toBeVisible();
});
});

View File

@@ -1,17 +1,12 @@
.triggerContainer {
display: contents;
> * {
anchor-name: var(--popover-anchor);
}
}
.content {
position: fixed;
position-area: block-end;
justify-self: anchor-center;
margin: var(--s-2) 0;
max-width: min(20rem, calc(100vw - var(--s-4)));
position: absolute;
margin: 0;
max-width: min(20rem, var(--floating-available-width, 100vw));
max-height: var(--floating-available-height, none);
overflow: auto;
padding: var(--s-2);
border: var(--border-style);
@@ -22,24 +17,4 @@
background-color: var(--color-bg);
color: var(--color-text);
outline: none;
&[data-placement="top"] {
position-area: block-start;
}
&[data-placement="bottom start"] {
position-area: block-end span-inline-end;
justify-self: unset;
}
&[data-placement="bottom end"] {
position-area: block-end span-inline-start;
justify-self: unset;
}
&[data-placement="right"] {
position-area: inline-end;
justify-self: unset;
align-self: anchor-center;
}
}

View File

@@ -1,21 +1,14 @@
import clsx from "clsx";
import * as React from "react";
import { flushSync } from "react-dom";
import { useHydrated } from "~/hooks/useHydrated";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
import {
type AnchorPlacement,
useAnchorPositioning,
} from "./anchor-positioning";
import styles from "./Popover.module.css";
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
import { type FloatingPlacement, useFloatingLayer } from "./useFloatingLayer";
import { useScrollIntoView } from "./useScrollIntoView";
export type PopoverPlacement = AnchorPlacement;
/** `useId` values hold characters CSS idents can't (e.g. `:`), strip them for anchor names. */
export function useAnchorSafeId() {
return React.useId().replace(/[^a-zA-Z0-9-]/g, "");
}
export type PopoverPlacement = FloatingPlacement;
/**
* `toggle` does not bubble natively but React propagates it anyway, so an
@@ -26,6 +19,10 @@ export function isOwnToggle(event: React.ToggleEvent<HTMLElement>) {
return event.target === event.currentTarget;
}
export function usePopoverTargetOnceHydrated(popoverId: string) {
return useHydrated() ? popoverId : undefined;
}
/**
* Shows a popover once React has committed `open`, so content mounted only
* while open is in the popover's first painted frame instead of appearing a
@@ -33,7 +30,7 @@ export function isOwnToggle(event: React.ToggleEvent<HTMLElement>) {
* browser's own open (the trigger's `popoverTarget`) is cancelled there and
* redone through `onOpen` in the next frame, still before it paints, as a
* popover cannot be shown from inside the show operation being cancelled.
* Call it before `useAnchorPositioning` so the popover is showing by the time
* Call it before `useFloatingLayer` so the popover is showing by the time
* that measures it.
*/
export function useShowPopoverOnOpen({
@@ -86,10 +83,10 @@ export function focusLeftTo(
/**
* Popover opened by `trigger` (a SendouButton); controlled or uncontrolled. Renders through the
* native popover API with CSS anchor positioning.
* native popover API, placed next to the trigger by `useFloatingLayer`.
*
* With `eager` the content is rendered while closed too, so the popover opens with its content
* before hydration (and without JavaScript altogether).
* With `eager` the content is rendered while closed too, so it is in the server markup and there
* is nothing left to mount when the popover opens.
*/
export function SendouPopover({
children,
@@ -108,9 +105,8 @@ export function SendouPopover({
isOpen?: boolean;
eager?: boolean;
}) {
const uid = useAnchorSafeId();
const popoverId = `${uid}-popover`;
const anchorName = `--popover-anchor-${uid}`;
const popoverId = `${React.useId()}-popover`;
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
const [isControlled] = React.useState(isOpen !== undefined);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
@@ -153,13 +149,15 @@ export function SendouPopover({
open,
onOpen: () => setOpen(true),
});
useCloseOnScrollClip(open, popoverRef, () => setOpen(false));
useAnchorPositioning({
useScrollIntoView(
open,
() => triggerContainerRef.current?.firstElementChild ?? null,
);
useFloatingLayer({
isOpen: open,
popoverRef,
floatingRef: popoverRef,
getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null,
placement,
constrainHeight: true,
});
const onToggle = (event: React.ToggleEvent<HTMLDivElement>) => {
@@ -189,11 +187,10 @@ export function SendouPopover({
<span
ref={triggerContainerRef}
className={styles.triggerContainer}
style={{ "--popover-anchor": anchorName } as React.CSSProperties}
onBlur={onBlur}
>
{React.cloneElement(trigger, {
popoverTarget: popoverId,
popoverTarget,
"aria-haspopup": "dialog",
})}
</span>
@@ -202,15 +199,9 @@ export function SendouPopover({
id={popoverId}
popover="auto"
className={clsx(styles.content, popoverClassName)}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
style={topLayerStyle}
role="dialog"
tabIndex={-1}
data-placement={placement}
onBeforeToggle={onBeforeToggle}
onToggle={onToggle}
onBlur={onBlur}
@@ -235,38 +226,28 @@ export function SendouAnchoredPopover({
triggerRef: React.RefObject<HTMLElement | null>;
"aria-label"?: string;
}) {
const uid = useAnchorSafeId();
const anchorName = `--popover-anchor-${uid}`;
const popoverRef = React.useRef<HTMLDivElement>(null);
const topLayerStyle = useTopLayerViewTransitionStyle();
// before the positioning effect, so the content is placed by its first paint
useIsomorphicLayoutEffect(() => {
const trigger = triggerRef.current;
const popover = popoverRef.current;
if (!popover) return;
if (isOpen) {
trigger?.style.setProperty("anchor-name", anchorName);
if (!popover.matches(":popover-open")) {
popover.showPopover();
}
} else if (popover.matches(":popover-open")) {
popover.hidePopover();
}
}, [isOpen]);
return () => {
trigger?.style.removeProperty("anchor-name");
};
}, [isOpen, triggerRef, anchorName]);
useCloseOnScrollClip(isOpen, popoverRef, () => onOpenChange(false));
useAnchorPositioning({
useScrollIntoView(isOpen, () => triggerRef.current);
useFloatingLayer({
isOpen,
popoverRef,
floatingRef: popoverRef,
getAnchor: () => triggerRef.current,
constrainHeight: true,
});
const onToggle = (event: React.ToggleEvent<HTMLDivElement>) => {
@@ -286,9 +267,7 @@ export function SendouAnchoredPopover({
ref={popoverRef}
popover="auto"
className={styles.content}
style={
{ positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties
}
style={topLayerStyle}
role="dialog"
tabIndex={-1}
aria-label={ariaLabel}

View File

@@ -72,17 +72,11 @@
}
.popover {
position: fixed;
position-area: block-end;
width: anchor-size(width);
min-width: anchor-size(width);
/* fills the space the position-area leaves (its containing block), less the
gap to the anchor (the popover's own margin) and the padding to the
viewport edge; which side that space is on gets pinned when the popover
opens, which also sets the cap in pixels as WebKit does not resolve this
percentage */
max-height: calc(100% - var(--s-2) - 12px);
margin: var(--s-2) 0;
position: absolute;
margin: 0;
width: var(--floating-anchor-width);
min-width: var(--floating-anchor-width);
max-height: var(--floating-available-height, none);
padding: var(--s-1);
border: var(--border-style);
border-radius: var(--radius-box);
@@ -96,11 +90,6 @@
&:popover-open {
display: flex;
}
/* opened upwards the sticky header is the ceiling, not the viewport top */
&[data-side="above"] {
max-height: calc(100% - var(--s-2) - 12px - var(--popover-boundary-top));
}
}
.listBox {
@@ -140,7 +129,7 @@
}
.itemSelected {
color: var(--color-text-accent);
color: var(--color-fg-accent);
font-weight: var(--weight-bold);
}
@@ -151,7 +140,7 @@
border-bottom: 1px solid var(--color-border);
border-radius: 0;
accent-color: var(--color-accent);
accent-color: var(--color-fg-accent);
color: var(--color-text);
outline: none;
padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2))

View File

@@ -7,16 +7,15 @@ import { SendouButton } from "~/components/elements/Button";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import {
focusLeftTo,
isOwnToggle,
useAnchorSafeId,
usePopoverTargetOnceHydrated,
useShowPopoverOnOpen,
} from "./Popover";
import styles from "./Select.module.css";
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
import { useFloatingLayer } from "./useFloatingLayer";
import { useScrollIntoView } from "./useScrollIntoView";
export type SelectKey = string | number;
@@ -96,7 +95,7 @@ export interface SendouSelectProps<T extends object> {
/**
* A customizable select component with optional search functionality,
* rendered through the native popover API with CSS anchor positioning.
* rendered through the native popover API and placed by `useFloatingLayer`.
*
* Options mount only while the popover is open (plus the selected one, so the
* trigger can show it); the trigger's content is read straight from the
@@ -144,11 +143,11 @@ export function SendouSelect<T extends object>({
children,
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
const uid = useAnchorSafeId();
const uid = React.useId();
const topLayerStyle = useTopLayerViewTransitionStyle();
const popoverId = `${uid}-select-popover`;
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
const listboxId = `${uid}-select-listbox`;
const anchorName = `--select-anchor-${uid}`;
const labelId = label ? `${uid}-select-label` : undefined;
const valueId = `${uid}-select-value`;
const triggerId = `${uid}-select-trigger`;
@@ -203,13 +202,11 @@ export function SendouSelect<T extends object>({
open,
onOpen: () => setOpen(true),
});
useCloseOnScrollClip(open, popoverRef, () => setOpen(false));
useAnchorPositioning({
useScrollIntoView(open, () => triggerElementRef.current);
useFloatingLayer({
isOpen: open,
popoverRef,
floatingRef: popoverRef,
getAnchor: () => triggerElementRef.current,
matchAnchorWidth: true,
constrainHeight: true,
});
// after positioning, so the selection scrolls into the space the list ends up with
useIsomorphicLayoutEffect(() => {
@@ -539,8 +536,7 @@ export function SendouSelect<T extends object>({
: undefined
}
data-required={isRequired || undefined}
popoverTarget={popoverId}
style={{ anchorName } as React.CSSProperties}
popoverTarget={popoverTarget}
onKeyDown={onTriggerKeyDown}
>
<span
@@ -575,12 +571,7 @@ export function SendouSelect<T extends object>({
id={popoverId}
popover="auto"
className={clsx(styles.popover, popoverClassName)}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
style={topLayerStyle}
onBeforeToggle={onPopoverBeforeToggle}
onToggle={onPopoverToggle}
onKeyDown={onPopoverKeyDown}
@@ -909,14 +900,14 @@ function SelectOption(props: SendouSelectItemProps) {
interface SendouSelectItemSectionProps {
heading: string;
headingImgPath?: string;
headingImg?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function SendouSelectItemSection({
heading,
headingImgPath,
headingImg,
children,
className,
}: SendouSelectItemSectionProps) {
@@ -930,9 +921,7 @@ export function SendouSelectItemSection({
// biome-ignore lint/a11y/useSemanticElements: a fieldset would carry form semantics this listbox section does not have
<div role="group" aria-label={heading}>
<div className={clsx(className, styles.categoryHeading)}>
{headingImgPath ? (
<Image path={headingImgPath} size={28} alt="" />
) : null}
{headingImg}
{heading}
<div className={styles.categoryDivider} />
</div>

View File

@@ -36,12 +36,12 @@
margin-block-end: 0;
&:has(> .input:checked) .indicator {
background: var(--color-text-accent);
border-color: var(--color-text-accent);
background: var(--color-fill-accent);
border-color: var(--color-fill-accent);
grid-template-columns: 1fr 1fr 0fr;
&:before {
background: var(--color-text-inverse);
background: var(--color-fg-on-accent);
}
}

View File

@@ -88,12 +88,12 @@
}
&[data-selected] .tabButton {
border-color: var(--color-text-accent);
border-color: var(--color-fg-accent);
color: var(--color-text);
}
&:focus-visible .tabButton {
color: var(--color-text-accent) !important;
color: var(--color-fg-accent) !important;
outline: none;
}
}
@@ -141,7 +141,7 @@
}
.tabNumber {
color: var(--color-text-accent);
color: var(--color-fg-accent);
margin-inline-start: var(--s-2);
}
@@ -186,7 +186,7 @@
&[data-selected] .tabButton {
border-bottom-color: transparent;
border-inline-end-color: var(--color-text-accent);
border-inline-end-color: var(--color-fg-accent);
}
}

View File

@@ -4,7 +4,7 @@
position: fixed;
inset: unset;
top: calc(var(--layout-nav-height) + var(--s-2));
right: 10px;
right: calc(10px + var(--scrollbar-width, 0px));
margin: 0;
padding: 0;
border: none;

View File

@@ -1,190 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { SendouPopover } from "./Popover";
import { SendouSelect, SendouSelectItem } from "./Select";
const SEASONS = [{ id: 1, name: "Season 1" }];
const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({
id: index + 1,
name: `Season ${index + 1}`,
}));
let disablingStyle: HTMLStyleElement | null = null;
afterEach(() => {
disablingStyle?.remove();
disablingStyle = null;
vi.restoreAllMocks();
});
/** The test browser has anchor positioning, so the fallback has to be forced on. */
function disableAnchorPositioning() {
vi.spyOn(CSS, "supports").mockReturnValue(false);
disablingStyle = document.createElement("style");
disablingStyle.textContent = `[popover] {
position-area: none !important;
justify-self: normal !important;
align-self: normal !important;
}`;
document.head.append(disablingStyle);
}
function rectOf(element: Element) {
return element.getBoundingClientRect();
}
describe("useAnchorPositioning", () => {
test("centers the popover under its trigger", async () => {
disableAnchorPositioning();
const screen = await render(
<div style={{ padding: "100px" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
const trigger = screen.getByRole("button", { name: "Filters" });
await trigger.click();
const triggerRect = rectOf(trigger.element());
const popoverRect = rectOf(screen.getByRole("dialog").element());
expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom);
expect(
Math.abs(
popoverRect.left +
popoverRect.width / 2 -
(triggerRect.left + triggerRect.width / 2),
),
).toBeLessThan(2);
});
test("gives the select popover the width of its trigger", async () => {
disableAnchorPositioning();
const screen = await render(
<div style={{ padding: "100px" }}>
<SendouSelect
label="Season"
items={SEASONS}
placeholder="Pick a season"
>
{({ id, name }: (typeof SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1" }))
.toBeVisible();
const triggerRect = rectOf(trigger.element());
const popover = document.querySelector("[popover]");
const popoverRect = rectOf(popover as Element);
expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0);
expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0);
expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom);
});
test("opens a select upwards when its options do not fit below the trigger", async () => {
const screen = await render(<SelectNearViewportBottom />);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const triggerRect = rectOf(trigger.element());
const popoverRect = rectOf(document.querySelector("[popover]") as Element);
expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top);
expect(popoverRect.top).toBeGreaterThanOrEqual(0);
});
test("caps a long select to the space below its trigger", async () => {
const screen = await render(
<div style={{ padding: "100px" }}>
<SendouSelect
label="Season"
items={MANY_SEASONS}
placeholder="Pick a season"
>
{({ id, name }: (typeof MANY_SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = document.querySelector("[popover]") as HTMLElement;
const listbox = screen.getByRole("listbox").element();
const spaceBelow =
window.innerHeight -
rectOf(trigger.element()).bottom -
Number.parseFloat(getComputedStyle(popover).marginTop) -
12;
// an explicit cap, as WebKit does not resolve the percentage one in the CSS
expect(Number.parseFloat(popover.style.maxHeight)).toBeCloseTo(
spaceBelow,
0,
);
expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight);
expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight);
});
test("keeps the select where it opened when searching shrinks the list", async () => {
const screen = await render(<SelectNearViewportBottom />);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = document.querySelector("[popover]") as Element;
const bottomOnOpen = rectOf(popover).bottom;
await screen.getByRole("combobox").fill("Season 40");
await expect
.element(screen.getByRole("option", { name: "Season 40" }))
.toBeVisible();
expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0);
});
});
function SelectNearViewportBottom() {
return (
<div style={{ marginTop: "calc(100vh - 100px)" }}>
<SendouSelect
label="Season"
items={MANY_SEASONS}
placeholder="Pick a season"
search={{ placeholder: "Search seasons..." }}
>
{({ id, name }: (typeof MANY_SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>
);
}

View File

@@ -1,334 +0,0 @@
import * as React from "react";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
const VIEWPORT_PADDING = 12;
const POSITION_PROPERTIES = [
"top",
"right",
"bottom",
"left",
"max-height",
"width",
];
/** Mirrors the `position-area` values the popovers declare in their CSS. */
export type AnchorPlacement =
| "top"
| "bottom"
| "right"
| "bottom start"
| "bottom end";
/** The `position-area` of the side each placement asks for, and of the opposite one. */
const POSITION_AREAS: Record<
AnchorPlacement,
{ preferred: string; flipped: string }
> = {
top: { preferred: "block-start", flipped: "block-end" },
bottom: { preferred: "block-end", flipped: "block-start" },
"bottom start": {
preferred: "block-end span-inline-end",
flipped: "block-start span-inline-end",
},
"bottom end": {
preferred: "block-end span-inline-start",
flipped: "block-start span-inline-start",
},
right: { preferred: "inline-end", flipped: "inline-start" },
};
/**
* Opens a popover on the side of its anchor that fits its content, keeping it
* there for as long as it stays open.
*
* Where CSS anchor positioning is supported it only pins the `position-area`
* and, with `constrainHeight`, caps the height in pixels (WebKit resolves a
* percentage `max-height` of an anchor-positioned box against nothing, so a
* long list would run past the viewport with nothing to scroll). The CSS
* handles the rest. `position-try-fallbacks` is deliberately not used:
* it flips only when a side overflows, so a popover capped to the space it has
* never flips, and on iOS 26 a popover carrying it locks up the page for good
* when it leaves the top layer during a navigation. Presumably an iOS 26 WebKit
* bug, so a pure CSS solution is worth retrying once it is fixed upstream, but
* verify it in the iOS simulator before deploying. Browsers without anchor
* positioning (Chrome < 125, Safari < 26, Firefox < 147), where the popover
* would land in the top left corner of the viewport, get positioned here in full.
*/
export function useAnchorPositioning({
isOpen,
popoverRef,
getAnchor,
placement = "bottom",
matchAnchorWidth = false,
constrainHeight = false,
}: {
isOpen: boolean;
popoverRef: React.RefObject<HTMLElement | null>;
getAnchor: () => Element | null;
placement?: AnchorPlacement;
/** Take the anchor's width, like the CSS `width: anchor-size(width)` does. */
matchAnchorWidth?: boolean;
/** Cap the height to the space on the chosen side. Only for popovers that scroll their content. */
constrainHeight?: boolean;
}) {
const getAnchorRef = React.useRef(getAnchor);
getAnchorRef.current = getAnchor;
useIsomorphicLayoutEffect(() => {
const popover = popoverRef.current;
if (!isOpen || !popover) return;
const anchorPositioned = CSS.supports("anchor-name: --a");
/** Picked on the first measurement, so growing or shrinking content cannot move the popover. */
let fitsPreferred: boolean | null = null;
const position = () => {
const anchor = getAnchorRef.current();
// a popover shown after this effect (a controlled one) measures as hidden
if (!anchor || !popover.matches(":popover-open")) return;
fitsPreferred ??= preferredSideFits(popover, anchor, placement);
const below = placement === "top" ? !fitsPreferred : fitsPreferred;
if (placement !== "right") {
popover.dataset.side = below ? "below" : "above";
}
if (anchorPositioned) {
const area = POSITION_AREAS[placement];
popover.style.setProperty(
"position-area",
fitsPreferred ? area.preferred : area.flipped,
);
if (constrainHeight) {
popover.style.setProperty(
"max-height",
px(availableHeight(popover, anchor, placement, below)),
);
}
return;
}
applyStyles(
popover,
positionStyles(popover, anchor, {
below,
placement,
matchAnchorWidth,
constrainHeight,
}),
);
};
position();
popover.addEventListener("toggle", position);
let contentObserver: ResizeObserver | undefined;
if (!anchorPositioned) {
// the popover is fixed, so it has to follow an anchor moved by scrolling
window.addEventListener("scroll", position, {
capture: true,
passive: true,
});
window.addEventListener("resize", position);
contentObserver = new ResizeObserver(position);
contentObserver.observe(popover);
}
return () => {
popover.removeEventListener("toggle", position);
window.removeEventListener("scroll", position, { capture: true });
window.removeEventListener("resize", position);
contentObserver?.disconnect();
delete popover.dataset.side;
popover.style.removeProperty("position-area");
applyStyles(popover, {});
};
}, [isOpen, popoverRef, placement, matchAnchorWidth, constrainHeight]);
}
/** Whether to keep to the side the placement asks for; the roomier one is taken when the content does not fit there. */
function preferredSideFits(
popover: HTMLElement,
anchor: Element,
placement: AnchorPlacement,
) {
const anchorRect = anchor.getBoundingClientRect();
const computed = getComputedStyle(popover);
if (placement === "right") {
const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING;
const spaceInlineEnd =
window.innerWidth - anchorRect.right - VIEWPORT_PADDING;
const [preferred, other] =
computed.direction === "rtl"
? [spaceInlineStart, spaceInlineEnd]
: [spaceInlineEnd, spaceInlineStart];
const width = popover.getBoundingClientRect().width;
return width <= preferred || preferred >= other;
}
const { above, below } = spaceAroundAnchor(anchorRect, computed);
const [preferred, other] =
placement === "top" ? [above, below] : [below, above];
return naturalHeight(popover) <= preferred || preferred >= other;
}
/** The height the popover may take on the side it opened to. */
function availableHeight(
popover: HTMLElement,
anchor: Element,
placement: AnchorPlacement,
below: boolean,
) {
if (placement === "right") {
return Math.max(0, window.innerHeight - 2 * VIEWPORT_PADDING);
}
const space = spaceAroundAnchor(
anchor.getBoundingClientRect(),
getComputedStyle(popover),
);
return Math.max(0, below ? space.below : space.above);
}
/**
* Height each side of the anchor has for the popover, its margin and the
* viewport padding taken out. Above the anchor the sticky header
* (`--popover-boundary-top`) is the ceiling, not the top of the viewport.
*/
function spaceAroundAnchor(anchorRect: DOMRect, computed: CSSStyleDeclaration) {
return {
above:
anchorRect.top -
(Number.parseFloat(computed.getPropertyValue("--popover-boundary-top")) ||
0) -
VIEWPORT_PADDING -
Number.parseFloat(computed.marginBottom),
below:
window.innerHeight -
anchorRect.bottom -
VIEWPORT_PADDING -
Number.parseFloat(computed.marginTop),
};
}
/** The height the content wants, which the `max-height` capping it to one side's space hides. */
function naturalHeight(popover: HTMLElement) {
const capped = popover.style.maxHeight;
popover.style.setProperty("max-height", "none");
const height = popover.getBoundingClientRect().height;
if (capped) {
popover.style.setProperty("max-height", capped);
} else {
popover.style.removeProperty("max-height");
}
return height;
}
function positionStyles(
popover: HTMLElement,
anchor: Element,
{
below,
placement,
matchAnchorWidth,
constrainHeight,
}: {
below: boolean;
placement: AnchorPlacement;
matchAnchorWidth: boolean;
constrainHeight: boolean;
},
) {
const anchorRect = anchor.getBoundingClientRect();
const popoverRect = popover.getBoundingClientRect();
const computed = getComputedStyle(popover);
const isRtl = computed.direction === "rtl";
// the margins of the popover offset it from the inset it is given, which is
// the gap to the anchor in the block axis and drift to undo everywhere else
const marginTop = Number.parseFloat(computed.marginTop);
const marginLeft = Number.parseFloat(computed.marginLeft);
const width = matchAnchorWidth ? anchorRect.width : popoverRect.width;
const styles: Record<string, string> = matchAnchorWidth
? { width: px(anchorRect.width) }
: {};
if (placement === "right") {
// inline-end of the anchor, flipping over it like `flip-inline` does
const height = Math.max(popoverRect.height, popover.scrollHeight);
const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING;
const spaceInlineEnd =
window.innerWidth - anchorRect.right - VIEWPORT_PADDING;
const [preferred, other] = isRtl
? [spaceInlineStart, spaceInlineEnd]
: [spaceInlineEnd, spaceInlineStart];
const towardsInlineEnd = width <= preferred || preferred >= other;
return {
...styles,
top: px(anchorRect.top + anchorRect.height / 2 - height / 2 - marginTop),
bottom: "auto",
...horizontalPlacement(
towardsInlineEnd !== isRtl ? anchorRect.right : anchorRect.left - width,
width,
marginLeft,
),
};
}
const alignedToAnchorLeft =
placement === "bottom start"
? !isRtl
: placement === "bottom end"
? isRtl
: null;
const left =
alignedToAnchorLeft === null
? anchorRect.left + anchorRect.width / 2 - width / 2
: alignedToAnchorLeft
? anchorRect.left
: anchorRect.right - width;
return {
...styles,
...(below
? { top: px(anchorRect.bottom), bottom: "auto" }
: { top: "auto", bottom: px(window.innerHeight - anchorRect.top) }),
...(constrainHeight
? { "max-height": px(availableHeight(popover, anchor, placement, below)) }
: {}),
...horizontalPlacement(left, width, marginLeft),
};
}
function horizontalPlacement(left: number, width: number, marginLeft: number) {
const rightmost = Math.max(
VIEWPORT_PADDING,
window.innerWidth - VIEWPORT_PADDING - width,
);
return {
left: px(
Math.min(Math.max(left, VIEWPORT_PADDING), rightmost) - marginLeft,
),
right: "auto",
};
}
/** Writes the positioning properties, clearing the ones the placement leaves out. */
function applyStyles(popover: HTMLElement, styles: Record<string, string>) {
for (const property of POSITION_PROPERTIES) {
const value = styles[property];
if (value === undefined) {
popover.style.removeProperty(property);
} else if (popover.style.getPropertyValue(property) !== value) {
popover.style.setProperty(property, value);
}
}
}
function px(value: number) {
return `${Math.round(value)}px`;
}

View File

@@ -0,0 +1,284 @@
import { describe, expect, test } from "vitest";
import * as FloatingLayer from "./floating-layer";
const BOUNDS: FloatingLayer.Bounds = {
top: 0,
right: 1000,
bottom: 800,
left: 0,
};
const VIEWPORT = { width: 1000, height: 800 };
const GAP = 8;
const PADDING = 12;
function anchorAt(top: number, left = 100): FloatingLayer.Rect {
return { top, left, width: 200, height: 40 };
}
describe("FloatingLayer.resolve", () => {
test.each([
{
why: "stays below when the content fits there",
anchorTop: 100,
height: 300,
side: "bottom",
availableHeight: 640,
},
{
why: "flips above when the content does not fit below but has more room there",
anchorTop: 700,
height: 300,
side: "top",
availableHeight: 680,
},
{
why: "takes the roomier side when the content fits on neither",
anchorTop: 700,
height: 900,
side: "top",
availableHeight: 680,
},
{
why: "keeps the asked side when it is the roomier one though the content fits on neither",
anchorTop: 100,
height: 900,
side: "bottom",
availableHeight: 640,
},
])("$why", ({ anchorTop, height, side, availableHeight }) => {
const resolution = FloatingLayer.resolve({
anchor: anchorAt(anchorTop),
floating: { width: 200, height },
bounds: BOUNDS,
placement: "bottom",
gap: GAP,
padding: PADDING,
rtl: false,
});
expect(resolution.side).toBe(side);
expect(resolution.availableHeight).toBe(availableHeight);
expect(resolution.availableWidth).toBe(976);
});
test("keeps a sticky header at the top of the bounds out of the room above", () => {
const resolution = FloatingLayer.resolve({
anchor: anchorAt(300),
floating: { width: 200, height: 250 },
bounds: { ...BOUNDS, top: 55 },
placement: "top",
gap: GAP,
padding: PADDING,
rtl: false,
});
expect(resolution.side).toBe("bottom");
expect(resolution.availableHeight).toBe(440);
});
test("puts a beside placement on the other side when the content has more room there", () => {
const resolution = FloatingLayer.resolve({
anchor: { top: 100, left: 800, width: 100, height: 40 },
floating: { width: 200, height: 40 },
bounds: BOUNDS,
placement: "right",
gap: GAP,
padding: PADDING,
rtl: false,
});
expect(resolution.side).toBe("left");
expect(resolution.availableWidth).toBe(780);
expect(resolution.availableHeight).toBe(776);
});
test.each([
{ placement: "bottom", rtl: false, align: "center", origin: "50% 0%" },
{ placement: "bottom start", rtl: false, align: "start", origin: "0% 0%" },
{ placement: "bottom end", rtl: false, align: "end", origin: "100% 0%" },
{ placement: "bottom end", rtl: true, align: "end", origin: "0% 0%" },
{ placement: "top", rtl: false, align: "center", origin: "50% 100%" },
{ placement: "right", rtl: false, align: "center", origin: "0% 50%" },
] as const)(
"$placement (rtl: $rtl) aligns $align with its origin at $origin",
({ placement, rtl, align, origin }) => {
const resolution = FloatingLayer.resolve({
anchor: anchorAt(100, 400),
floating: { width: 100, height: 50 },
bounds: BOUNDS,
placement,
gap: GAP,
padding: PADDING,
rtl,
});
expect(resolution.align).toBe(align);
expect(resolution.transformOrigin).toBe(origin);
},
);
});
describe("FloatingLayer.spaceAcross", () => {
test.each([
{ placement: "bottom", space: 976 },
{ placement: "top", space: 976 },
{ placement: "right", space: 776 },
] as const)("$placement has $space across", ({ placement, space }) => {
expect(FloatingLayer.spaceAcross(BOUNDS, placement, PADDING)).toBe(space);
});
});
describe("FloatingLayer.isVerticalPlacement", () => {
test.each([
{ placement: "bottom start", vertical: true },
{ placement: "top", vertical: true },
{ placement: "right", vertical: false },
] as const)("$placement -> $vertical", ({ placement, vertical }) => {
expect(FloatingLayer.isVerticalPlacement(placement)).toBe(vertical);
});
});
describe("FloatingLayer.insets", () => {
const box = { width: 100, height: 50 };
test.each([
{
why: "centers under the anchor",
anchor: anchorAt(100),
floating: box,
side: "bottom",
align: "center",
rtl: false,
expected: { top: 148, right: null, bottom: null, left: 150 },
},
{
why: "hangs from the edge facing the anchor above it",
anchor: anchorAt(100),
floating: box,
side: "top",
align: "center",
rtl: false,
expected: { top: null, right: null, bottom: 708, left: 150 },
},
{
why: "lines up with the start edge of the anchor",
anchor: anchorAt(100),
floating: box,
side: "bottom",
align: "start",
rtl: false,
expected: { top: 148, right: null, bottom: null, left: 100 },
},
{
why: "lines up with the end edge of the anchor",
anchor: anchorAt(100),
floating: box,
side: "bottom",
align: "end",
rtl: false,
expected: { top: 148, right: null, bottom: null, left: 200 },
},
{
why: "reads start as the right edge in rtl",
anchor: anchorAt(100),
floating: box,
side: "bottom",
align: "start",
rtl: true,
expected: { top: 148, right: null, bottom: null, left: 200 },
},
{
why: "shifts back inside the bounds on the right",
anchor: anchorAt(100, 900),
floating: box,
side: "bottom",
align: "center",
rtl: false,
expected: { top: 148, right: null, bottom: null, left: 888 },
},
{
why: "shifts back inside the bounds on the left",
anchor: { top: 100, left: 0, width: 50, height: 40 },
floating: box,
side: "bottom",
align: "center",
rtl: false,
expected: { top: 148, right: null, bottom: null, left: 12 },
},
{
why: "sits at the padding when wider than the bounds",
anchor: anchorAt(100),
floating: { width: 1200, height: 50 },
side: "bottom",
align: "center",
rtl: false,
expected: { top: 148, right: null, bottom: null, left: 12 },
},
{
why: "sits beside the anchor",
anchor: { top: 300, left: 100, width: 100, height: 40 },
floating: box,
side: "right",
align: "center",
rtl: false,
expected: { top: 295, right: null, bottom: null, left: 208 },
},
{
why: "hangs from the edge facing the anchor on its left",
anchor: { top: 300, left: 100, width: 100, height: 40 },
floating: box,
side: "left",
align: "center",
rtl: false,
expected: { top: 295, right: 908, bottom: null, left: null },
},
{
why: "shifts down inside the bounds beside a high anchor",
anchor: { top: 10, left: 100, width: 100, height: 40 },
floating: box,
side: "right",
align: "center",
rtl: false,
expected: { top: 12, right: null, bottom: null, left: 208 },
},
] as const)("$why", ({ anchor, floating, side, align, rtl, expected }) => {
expect(
FloatingLayer.insets({
anchor,
floating,
bounds: BOUNDS,
side,
align,
gap: GAP,
padding: PADDING,
rtl,
viewport: VIEWPORT,
}),
).toEqual(expected);
});
});
describe("FloatingLayer.documentInsets", () => {
test.each([
{
why: "moves the near edges down the document by the scroll offset",
insets: { top: 148, right: null, bottom: null, left: 150 },
scroll: { x: 0, y: 300 },
expected: { top: 448, right: null, bottom: null, left: 150 },
},
{
why: "moves the far edges the other way",
insets: { top: null, right: 908, bottom: 708, left: null },
scroll: { x: 40, y: 300 },
expected: { top: null, right: 868, bottom: 408, left: null },
},
{
why: "leaves an unscrolled page as it is",
insets: { top: 148, right: null, bottom: null, left: 150 },
scroll: { x: 0, y: 0 },
expected: { top: 148, right: null, bottom: null, left: 150 },
},
])("$why", ({ insets, scroll, expected }) => {
expect(FloatingLayer.documentInsets(insets, scroll)).toEqual(expected);
});
});

View File

@@ -0,0 +1,241 @@
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
export type Placement =
| "top"
| "bottom"
| "right"
| "bottom start"
| "bottom end";
export interface Size {
width: number;
height: number;
}
export interface Rect extends Size {
top: number;
left: number;
}
export interface Bounds {
top: number;
right: number;
bottom: number;
left: number;
}
export interface Resolution {
side: Side;
align: Align;
availableWidth: number;
availableHeight: number;
transformOrigin: string;
}
export interface Insets {
top: number | null;
right: number | null;
bottom: number | null;
left: number | null;
}
const OPPOSITE_SIDE: Record<Side, Side> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
const ALIGN_ORIGIN: Record<Align, string> = {
start: "0%",
center: "50%",
end: "100%",
};
export function resolve({
anchor,
floating,
bounds,
placement,
gap,
padding,
rtl,
}: {
anchor: Rect;
floating: Size;
bounds: Bounds;
placement: Placement;
gap: number;
padding: number;
rtl: boolean;
}): Resolution {
const { side: preferred, align } = parsePlacement(placement);
const space = spaceAround(anchor, bounds, gap, padding);
const opposite = OPPOSITE_SIDE[preferred];
const needed = isVertical(preferred) ? floating.height : floating.width;
const across = spaceAcross(bounds, placement, padding);
const side =
needed <= space[preferred] || space[preferred] >= space[opposite]
? preferred
: opposite;
return {
side,
align,
availableWidth: Math.max(0, isVertical(side) ? across : space[side]),
availableHeight: Math.max(0, isVertical(side) ? space[side] : across),
transformOrigin: transformOrigin(side, align, rtl),
};
}
export function spaceAcross(
bounds: Bounds,
placement: Placement,
padding: number,
) {
const size = isVerticalPlacement(placement)
? bounds.right - bounds.left
: bounds.bottom - bounds.top;
return Math.max(0, size - 2 * padding);
}
export function isVerticalPlacement(placement: Placement) {
return isVertical(parsePlacement(placement).side);
}
export function insets({
anchor,
floating,
bounds,
side,
align,
gap,
padding,
rtl,
viewport,
}: {
anchor: Rect;
floating: Size;
bounds: Bounds;
side: Side;
align: Align;
gap: number;
padding: number;
rtl: boolean;
viewport: Size;
}): Insets {
if (isVertical(side)) {
const left = clamp(
alignedStart(
anchor.left,
anchor.width,
floating.width,
physicalAlign(align, rtl),
),
bounds.left + padding,
bounds.right - padding - floating.width,
);
return side === "bottom"
? {
top: anchor.top + anchor.height + gap,
right: null,
bottom: null,
left,
}
: {
top: null,
right: null,
bottom: viewport.height - (anchor.top - gap),
left,
};
}
const top = clamp(
alignedStart(anchor.top, anchor.height, floating.height, align),
bounds.top + padding,
bounds.bottom - padding - floating.height,
);
return side === "right"
? { top, right: null, bottom: null, left: anchor.left + anchor.width + gap }
: {
top,
right: viewport.width - (anchor.left - gap),
bottom: null,
left: null,
};
}
export function documentInsets(
viewportInsets: Insets,
scroll: { x: number; y: number },
): Insets {
const { top, right, bottom, left } = viewportInsets;
return {
top: top === null ? null : top + scroll.y,
right: right === null ? null : right - scroll.x,
bottom: bottom === null ? null : bottom - scroll.y,
left: left === null ? null : left + scroll.x,
};
}
function parsePlacement(placement: Placement): { side: Side; align: Align } {
const [side, align = "center"] = placement.split(" ") as [Side, Align?];
return { side, align };
}
function isVertical(side: Side) {
return side === "top" || side === "bottom";
}
function spaceAround(
anchor: Rect,
bounds: Bounds,
gap: number,
padding: number,
): Record<Side, number> {
const taken = gap + padding;
return {
top: anchor.top - bounds.top - taken,
bottom: bounds.bottom - (anchor.top + anchor.height) - taken,
left: anchor.left - bounds.left - taken,
right: bounds.right - (anchor.left + anchor.width) - taken,
};
}
function alignedStart(
anchorStart: number,
anchorSize: number,
floatingSize: number,
align: Align,
) {
if (align === "start") return anchorStart;
if (align === "end") return anchorStart + anchorSize - floatingSize;
return anchorStart + anchorSize / 2 - floatingSize / 2;
}
function physicalAlign(align: Align, rtl: boolean): Align {
if (!rtl || align === "center") return align;
return align === "start" ? "end" : "start";
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max));
}
function transformOrigin(side: Side, align: Align, rtl: boolean) {
if (isVertical(side)) {
const x = ALIGN_ORIGIN[physicalAlign(align, rtl)];
return side === "bottom" ? `${x} 0%` : `${x} 100%`;
}
const y = ALIGN_ORIGIN[align];
return side === "right" ? `0% ${y}` : `100% ${y}`;
}

View File

@@ -0,0 +1,23 @@
import { invariant } from "~/utils/invariant";
export const KEYBOARD_HEIGHT = 300;
export function openKeyboard() {
const viewport = window.visualViewport;
invariant(viewport);
const shrunk = viewport.height - KEYBOARD_HEIGHT;
Object.defineProperty(viewport, "height", {
configurable: true,
get: () => shrunk,
});
viewport.dispatchEvent(new Event("resize"));
}
export function closeKeyboard() {
const viewport = window.visualViewport;
invariant(viewport);
Reflect.deleteProperty(viewport, "height");
viewport.dispatchEvent(new Event("resize"));
}

View File

@@ -1,173 +0,0 @@
import * as React from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { invariant } from "~/utils/invariant";
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
const PAGE_HEIGHT = 5000;
afterEach(() => {
window.scrollTo(0, 0);
closeKeyboard();
});
function Overlay({
top,
height,
close,
}: {
top: number;
height: number;
close: () => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
useCloseOnScrollClip(true, ref, close);
return (
<>
<div style={{ height: PAGE_HEIGHT }} />
<div
ref={ref}
style={{ position: "absolute", top, left: 0, width: 100, height }}
/>
</>
);
}
function ScrollingOverlay({
height,
close,
}: {
height: number;
close: () => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
useCloseOnScrollClip(true, ref, close);
return (
<>
<div style={{ height: PAGE_HEIGHT }} />
<div
ref={ref}
data-testid="scroller"
style={{
position: "absolute",
top: 200,
left: 0,
width: 100,
height,
overflowY: "auto",
}}
>
<div style={{ height: 1000 }} />
</div>
</>
);
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 150));
const KEYBOARD_HEIGHT = 300;
/** Shrinks the visual viewport the way the virtual keyboard opening does. */
function openKeyboard() {
const viewport = window.visualViewport;
invariant(viewport);
const shrunk = viewport.height - KEYBOARD_HEIGHT;
Object.defineProperty(viewport, "height", {
configurable: true,
get: () => shrunk,
});
viewport.dispatchEvent(new Event("resize"));
}
function closeKeyboard() {
const viewport = window.visualViewport;
invariant(viewport);
Reflect.deleteProperty(viewport, "height");
viewport.dispatchEvent(new Event("resize"));
}
describe("useCloseOnScrollClip", () => {
test("closes once scrolling clips a popover that was fully visible", async () => {
const close = vi.fn();
await render(<Overlay top={200} height={100} close={close} />);
await settle();
expect(close).not.toHaveBeenCalled();
window.scrollTo(0, 250);
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
});
test("never closes a popover too tall to have been fully visible", async () => {
const close = vi.fn();
await render(<Overlay top={0} height={PAGE_HEIGHT * 2} close={close} />);
await settle();
window.scrollTo(0, 250);
await settle();
expect(close).not.toHaveBeenCalled();
});
test("does not close a popover that was already clipped when it opened", async () => {
const close = vi.fn();
await render(<Overlay top={-50} height={100} close={close} />);
await settle();
window.scrollTo(0, 250);
await settle();
expect(close).not.toHaveBeenCalled();
});
test("never closes over the scroll the virtual keyboard opening causes", async () => {
const close = vi.fn();
await render(<Overlay top={200} height={100} close={close} />);
await settle();
openKeyboard();
window.scrollTo(0, 250);
await settle();
expect(close).not.toHaveBeenCalled();
});
test("forgets a scroll the keyboard only lands after", async () => {
const close = vi.fn();
await render(<Overlay top={200} height={100} close={close} />);
await settle();
window.scrollTo(0, 250);
// the scroll can reach the page before the keyboard has shrunk the viewport
window.dispatchEvent(new Event("scroll"));
openKeyboard();
await settle();
expect(close).not.toHaveBeenCalled();
});
test("never closes over a scroll of the popover's own content", async () => {
const close = vi.fn();
const screen = await render(
<ScrollingOverlay height={100} close={close} />,
);
await settle();
const scroller = document.querySelector<HTMLElement>(
'[data-testid="scroller"]',
);
invariant(scroller);
scroller.scrollTop = 500;
await settle();
// the content growing then clips it, which alone must never close
screen.rerender(<ScrollingOverlay height={PAGE_HEIGHT} close={close} />);
await settle();
expect(close).not.toHaveBeenCalled();
});
});

View File

@@ -1,93 +0,0 @@
import * as React from "react";
const VISIBLE_RATIO_THRESHOLD = 0.98;
/** A visual viewport shorter than the window by more than this is the virtual keyboard, not collapsing browser chrome. */
const KEYBOARD_MIN_HEIGHT = 150;
/**
* Closes an open popover once scrolling clips it against the sticky header
* (`--popover-boundary-top`) or the bottom of the viewport.
*
* Only scrolling may close: a popover clipped by its own content growing (the
* moment before anchor positioning flips it into view), one too tall to ever
* fit fully, or one measured before it is shown must not close itself. The
* virtual keyboard opening is not scrolling either, even though the browser
* scrolls the page to keep the focused field in view as it does: a popover
* left under the keyboard beats one that closes as its own search input is
* focused.
*/
export function useCloseOnScrollClip(
isOpen: boolean,
elementRef: React.RefObject<HTMLElement | null>,
close: () => void,
) {
const closeRef = React.useRef(close);
closeRef.current = close;
React.useEffect(() => {
if (!isOpen) return;
const element = elementRef.current;
if (!element) return;
const marginTop =
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--popover-boundary-top"),
) || 0;
let wasFullyVisible = false;
let scrolledSinceFullyVisible = false;
const onScroll = (event: Event) => {
// the popover scrolling its own content (e.g. a select revealing the
// selected option) is not the page moving out from under it
if (event.target instanceof Node && element.contains(event.target)) {
return;
}
if (keyboardIsOpen()) return;
scrolledSinceFullyVisible = true;
};
window.addEventListener("scroll", onScroll, {
capture: true,
passive: true,
});
// the keyboard can land after the scroll it causes, which then has to be forgotten
const onViewportResize = () => {
if (keyboardIsOpen()) {
scrolledSinceFullyVisible = false;
}
};
window.visualViewport?.addEventListener("resize", onViewportResize);
const observer = new IntersectionObserver(
(entries) => {
const entry = entries.at(-1);
if (!entry) return;
if (entry.intersectionRatio >= VISIBLE_RATIO_THRESHOLD) {
wasFullyVisible = true;
scrolledSinceFullyVisible = false;
} else if (wasFullyVisible && scrolledSinceFullyVisible) {
closeRef.current();
}
},
{
threshold: [0, VISIBLE_RATIO_THRESHOLD],
rootMargin: `${-marginTop}px 0px 0px 0px`,
},
);
observer.observe(element);
return () => {
window.removeEventListener("scroll", onScroll, { capture: true });
window.visualViewport?.removeEventListener("resize", onViewportResize);
observer.disconnect();
};
}, [isOpen, elementRef]);
}
function keyboardIsOpen() {
const viewport = window.visualViewport;
if (!viewport) return false;
return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT;
}

View File

@@ -0,0 +1,426 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { page } from "vitest/browser";
import { render } from "vitest-browser-react";
import { invariant } from "~/utils/invariant";
import { SendouPopover } from "./Popover";
import { SendouSelect, SendouSelectItem } from "./Select";
import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard";
const SEASONS = [{ id: 1, name: "Season 1" }];
const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({
id: index + 1,
name: `Season ${index + 1}`,
}));
afterEach(() => {
window.scrollTo(0, 0);
closeKeyboard();
});
function rectOf(element: Element) {
return element.getBoundingClientRect();
}
function openPopover() {
const popover = document.querySelector<HTMLElement>("[popover]");
invariant(popover);
return popover;
}
function lengthOf(element: Element, property: string) {
return Number.parseFloat(
getComputedStyle(element).getPropertyValue(property),
);
}
/** A popover takes focus a frame after opening, scrolling itself into view, which would undo a scroll made before then. */
async function waitForFocus(popover: Element) {
await vi.waitFor(() => expect(document.activeElement).toBe(popover));
}
function ManySeasonsSelect() {
return (
<SendouSelect
label="Season"
items={MANY_SEASONS}
placeholder="Pick a season"
search={{ placeholder: "Search seasons..." }}
>
{({ id, name }: (typeof MANY_SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
);
}
describe("useFloatingLayer", () => {
test("centers the popover under its trigger", async () => {
const screen = await render(
<div style={{ padding: "100px" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
const trigger = screen.getByRole("button", { name: "Filters" });
await trigger.click();
const popover = screen.getByRole("dialog").element();
const triggerRect = rectOf(trigger.element());
const popoverRect = rectOf(popover);
expect(popoverRect.top).toBeCloseTo(
triggerRect.bottom + lengthOf(popover, "--floating-gap"),
0,
);
expect(
Math.abs(
popoverRect.left +
popoverRect.width / 2 -
(triggerRect.left + triggerRect.width / 2),
),
).toBeLessThan(2);
expect(popover.getAttribute("data-side")).toBe("bottom");
expect(popover.getAttribute("data-align")).toBe("center");
});
test("gives the select popover the width of its trigger", async () => {
const screen = await render(
<div style={{ padding: "100px" }}>
<SendouSelect
label="Season"
items={SEASONS}
placeholder="Pick a season"
>
{({ id, name }: (typeof SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1" }))
.toBeVisible();
const triggerRect = rectOf(trigger.element());
const popoverRect = rectOf(openPopover());
expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0);
expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0);
expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom);
});
test("opens a select upwards when its options do not fit below the trigger", async () => {
const screen = await render(
<div style={{ marginTop: "calc(100vh - 100px)" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const triggerRect = rectOf(trigger.element());
const popover = openPopover();
const popoverRect = rectOf(popover);
expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top);
expect(popoverRect.top).toBeGreaterThanOrEqual(0);
expect(popover.getAttribute("data-side")).toBe("top");
});
test("caps a long select to the space below its trigger", async () => {
const screen = await render(
<div style={{ padding: "100px" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = openPopover();
const listbox = screen.getByRole("listbox").element();
const spaceBelow = Math.floor(
window.innerHeight -
lengthOf(popover, "--popover-boundary-bottom") -
rectOf(trigger.element()).bottom -
lengthOf(popover, "--floating-gap") -
lengthOf(popover, "--floating-viewport-padding"),
);
expect(lengthOf(popover, "--floating-available-height")).toBe(spaceBelow);
expect(Number.parseFloat(getComputedStyle(popover).maxHeight)).toBe(
spaceBelow,
);
expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight);
expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight);
});
test("keeps the select where it opened when searching shrinks the list", async () => {
const screen = await render(
<div style={{ marginTop: "calc(100vh - 100px)" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = openPopover();
const bottomOnOpen = rectOf(popover).bottom;
await screen.getByRole("combobox").fill("Season 40");
await expect
.element(screen.getByRole("option", { name: "Season 40" }))
.toBeVisible();
expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0);
});
test("follows its trigger when the page scrolls", async () => {
const screen = await render(
<div style={{ padding: "100px", height: "300vh" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
const trigger = screen.getByRole("button", { name: "Filters" });
await trigger.click();
const popover = screen.getByRole("dialog").element();
const gap = lengthOf(popover, "--floating-gap");
await waitForFocus(popover);
window.scrollTo(0, 40);
await vi.waitFor(() => {
expect(rectOf(popover).top).toBeCloseTo(
rectOf(trigger.element()).bottom + gap,
0,
);
});
expect(popover.matches(":popover-open")).toBe(true);
});
test("keeps a select out from under the keyboard taking the space below it", async () => {
const screen = await render(
<div style={{ padding: "100px" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = openPopover();
const viewport = window.visualViewport;
invariant(viewport);
expect(rectOf(popover).bottom).toBeGreaterThan(
viewport.height - KEYBOARD_HEIGHT,
);
openKeyboard();
await vi.waitFor(() => {
expect(rectOf(popover).bottom).toBeLessThanOrEqual(viewport.height);
});
expect(popover.matches(":popover-open")).toBe(true);
});
test("flips over when scrolling leaves it more room on the other side", async () => {
const screen = await render(
<div style={{ marginTop: "calc(100vh - 100px)", height: "300vh" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = openPopover();
expect(popover.getAttribute("data-side")).toBe("top");
window.scrollTo(0, 400);
await vi.waitFor(() => {
expect(popover.getAttribute("data-side")).toBe("bottom");
});
expect(rectOf(popover).top).toBeCloseTo(
rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"),
0,
);
expect(popover.matches(":popover-open")).toBe(true);
});
test("keeps above the mobile nav's floor", async () => {
const { innerWidth, innerHeight } = window;
const root = document.documentElement;
const floorBefore = root.style.getPropertyValue(
"--popover-boundary-bottom",
);
await page.viewport(375, 667);
// what the layout sets at the mobile breakpoint, with no layout rendered here
root.style.setProperty("--popover-boundary-bottom", "55px");
try {
const screen = await render(
<div style={{ padding: "100px" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = openPopover();
const floor = lengthOf(popover, "--popover-boundary-bottom");
expect(floor).toBeGreaterThan(0);
expect(rectOf(popover).bottom).toBeLessThanOrEqual(
window.innerHeight - floor,
);
} finally {
root.style.setProperty("--popover-boundary-bottom", floorBefore);
await page.viewport(innerWidth, innerHeight);
}
});
test("caps the content again after the viewport shrinks with the popover above its trigger", async () => {
const { innerWidth, innerHeight } = window;
try {
// pixels rather than viewport units, so the trigger stays put when the
// viewport shrinks, and high enough up to stay in view once it has
const screen = await render(
<div style={{ marginTop: innerHeight - 200, height: "300vh" }}>
<ManySeasonsSelect />
</div>,
);
const trigger = screen.getByRole("button", { name: /Pick a season/ });
await trigger.click();
await expect
.element(screen.getByRole("option", { name: "Season 1", exact: true }))
.toBeVisible();
const popover = openPopover();
expect(popover.getAttribute("data-side")).toBe("top");
await page.viewport(innerWidth, innerHeight - 40);
await vi.waitFor(() => {
expect(
Number.parseFloat(getComputedStyle(popover).maxHeight),
).toBeLessThanOrEqual(window.innerHeight);
});
expect(getComputedStyle(popover).visibility).toBe("visible");
expect(rectOf(popover).top).toBeGreaterThanOrEqual(0);
} finally {
await page.viewport(innerWidth, innerHeight);
}
});
test("stays put under an anchor in a sticky header while the page scrolls", async () => {
const screen = await render(
<div style={{ height: "300vh" }}>
<header style={{ position: "sticky", top: 0, padding: 20 }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</header>
</div>,
);
const trigger = screen.getByRole("button", { name: "Filters" });
await trigger.click();
const popover = screen.getByRole("dialog").element();
const topBefore = rectOf(popover).top;
expect(getComputedStyle(popover).position).toBe("fixed");
await waitForFocus(popover);
window.scrollTo(0, 200);
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
expect(rectOf(popover).top).toBeCloseTo(topBefore, 0);
expect(rectOf(popover).top).toBeCloseTo(
rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"),
0,
);
});
test("hides while its trigger is scrolled out of sight and shows again once it is back", async () => {
const screen = await render(
<div style={{ padding: "100px", height: "300vh" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
await screen.getByRole("button", { name: "Filters" }).click();
const popover = screen.getByRole("dialog").element();
expect(getComputedStyle(popover).visibility).toBe("visible");
await waitForFocus(popover);
window.scrollTo(0, 600);
await vi.waitFor(() => {
expect(getComputedStyle(popover).visibility).toBe("hidden");
});
expect(popover.matches(":popover-open")).toBe(true);
window.scrollTo(0, 0);
await vi.waitFor(() => {
expect(getComputedStyle(popover).visibility).toBe("visible");
});
});
test("hides once a scrolling container clips its trigger", async () => {
const screen = await render(
<div data-testid="scroller" style={{ height: 200, overflow: "auto" }}>
<div style={{ padding: "20px 0" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>
<div style={{ height: 1000 }} />
</div>,
);
await screen.getByRole("button", { name: "Filters" }).click();
const popover = screen.getByRole("dialog").element();
const scroller = document.querySelector<HTMLElement>(
'[data-testid="scroller"]',
);
invariant(scroller);
await waitForFocus(popover);
scroller.scrollTop = 300;
await vi.waitFor(() => {
expect(getComputedStyle(popover).visibility).toBe("hidden");
});
});
});

View File

@@ -0,0 +1,317 @@
import * as React from "react";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { visibleViewportRect } from "~/utils/visual-viewport";
import * as FloatingLayer from "./floating-layer";
const GAP_PROPERTY = "--floating-gap";
const PADDING_PROPERTY = "--floating-viewport-padding";
const CEILING_PROPERTY = "--popover-boundary-top";
const FLOOR_PROPERTY = "--popover-boundary-bottom";
const AVAILABLE_WIDTH_PROPERTY = "--floating-available-width";
const AVAILABLE_HEIGHT_PROPERTY = "--floating-available-height";
const ANCHOR_WIDTH_PROPERTY = "--floating-anchor-width";
const ANCHOR_HEIGHT_PROPERTY = "--floating-anchor-height";
const TRANSFORM_ORIGIN_PROPERTY = "--floating-transform-origin";
const OUTPUT_PROPERTIES = [
AVAILABLE_WIDTH_PROPERTY,
AVAILABLE_HEIGHT_PROPERTY,
ANCHOR_WIDTH_PROPERTY,
ANCHOR_HEIGHT_PROPERTY,
TRANSFORM_ORIGIN_PROPERTY,
];
const INSET_PROPERTIES = ["top", "right", "bottom", "left"] as const;
const UNCAPPED = "10000000px";
export type FloatingPlacement = FloatingLayer.Placement;
export function useFloatingLayer({
isOpen,
floatingRef,
getAnchor,
placement = "bottom",
}: {
isOpen: boolean;
floatingRef: React.RefObject<HTMLElement | null>;
getAnchor: () => Element | null;
placement?: FloatingPlacement;
}) {
const getAnchorRef = React.useRef(getAnchor);
getAnchorRef.current = getAnchor;
useIsomorphicLayoutEffect(() => {
const floating = floatingRef.current;
if (!isOpen || !floating) return;
let natural: FloatingLayer.Size | null = null;
let lastBounds: FloatingLayer.Bounds | null = null;
let lastResolution: FloatingLayer.Resolution | null = null;
let viewportAnchored: boolean | null = null;
let frameId: number | null = null;
const update = () => {
const anchor = getAnchorRef.current();
if (!anchor || !floating.matches(":popover-open")) return;
if (viewportAnchored === null) {
viewportAnchored = isViewportAnchored(anchor);
floating.style.position = viewportAnchored ? "fixed" : "absolute";
}
const computed = getComputedStyle(floating);
const gap = lengthOf(computed, GAP_PROPERTY);
const padding = lengthOf(computed, PADDING_PROPERTY);
const rtl = computed.direction === "rtl";
const bounds = floatingBounds(floating);
const anchorRect = anchor.getBoundingClientRect();
const detached = isAnchorDetached(
anchor,
anchorRect,
computed,
viewportAnchored,
);
floating.style.visibility = detached ? "hidden" : "";
if (detached) return;
floating.style.setProperty(ANCHOR_WIDTH_PROPERTY, px(anchorRect.width));
floating.style.setProperty(ANCHOR_HEIGHT_PROPERTY, px(anchorRect.height));
if (natural === null || !sameBounds(bounds, lastBounds)) {
lastBounds = bounds;
natural = naturalSize(floating, bounds, placement, padding);
lastResolution = null;
}
const resolution = FloatingLayer.resolve({
anchor: anchorRect,
floating: natural,
bounds,
placement,
gap,
padding,
rtl,
});
if (!sameResolution(resolution, lastResolution)) {
lastResolution = resolution;
floating.style.setProperty(
AVAILABLE_WIDTH_PROPERTY,
px(Math.floor(resolution.availableWidth)),
);
floating.style.setProperty(
AVAILABLE_HEIGHT_PROPERTY,
px(Math.floor(resolution.availableHeight)),
);
floating.style.setProperty(
TRANSFORM_ORIGIN_PROPERTY,
resolution.transformOrigin,
);
floating.dataset.side = resolution.side;
floating.dataset.align = resolution.align;
}
const root = document.documentElement;
const viewportInsets = FloatingLayer.insets({
anchor: anchorRect,
floating: floating.getBoundingClientRect(),
bounds,
side: resolution.side,
align: resolution.align,
gap,
padding,
rtl,
viewport: { width: root.clientWidth, height: root.clientHeight },
});
const insets = viewportAnchored
? viewportInsets
: FloatingLayer.documentInsets(viewportInsets, {
x: window.scrollX,
y: window.scrollY,
});
for (const property of INSET_PROPERTIES) {
const value = insets[property];
floating.style.setProperty(
property,
value === null ? "auto" : px(roundToDevicePixels(value)),
);
}
};
update();
const scheduleUpdate = () => {
if (frameId !== null) return;
frameId = requestAnimationFrame(() => {
frameId = null;
update();
});
};
const onScroll = (event: Event) => {
if (event.target instanceof Node && floating.contains(event.target)) {
return;
}
update();
};
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(floating);
const anchor = getAnchorRef.current();
if (anchor) {
resizeObserver.observe(anchor);
}
floating.addEventListener("toggle", update);
window.addEventListener("scroll", onScroll, {
capture: true,
passive: true,
});
window.addEventListener("resize", scheduleUpdate);
window.visualViewport?.addEventListener("resize", scheduleUpdate);
window.visualViewport?.addEventListener("scroll", scheduleUpdate);
return () => {
resizeObserver.disconnect();
floating.removeEventListener("toggle", update);
window.removeEventListener("scroll", onScroll, { capture: true });
window.removeEventListener("resize", scheduleUpdate);
window.visualViewport?.removeEventListener("resize", scheduleUpdate);
window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
for (const property of [
"position",
"visibility",
...INSET_PROPERTIES,
...OUTPUT_PROPERTIES,
]) {
floating.style.removeProperty(property);
}
delete floating.dataset.side;
delete floating.dataset.align;
};
}, [isOpen, floatingRef, placement]);
}
function naturalSize(
floating: HTMLElement,
bounds: FloatingLayer.Bounds,
placement: FloatingPlacement,
padding: number,
): FloatingLayer.Size {
const vertical = FloatingLayer.isVerticalPlacement(placement);
floating.style.setProperty(
vertical ? AVAILABLE_WIDTH_PROPERTY : AVAILABLE_HEIGHT_PROPERTY,
px(FloatingLayer.spaceAcross(bounds, placement, padding)),
);
floating.style.setProperty(
vertical ? AVAILABLE_HEIGHT_PROPERTY : AVAILABLE_WIDTH_PROPERTY,
UNCAPPED,
);
const { width, height } = floating.getBoundingClientRect();
return { width, height };
}
export function floatingBounds(element: Element): FloatingLayer.Bounds {
const computed = getComputedStyle(element);
const visible = visibleViewportRect();
const layoutHeight = document.documentElement.clientHeight;
return {
top: Math.max(visible.top, lengthOf(computed, CEILING_PROPERTY)),
right: visible.right,
bottom: Math.min(
visible.bottom,
layoutHeight - lengthOf(computed, FLOOR_PROPERTY),
),
left: visible.left,
};
}
function isAnchorDetached(
anchor: Element,
anchorRect: DOMRect,
computed: CSSStyleDeclaration,
viewportAnchored: boolean,
) {
const root = document.documentElement;
let top = viewportAnchored ? 0 : lengthOf(computed, CEILING_PROPERTY);
let right = root.clientWidth;
let bottom =
root.clientHeight -
(viewportAnchored ? 0 : lengthOf(computed, FLOOR_PROPERTY));
let left = 0;
let element = anchor.parentElement;
while (element !== null && element !== document.body) {
if (getComputedStyle(element).overflow !== "visible") {
const rect = element.getBoundingClientRect();
top = Math.max(top, rect.top);
right = Math.min(right, rect.right);
bottom = Math.min(bottom, rect.bottom);
left = Math.max(left, rect.left);
}
element = element.parentElement;
}
return (
anchorRect.bottom <= top ||
anchorRect.top >= bottom ||
anchorRect.right <= left ||
anchorRect.left >= right
);
}
function isViewportAnchored(anchor: Element) {
let element: Element | null = anchor;
while (element !== null && element !== document.body) {
const position = getComputedStyle(element).position;
if (position === "fixed" || position === "sticky") return true;
element = element.parentElement;
}
return false;
}
function sameBounds(a: FloatingLayer.Bounds, b: FloatingLayer.Bounds | null) {
return (
b !== null &&
a.top === b.top &&
a.right === b.right &&
a.bottom === b.bottom &&
a.left === b.left
);
}
function sameResolution(
a: FloatingLayer.Resolution,
b: FloatingLayer.Resolution | null,
) {
return (
b !== null &&
a.side === b.side &&
a.align === b.align &&
Math.floor(a.availableWidth) === Math.floor(b.availableWidth) &&
Math.floor(a.availableHeight) === Math.floor(b.availableHeight) &&
a.transformOrigin === b.transformOrigin
);
}
function lengthOf(computed: CSSStyleDeclaration, property: string) {
return Number.parseFloat(computed.getPropertyValue(property)) || 0;
}
function roundToDevicePixels(value: number) {
const ratio = window.devicePixelRatio || 1;
return Math.round(value * ratio) / ratio;
}
function px(value: number) {
return `${value}px`;
}

View File

@@ -0,0 +1,99 @@
import * as React from "react";
import { afterEach, describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { lockScroll } from "~/modules/scroll-lock/scroll-lock";
import { invariant } from "~/utils/invariant";
import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard";
import { useScrollIntoView } from "./useScrollIntoView";
const PAGE_HEIGHT = 5000;
const ANCHOR_HEIGHT = 40;
afterEach(() => {
closeKeyboard();
window.scrollTo(0, 0);
});
const settle = () => new Promise((resolve) => setTimeout(resolve, 100));
function Anchored({ top }: { top: number }) {
const ref = React.useRef<HTMLDivElement>(null);
useScrollIntoView(true, () => ref.current);
return (
<>
<div style={{ height: PAGE_HEIGHT }} />
<div
ref={ref}
data-testid="anchor"
style={{
position: "absolute",
top,
left: 0,
width: 100,
height: ANCHOR_HEIGHT,
}}
/>
</>
);
}
function anchorRect() {
const anchor = document.querySelector('[data-testid="anchor"]');
invariant(anchor);
return anchor.getBoundingClientRect();
}
function visualViewportHeight() {
const viewport = window.visualViewport;
invariant(viewport);
return viewport.height;
}
describe("useScrollIntoView", () => {
test("scrolls the page so the anchor sits above the keyboard opening over it", async () => {
const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20;
await render(<Anchored top={top} />);
openKeyboard();
await expect
.poll(() => anchorRect().bottom)
.toBeLessThanOrEqual(visualViewportHeight());
expect(window.scrollY).toBeGreaterThan(0);
});
test("leaves the page alone when the anchor is above the keyboard already", async () => {
await render(<Anchored top={100} />);
openKeyboard();
await settle();
expect(window.scrollY).toBe(0);
});
test("leaves the page alone when the viewport did not shrink to a keyboard", async () => {
const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20;
await render(<Anchored top={top} />);
window.visualViewport?.dispatchEvent(new Event("resize"));
await settle();
expect(window.scrollY).toBe(0);
});
test("does not scroll a scroll locked page", async () => {
const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20;
await render(<Anchored top={top} />);
const release = lockScroll();
try {
openKeyboard();
await settle();
expect(window.scrollY).toBe(0);
} finally {
release();
}
});
});

View File

@@ -0,0 +1,51 @@
import * as React from "react";
import { isScrollLocked } from "~/modules/scroll-lock/scroll-lock";
import { keyboardIsOpen } from "~/utils/visual-viewport";
import { floatingBounds } from "./useFloatingLayer";
const PADDING_PROPERTY = "--floating-viewport-padding";
// Brings the anchor of an open popover back on screen if the mobile keyboard opens over it
export function useScrollIntoView(
isOpen: boolean,
getAnchor: () => Element | null,
) {
const getAnchorRef = React.useRef(getAnchor);
getAnchorRef.current = getAnchor;
React.useEffect(() => {
const viewport = window.visualViewport;
if (!isOpen || !viewport) return;
const onResize = () => {
if (!keyboardIsOpen()) return;
const anchor = getAnchorRef.current();
if (anchor) {
revealAboveKeyboard(anchor);
}
};
viewport.addEventListener("resize", onResize);
return () => viewport.removeEventListener("resize", onResize);
}, [isOpen]);
}
function revealAboveKeyboard(anchor: Element) {
anchor.scrollIntoView({ block: "nearest", inline: "nearest" });
if (isScrollLocked()) return;
const padding =
Number.parseFloat(
getComputedStyle(anchor).getPropertyValue(PADDING_PROPERTY),
) || 0;
const bounds = floatingBounds(anchor);
const rect = anchor.getBoundingClientRect();
const below = rect.bottom - (bounds.bottom - padding);
const above = bounds.top + padding - rect.top;
if (below > 0) {
window.scrollBy({ top: below });
} else if (above > 0) {
window.scrollBy({ top: -above });
}
}

View File

@@ -128,7 +128,7 @@
}
.value {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
.chevron,

View File

@@ -101,8 +101,8 @@
.unreadBadge {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
@@ -146,8 +146,8 @@
right: -2px;
font-size: var(--font-3xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
min-width: 14px;
height: 14px;
padding: 0 3px;

View File

@@ -122,7 +122,7 @@
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-full);
background-color: var(--color-bg-higher);
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
.cta {

View File

@@ -0,0 +1,37 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { page } from "vitest/browser";
import { render } from "vitest-browser-react";
import { GlobalSearch } from "./GlobalSearch";
let hrefBefore = window.location.href;
beforeEach(() => {
hrefBefore = window.location.href;
});
afterEach(() => {
window.history.replaceState(null, "", hrefBefore);
});
function pushSearchParamOpen() {
const url = new URL(window.location.href);
url.searchParams.set("search", "open");
window.history.pushState(null, "", url);
}
describe("GlobalSearch", () => {
test("opens from the search param and closes when navigating back pops it", async () => {
pushSearchParamOpen();
const router = createMemoryRouter([
{ path: "*", element: <GlobalSearch /> },
]);
await render(<RouterProvider router={router} />);
await expect.element(page.getByRole("dialog")).toBeVisible();
window.history.back();
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
});
});

View File

@@ -44,7 +44,8 @@
}
@container (width < 620px) {
.searchLabel {
.searchLabel,
.searchPlaceholder {
display: none;
}
@@ -77,7 +78,20 @@
.modal {
width: calc(100% - 2 * var(--layout-main-padding));
max-width: 36rem;
margin: 15vh auto auto;
inset-block-start: var(--visual-viewport-offset-top, 0px);
inset-block-end: calc(
100% -
var(--visual-viewport-offset-top, 0px) -
var(--visual-viewport-height, 100%)
);
max-height: calc(
var(--visual-viewport-height, 100dvh) -
2 *
var(--modal-margin-block)
);
margin: var(--modal-margin-block) auto auto;
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid var(--color-border);
background-color: var(--color-bg);
@@ -97,13 +111,19 @@
}
}
.content {
display: flex;
flex-direction: column;
min-height: 0;
}
.inputContainer {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
&:focus-within {
border-color: var(--color-text-accent);
border-color: var(--color-fg-accent);
}
}
@@ -181,8 +201,8 @@
}
.searchTypeRadioSelected {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
border-color: transparent;
}

View File

@@ -83,8 +83,8 @@ export function GlobalSearch() {
const [isOpen, setIsOpen] = React.useState(searchParamOpen);
const prevSearchParamOpen = React.useRef(searchParamOpen);
if (searchParamOpen && !prevSearchParamOpen.current) {
setIsOpen(true);
if (searchParamOpen !== prevSearchParamOpen.current) {
setIsOpen(searchParamOpen);
}
prevSearchParamOpen.current = searchParamOpen;
@@ -310,7 +310,7 @@ function GlobalSearchContent({
if (searchType === "weapons" && selectedWeapon) {
return (
<div onClickCapture={handleClickCapture}>
<div className={styles.content} onClickCapture={handleClickCapture}>
<WeaponDestinationMenu
selectedWeapon={selectedWeapon}
onBack={handleBackToWeaponSearch}
@@ -322,7 +322,7 @@ function GlobalSearchContent({
}
return (
<div onClickCapture={handleClickCapture}>
<div className={styles.content} onClickCapture={handleClickCapture}>
<div className={styles.inputContainer}>
<p className={styles.inputPrefix}>
{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`}

View File

@@ -1,5 +1,5 @@
.listBox {
max-height: 325px;
min-height: 0;
overflow-y: auto;
padding: var(--s-2);
outline: none;

View File

@@ -0,0 +1,57 @@
import type * as React from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { TopNavMenus } from "./TopNavMenus";
const viewportBefore = { width: window.innerWidth, height: window.innerHeight };
// the top nav is the desktop navigation, shown from the tablet breakpoint up
beforeEach(async () => {
await page.viewport(1280, 720);
});
afterEach(async () => {
await page.viewport(viewportBefore.width, viewportBefore.height);
});
function withRouter(element: React.ReactElement) {
const router = createMemoryRouter([{ path: "*", element }], {
initialEntries: ["/"],
});
return <RouterProvider router={router} />;
}
function openPopovers() {
return [...document.querySelectorAll<HTMLElement>("[popover]:popover-open")];
}
describe("TopNavMenus", () => {
test("hovering another item while a menu is open moves the open menu there", async () => {
const screen = await render(withRouter(<TopNavMenus />));
await screen.getByRole("button", { name: "Play" }).click();
await vi.waitFor(() => {
expect(openPopovers()).toHaveLength(1);
expect(openPopovers()[0].querySelector('a[href="/q"]')).not.toBeNull();
});
await userEvent.hover(screen.getByRole("button", { name: "Tools" }));
await vi.waitFor(() => {
const open = openPopovers();
expect(open).toHaveLength(1);
expect(open[0].querySelector('a[href="/analyzer"]')).not.toBeNull();
});
});
test("hovering an item with no menu open leaves every menu closed", async () => {
const screen = await render(withRouter(<TopNavMenus />));
await userEvent.hover(screen.getByRole("button", { name: "Tools" }));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(openPopovers()).toHaveLength(0);
});
});

View File

@@ -88,7 +88,7 @@
.preview {
position: absolute;
top: calc(100% + var(--s-1));
top: calc(100% + var(--floating-gap));
left: 0;
z-index: 1;
display: grid;
@@ -121,7 +121,7 @@
content: "";
position: absolute;
z-index: -1;
inset: calc(-1 * (var(--s-1) + var(--border-width)));
inset: calc(-1 * (var(--floating-gap) + var(--border-width)));
}
}

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { type PointerEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { Form, Link, useLocation } from "react-router";
import { Config } from "~/config";
@@ -74,19 +74,48 @@ const NAV_CATEGORIES = [
},
] as const;
interface MenuOpenState {
isOpen: boolean;
anotherIsOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function TopNavMenus() {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const openStateOf = (name: string): MenuOpenState => ({
isOpen: openMenu === name,
anotherIsOpen: openMenu !== null && openMenu !== name,
onOpenChange: (open) =>
setOpenMenu((current) => {
if (open) return name;
return current === name ? null : current;
}),
});
return (
<nav className={styles.container}>
{NAV_CATEGORIES.map((category) => (
<CategoryMenu key={category.name} category={category} />
<CategoryMenu
key={category.name}
category={category}
openState={openStateOf(category.name)}
/>
))}
{process.env.NODE_ENV === "development" ? <DevMenu /> : null}
{process.env.NODE_ENV === "development" ? (
<DevMenu openState={openStateOf("dev")} />
) : null}
</nav>
);
}
function DevMenu() {
const [isOpen, setIsOpen] = useState(false);
function takeOverOnHover(openState: MenuOpenState, event: PointerEvent) {
if (openState.anotherIsOpen && event.pointerType !== "touch") {
openState.onOpenChange(true);
}
}
function DevMenu({ openState }: { openState: MenuOpenState }) {
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
const location = useLocation();
const returnTo = `${location.pathname}${location.search}`;
@@ -98,7 +127,10 @@ function DevMenu() {
<button
type="button"
className={styles.menuButton}
onPointerEnter={() => setIsPreviewSuppressed(false)}
onPointerEnter={(event) => {
setIsPreviewSuppressed(false);
takeOverOnHover(openState, event);
}}
>
Dev
<ChevronDown className={styles.menuButtonChevron} />
@@ -106,8 +138,8 @@ function DevMenu() {
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={openState.isOpen}
onOpenChange={openState.onOpenChange}
eager
>
<div className={styles.menuContent}>
@@ -140,7 +172,7 @@ function DevMenu() {
to={item.url}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
openState.onOpenChange(false);
setIsPreviewSuppressed(true);
}}
>
@@ -155,7 +187,7 @@ function DevMenu() {
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
{!openState.isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{DEV_IMPERSONATE_ITEMS.map((item) => (
<Form
@@ -198,11 +230,12 @@ function DevMenu() {
function CategoryMenu({
category,
openState,
}: {
category: (typeof NAV_CATEGORIES)[number];
openState: MenuOpenState;
}) {
const { t } = useTranslation(["common", "front"]);
const [isOpen, setIsOpen] = useState(false);
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
const user = useUser();
const isStaff = user?.roles.includes("STAFF") ?? false;
@@ -221,7 +254,10 @@ function CategoryMenu({
<button
type="button"
className={styles.menuButton}
onPointerEnter={() => setIsPreviewSuppressed(false)}
onPointerEnter={(event) => {
setIsPreviewSuppressed(false);
takeOverOnHover(openState, event);
}}
>
{t(`front:nav.${category.name}`)}
<ChevronDown className={styles.menuButtonChevron} />
@@ -229,8 +265,8 @@ function CategoryMenu({
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={openState.isOpen}
onOpenChange={openState.onOpenChange}
eager
>
<div className={styles.menuContent}>
@@ -241,7 +277,7 @@ function CategoryMenu({
prefetch="intent"
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
openState.onOpenChange(false);
setIsPreviewSuppressed(true);
}}
>
@@ -256,7 +292,7 @@ function CategoryMenu({
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
{!openState.isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{visibleItems.map((item) => (
<Link

View File

@@ -93,8 +93,8 @@
right: -6px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);

View File

@@ -46,12 +46,12 @@
flex-direction: row;
align-items: center;
justify-content: center;
width: 40px;
width: 36px;
height: 36px;
background-color: var(--color-text-accent);
background-color: var(--color-fill-accent);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
color: var(--color-fg-on-accent);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s;
@@ -178,8 +178,8 @@
.friendRequestsBadge {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
@@ -196,8 +196,8 @@
inset-inline-end: -5px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
color: var(--color-fg-on-accent);
background-color: var(--color-fill-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);

View File

@@ -25,8 +25,9 @@ import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigati
import { useHydrated } from "~/hooks/useHydrated";
import { MOBILE_LAYOUT_QUERY, useLayoutSize } from "~/hooks/useLayoutSize";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { useScrollLock } from "~/hooks/useScrollLock";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
import { useVisualViewport } from "~/hooks/useVisualViewport";
import { useSearchParam } from "~/modules/search-params/hooks";
import type { RootLoaderData } from "~/root";
import { generateIdenticon } from "~/utils/identicon";
@@ -261,9 +262,10 @@ export function Layout({
const sideNavRef = React.useRef<HTMLElement>(null);
const [sideNavDrawerOpen, setSideNavDrawerOpen] = React.useState(false);
useClosePopoversOnNavigation(sideNavRef);
useScrollLock(sideNavDrawerOpen);
useVisualViewport();
const [chatSidebarModalOpen, setChatSidebarModalOpen] =
useTabletModal(isTabletLayout);
useVisualViewportHeight();
const chatSidebarOpen = chatContext?.chatOpen ?? false;
const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {});

View File

@@ -175,13 +175,13 @@
.tileNumber {
position: absolute;
background-color: var(--color-text-accent);
background-color: var(--color-fill-accent);
border-radius: 100%;
width: 18px;
height: 18px;
display: grid;
place-items: center;
color: var(--color-text-inverse);
color: var(--color-fg-on-accent);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
top: -5px;

View File

@@ -175,7 +175,7 @@
height: 100%;
&:hover .checkCircle {
border-color: var(--color-accent-high);
border-color: var(--color-fill-accent);
}
@container (max-width: 599px) {
@@ -199,9 +199,9 @@
}
.checkCircleSelected {
background-color: var(--color-accent-high);
border-color: var(--color-accent-high);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
border-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
& svg {
stroke-width: 3px;

View File

@@ -10,8 +10,9 @@ import {
} from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { specialWeaponImageUrl, stageBannerImageUrl } from "~/utils/urls";
import { ModeImage } from "../Image";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { stageBannerImageUrl } from "~/utils/urls";
import { ModeImage, SpecialWeaponImage } from "../Image";
import styles from "./MatchBanner.module.css";
interface BannerHost {
@@ -240,11 +241,9 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
testId={screenLegal ? "screen-allowed" : "screen-banned"}
aria-label={screenLegal ? "Screen allowed" : "Screen banned"}
>
<img
src={`${specialWeaponImageUrl(19)}.avif`}
width={imgSize}
height={imgSize}
alt=""
<SpecialWeaponImage
specialWeaponId={SPLATTERCOLOR_SCREEN_ID}
size={imgSize}
/>
<Icon
size={imgSize}

View File

@@ -216,10 +216,10 @@
flex-shrink: 0;
&[data-side="alpha"] {
background-color: var(--color-accent);
background-color: var(--color-fg-accent);
}
&[data-side="bravo"] {
background-color: var(--color-second);
background-color: var(--color-fg-second);
}
}

View File

@@ -76,11 +76,9 @@ describe("findAllTags", () => {
const result = await ArtRepository.findAllTags();
expect(result).toHaveLength(3);
expect(result.map((t) => t.name).sort()).toEqual([
"Character",
"Landscape",
"Weapon",
]);
expect(
result.map((t) => t.name).sort((a, b) => a.localeCompare(b)),
).toEqual(["Character", "Landscape", "Weapon"]);
});
test("returns empty array when no tags exist", async () => {

View File

@@ -172,7 +172,8 @@
}
.dialogTagUser {
background-color: var(--color-accent);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
}
@keyframes lightbox-zoom-in {

View File

@@ -7,6 +7,6 @@
}
.title {
color: var(--color-text-accent);
color: var(--color-fg-accent);
font-size: var(--font-md);
}

View File

@@ -174,8 +174,8 @@ describe("AvailabilityRepository.upsertOwnWeek", () => {
...WINDOW,
});
expect(weeks.map((week) => week.userId).sort()).toEqual(
[users.id(1), users.id(2)].sort(),
expect(weeks.map((week) => week.userId).sort((a, b) => a - b)).toEqual(
[users.id(1), users.id(2)].sort((a, b) => a - b),
);
});
});
@@ -469,7 +469,7 @@ describe("AvailabilityRepository.findAllTeamEventsByUserIds", () => {
events
.filter((event) => event.userId === users.id(2))
.map((e) => e.name)
.sort(),
.sort((a, b) => a.localeCompare(b)),
).toEqual(["Selected only", "Whole team"]);
});
});

View File

@@ -130,7 +130,7 @@
}
.noteFlag {
color: var(--color-text-accent);
color: var(--color-fg-accent);
flex-shrink: 0;
}

View File

@@ -50,5 +50,5 @@
}
.noteFlag {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}

View File

@@ -46,8 +46,8 @@
transition: background-color 0.15s;
&[aria-pressed="true"] {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
}
&:focus-visible {

View File

@@ -24,7 +24,7 @@
gap: var(--s-2);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-accent);
color: var(--color-fg-accent);
&:hover {
text-decoration: underline;

View File

@@ -90,7 +90,7 @@
}
.noteFlag {
color: var(--color-text-accent);
color: var(--color-fg-accent);
flex-shrink: 0;
}

View File

@@ -147,7 +147,7 @@
border: none;
border-radius: var(--radius-full);
font-size: var(--font-xs);
color: var(--color-text-accent);
color: var(--color-fg-accent);
cursor: pointer;
&:focus-visible {

View File

@@ -81,7 +81,7 @@
}
.noteFlag {
color: var(--color-text-accent);
color: var(--color-fg-accent);
}
.dayDot {

View File

@@ -48,7 +48,7 @@
margin-top: -8px;
margin-right: auto;
margin-left: auto;
color: var(--color-accent-high);
color: var(--color-fg-accent);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
}

View File

@@ -1,5 +1,5 @@
.explanation {
color: var(--color-text-accent);
color: var(--color-fg-accent);
font-weight: var(--weight-semi);
text-align: center;
}
@@ -33,6 +33,6 @@
}
.count {
color: var(--color-accent-high);
color: var(--color-fg-accent);
font-size: var(--font-xs);
}

View File

@@ -23,7 +23,7 @@
}
.inkGridApFocused {
color: var(--color-accent);
color: var(--color-fg-accent);
font-weight: var(--weight-bold);
text-decoration: underline;
}

View File

@@ -75,7 +75,7 @@
width: 100%;
align-items: center;
border-radius: var(--radius-box);
background-color: var(--color-accent-low);
background-color: var(--color-bg-accent);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
gap: var(--s-2);
@@ -179,7 +179,7 @@
padding: 0;
border: none;
background-color: transparent;
color: var(--color-accent);
color: var(--color-fg-accent);
font-size: var(--font-md);
font-weight: var(--weight-bold);
outline: initial;
@@ -245,8 +245,8 @@
.patch {
border-radius: var(--radius-selector);
background-color: var(--color-accent-low);
color: var(--color-accent-high);
background-color: var(--color-bg-accent);
color: var(--color-fg-accent);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
padding-inline: var(--s-2);

View File

@@ -15,7 +15,7 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Image } from "~/components/Image";
import { Image, SpecialWeaponImage, SubWeaponImage } from "~/components/Image";
import { weaponToSelectedWeapon } from "~/components/layout/WeaponSearch";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
@@ -52,8 +52,6 @@ import {
ANALYZER_URL,
mainWeaponImageUrl,
navIconUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
weaponParamsPage,
} from "~/utils/urls";
import { LinkButton, SendouButton } from "../../../components/elements/Button";
@@ -415,11 +413,9 @@ function BuildAnalyzerPage() {
title={t("analyzer:stat.category.sub")}
summaryRightContent={
<div className={styles.weaponInfoBadge}>
<Image
path={subWeaponImageUrl(analyzed.weapon.subWeaponSplId)}
width={20}
height={20}
alt={t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
<SubWeaponImage
subWeaponId={analyzed.weapon.subWeaponSplId}
size={20}
/>
{t(`weapons:SUB_${analyzed.weapon.subWeaponSplId}`)}
</div>
@@ -504,15 +500,9 @@ function BuildAnalyzerPage() {
title={t("analyzer:stat.category.special")}
summaryRightContent={
<div className={styles.weaponInfoBadge}>
<Image
path={specialWeaponImageUrl(
analyzed.weapon.specialWeaponSplId,
)}
width={20}
height={20}
alt={t(
`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`,
)}
<SpecialWeaponImage
specialWeaponId={analyzed.weapon.specialWeaponSplId}
size={20}
/>
{t(`weapons:SPECIAL_${analyzed.weapon.specialWeaponSplId}`)}
</div>
@@ -1341,12 +1331,7 @@ function EffectsSelector({
) : effect.type === "AURA" ? (
<span className="text-xs font-bold">AURA</span>
) : (
<Image
path={specialWeaponImageUrl(15)}
alt={t("weapons:SPECIAL_15")}
height={32}
width={32}
/>
<SpecialWeaponImage specialWeaponId={15} size={32} />
)}
</div>
<div>
@@ -1721,12 +1706,7 @@ function DamageTable({
<td>
<div className="stack horizontal xs items-center">
{damageIsSubWeaponDamage(val) ? (
<Image
alt=""
path={subWeaponImageUrl(val.subWeaponId)}
width={12}
height={12}
/>
<SubWeaponImage subWeaponId={val.subWeaponId} size={12} />
) : null}{" "}
{t(typeRowName as any)}{" "}
{damageIsSubWeaponDamage(val) && val.type === "SPLASH" ? (

View File

@@ -15,6 +15,6 @@
}
.bar {
background-color: var(--color-text-accent);
background-color: var(--color-fg-accent);
height: 100%;
}

View File

@@ -31,7 +31,7 @@
}
.cardRanked {
box-shadow: inset 0 -3px 0 var(--color-accent);
box-shadow: inset 0 -3px 0 var(--color-fg-accent);
}
.imgContainer {

View File

@@ -16,10 +16,10 @@ avatar slot down to the entry icons */
justify-content: center;
width: 2.5em;
height: 2.25em;
background-color: var(--color-text-accent);
background-color: var(--color-fill-accent);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
color: var(--color-fg-on-accent);
line-height: 1;
}

View File

@@ -47,8 +47,8 @@
top: 28px;
left: 50%;
transform: translateX(-50%);
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
padding: 1px 4px;
@@ -58,7 +58,7 @@
.pronounsTag {
background-color: var(--color-bg-higher);
color: var(--color-text-accent);
color: var(--color-fg-accent);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
padding: 1px 5px;
@@ -69,7 +69,7 @@
.messageUser {
font-weight: var(--weight-semi);
font-size: var(--font-sm);
color: oklch(from var(--color-text-accent) l c var(--chat-hue));
color: oklch(from var(--color-fg-accent) l c var(--chat-hue));
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
@@ -88,8 +88,8 @@
.sendButton.sendButton {
border-radius: var(--radius-full);
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
flex-shrink: 0;
&:disabled {
@@ -121,7 +121,7 @@
}
.roomLink {
color: var(--color-text-accent);
color: var(--color-fg-accent);
text-decoration: underline;
word-break: break-all;
}

View File

@@ -73,7 +73,7 @@
gap: var(--s-1);
height: 36px;
padding: 0 var(--s-2);
color: var(--color-text-inverse);
color: var(--color-text-on-light);
font-weight: var(--weight-semi);
&[data-slot-color="yellow"] {
@@ -331,7 +331,7 @@
.inkTimeLabel {
font-size: var(--font-2xs);
color: var(--color-text-second);
color: var(--color-fg-second);
text-align: center;
white-space: nowrap;
padding: var(--s-0-5) var(--s-0-5);

View File

@@ -1,7 +1,12 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SendouSwitch } from "~/components/elements/Switch";
import { Image, WeaponImage } from "~/components/Image";
import {
Image,
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import type {
@@ -9,11 +14,7 @@ import type {
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
abilityImageUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
} from "~/utils/urls";
import { abilityImageUrl } from "~/utils/urls";
import { LETHAL_DAMAGE } from "../comp-analyzer-constants";
import {
useSingleWeaponCombos,
@@ -201,9 +202,8 @@ function WeaponIcon({
}: WeaponIconProps) {
if (isSubWeapon) {
return (
<Image
path={subWeaponImageUrl(subWeaponId)}
alt=""
<SubWeaponImage
subWeaponId={subWeaponId}
size={18}
className={styles.subSpecialWeaponIcon}
/>
@@ -212,9 +212,8 @@ function WeaponIcon({
if (isSpecialWeapon) {
return (
<Image
path={specialWeaponImageUrl(specialWeaponId)}
alt=""
<SpecialWeaponImage
specialWeaponId={specialWeaponId}
size={18}
className={styles.subSpecialWeaponIcon}
/>

View File

@@ -80,7 +80,6 @@ export function RangeVisualization({ weaponIds }: RangeVisualizationProps) {
maxRange={maxRange}
minY={minY}
maxY={maxY}
weaponIds={weaponIds}
/>
</div>
) : null}
@@ -98,7 +97,6 @@ interface TrajectoryChartProps {
maxRange: number;
minY: number;
maxY: number;
weaponIds: MainWeaponId[];
}
function TrajectoryChart({
@@ -107,7 +105,6 @@ function TrajectoryChart({
maxRange,
minY,
maxY,
weaponIds,
}: TrajectoryChartProps) {
const chartWidth = 600;
const chartHeight = 200;
@@ -180,12 +177,11 @@ function TrajectoryChart({
return (
<div className={styles.chartContainer}>
<div className={styles.weaponLegend}>
{weapons.map((weapon, index) => {
const slotIndex = weaponIds.indexOf(weapon.weaponId);
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
{weapons.map((weapon) => {
const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length];
return (
<div
key={`${weapon.weaponId}-${index}`}
key={`${weapon.weaponId}-${weapon.slot}`}
className={styles.weaponLegendItem}
>
<WeaponImage
@@ -262,13 +258,12 @@ function TrajectoryChart({
})}
{/* Weapon trajectories */}
{weapons.map((weapon, index) => {
{weapons.map((weapon) => {
if (!weapon.trajectory) return null;
const slotIndex = weaponIds.indexOf(weapon.weaponId);
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length];
return (
<path
key={`${weapon.weaponId}-${index}`}
key={`${weapon.weaponId}-${weapon.slot}`}
d={trajectoryToPath(weapon.trajectory)}
fill="none"
stroke={color}
@@ -278,16 +273,15 @@ function TrajectoryChart({
})}
{/* Blast radius circles */}
{weapons.map((weapon, index) => {
{weapons.map((weapon) => {
if (!weapon.blastRadius || !weapon.trajectory) return null;
const groundPoint = getGroundIntersection(weapon.trajectory);
if (!groundPoint) return null;
const slotIndex = weaponIds.indexOf(weapon.weaponId);
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length];
const radiusPixels = xScale(weapon.blastRadius);
return (
<circle
key={`blast-${weapon.weaponId}-${index}`}
key={`blast-${weapon.weaponId}-${weapon.slot}`}
cx={xScale(groundPoint.z)}
cy={yScale(0)}
r={radiusPixels}

View File

@@ -83,6 +83,22 @@ describe("SelectedWeapons", () => {
expect(kitIcons.length).toBeGreaterThan(0);
});
test("renders a row per copy when the same weapon is picked twice", async () => {
const screen = await renderSelectedWeapons({
selectedWeaponIds: [0, 0],
});
const rows = screen.container.querySelectorAll(
'[data-testid^="selected-weapon-"]',
);
expect(rows.length).toBe(2);
const emptySlots = screen.container.querySelectorAll(
'[class*="weaponNameEmpty"]',
);
expect(emptySlots.length).toBe(MAX_WEAPONS - 2);
});
test("shows empty slots for remaining positions", async () => {
const screen = await renderSelectedWeapons({
selectedWeaponIds: [0],
@@ -135,6 +151,21 @@ describe("SelectedWeapons", () => {
expect(onRemove).toHaveBeenCalledWith(1);
});
test("calls onRemove with the clicked index when the same weapon is picked twice", async () => {
const onRemove = vi.fn();
const screen = await renderSelectedWeapons({
selectedWeaponIds: [0, 0],
onRemove,
});
const removeButtons = screen.container.querySelectorAll(
'[class*="removeButton"]',
);
(removeButtons[1] as HTMLElement).click();
expect(onRemove).toHaveBeenCalledWith(1);
});
test("does not render remove button for empty slots", async () => {
const screen = await renderSelectedWeapons({
selectedWeaponIds: [],

View File

@@ -8,6 +8,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
@@ -15,15 +16,18 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
import { nanoid } from "nanoid";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Image, WeaponImage } from "~/components/Image";
import {
Image,
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
abilityImageUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
} from "~/utils/urls";
import { abilityImageUrl } from "~/utils/urls";
import { MAX_WEAPONS } from "../comp-analyzer-constants";
import styles from "./SelectedWeapons.module.css";
@@ -39,6 +43,14 @@ export function SelectedWeapons({
onReorder,
}: SelectedWeaponsProps) {
const { t } = useTranslation(["weapons", "analyzer"]);
const [rowsState, setRows] = useState(() =>
reconcileRows([], selectedWeaponIds),
);
const rows = reconcileRows(rowsState, selectedWeaponIds);
if (rows !== rowsState) {
setRows(rows);
}
const sensors = useSensors(
useSensor(PointerSensor),
@@ -50,16 +62,16 @@ export function SelectedWeapons({
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId);
const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId);
if (!over || active.id === over.id) return;
const newIds = [...selectedWeaponIds];
const [removed] = newIds.splice(oldIndex, 1);
newIds.splice(newIndex, 0, removed);
const oldIndex = rows.findIndex((row) => row.id === active.id);
const newIndex = rows.findIndex((row) => row.id === over.id);
onReorder(newIds);
}
if (oldIndex === -1 || newIndex === -1) return;
const newRows = arrayMove(rows, oldIndex, newIndex);
setRows(newRows);
onReorder(newRows.map((row) => row.weaponId));
};
const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length;
@@ -73,13 +85,14 @@ export function SelectedWeapons({
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedWeaponIds}
items={rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
{selectedWeaponIds.map((weaponId, index) => (
{rows.map((row, index) => (
<SortableWeaponRow
key={weaponId}
weaponId={weaponId}
key={row.id}
rowId={row.id}
weaponId={row.weaponId}
index={index}
onRemove={onRemove}
showDragHandle={showDragHandle}
@@ -104,7 +117,36 @@ export function SelectedWeapons({
);
}
interface WeaponRow {
/** Identity of the slot rather than of the weapon, so rows of the same weapon stay apart while dragging */
id: string;
weaponId: MainWeaponId;
}
/** Returns the same rows when they already match, so a reorder of identical weapons is not undone */
function reconcileRows(
rows: WeaponRow[],
weaponIds: MainWeaponId[],
): WeaponRow[] {
const alreadyMatching =
rows.length === weaponIds.length &&
rows.every((row, index) => row.weaponId === weaponIds[index]);
if (alreadyMatching) return rows;
const unclaimed = [...rows];
return weaponIds.map((weaponId) => {
const matchIndex = unclaimed.findIndex((row) => row.weaponId === weaponId);
if (matchIndex === -1) {
return { id: nanoid(), weaponId };
}
return unclaimed.splice(matchIndex, 1)[0];
});
}
interface SortableWeaponRowProps {
rowId: string;
weaponId: MainWeaponId;
index: number;
onRemove: (index: number) => void;
@@ -112,6 +154,7 @@ interface SortableWeaponRowProps {
}
function SortableWeaponRow({
rowId,
weaponId,
index,
onRemove,
@@ -125,7 +168,7 @@ function SortableWeaponRow({
transform,
transition,
isDragging,
} = useSortable({ id: weaponId });
} = useSortable({ id: rowId });
const style = {
transform: CSS.Transform.toString(transform),
@@ -173,16 +216,11 @@ function SortableWeaponRow({
</div>
<div className={styles.subSpecialContainer}>
<div className={styles.kitIcon}>
<Image
path={subWeaponImageUrl(params.subWeaponId)}
alt={t(`weapons:SUB_${params.subWeaponId}`)}
size={24}
/>
<SubWeaponImage subWeaponId={params.subWeaponId} size={24} />
</div>
<div className={styles.kitIcon}>
<Image
path={specialWeaponImageUrl(params.specialWeaponId)}
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
<SpecialWeaponImage
specialWeaponId={params.specialWeaponId}
size={24}
/>
</div>

View File

@@ -1,8 +1,6 @@
// note: dev only component, not used in production code
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { specialWeaponImageUrl } from "~/utils/urls";
import { SpecialWeaponImage } from "~/components/Image";
import {
getSpecialsWithRange,
type SpecialWeaponWithRange,
@@ -15,8 +13,6 @@ const RANGE_TYPE_COLOR: Record<SpecialWeaponWithRange["rangeType"], string> = {
};
export function SpecialRangeVisualization() {
const { t } = useTranslation(["weapons"]);
const specials = getSpecialsWithRange();
if (specials.length === 0) {
return null;
@@ -53,12 +49,9 @@ export function SpecialRangeVisualization() {
return (
<div key={special.specialWeaponId} className={styles.row}>
<Image
path={specialWeaponImageUrl(special.specialWeaponId)}
width={28}
height={28}
alt={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
title={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
<SpecialWeaponImage
specialWeaponId={special.specialWeaponId}
size={28}
/>
<div className={styles.track}>
{blastWidth > 0 ? (

View File

@@ -1,12 +1,11 @@
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image";
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls";
import {
SPECIAL_CATEGORY_ORDER,
SPECIAL_WEAPON_CATEGORIES,
@@ -86,11 +85,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) {
className={styles.categoryItem}
data-first={index === 0}
>
<Image
path={subWeaponImageUrl(item.subId)}
alt={t(`weapons:SUB_${item.subId}`)}
size={20}
/>
<SubWeaponImage subWeaponId={item.subId} size={20} />
<span className={styles.categoryName}>
{t(`analyzer:comp.subCategory.${item.category}`)}
</span>
@@ -109,11 +104,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) {
className={styles.categoryItem}
data-first={index === 0}
>
<Image
path={specialWeaponImageUrl(item.specialId)}
alt={t(`weapons:SPECIAL_${item.specialId}`)}
size={20}
/>
<SpecialWeaponImage specialWeaponId={item.specialId} size={20} />
<span className={styles.categoryName}>
{t(`analyzer:comp.specialCategory.${item.category}`)}
</span>

View File

@@ -85,7 +85,7 @@
transition: border-color 0.1s;
&:hover {
border-color: var(--color-text-accent);
border-color: var(--color-fg-accent);
}
&:disabled {

View File

@@ -1,5 +1,10 @@
import { useTranslation } from "react-i18next";
import { Image, WeaponImage } from "~/components/Image";
import {
Image,
SpecialWeaponImage,
SubWeaponImage,
WeaponImage,
} from "~/components/Image";
import { Label } from "~/components/Label";
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import type {
@@ -13,11 +18,7 @@ import {
subWeaponIds,
weaponCategories,
} from "~/modules/in-game-lists/weapon-ids";
import {
specialWeaponImageUrl,
subWeaponImageUrl,
weaponCategoryUrl,
} from "~/utils/urls";
import { weaponCategoryUrl } from "~/utils/urls";
import { MAX_WEAPONS } from "../comp-analyzer-constants";
import type { CategorizationType } from "../comp-analyzer-types";
import styles from "./WeaponGrid.module.css";
@@ -112,9 +113,7 @@ export function WeaponGrid({
{groupedWeapons.map((group) => (
<div key={group.key} className={styles.categorySection}>
<div className={styles.categoryHeader}>
{group.iconPath ? (
<Image path={group.iconPath} alt="" size={24} />
) : null}
<WeaponGroupIcon icon={group.icon} />
<span className={styles.categoryName}>
{group.name.startsWith("SUB_") ||
group.name.startsWith("SPECIAL_")
@@ -155,19 +154,35 @@ export function WeaponGrid({
);
}
type WeaponGroupIconValue =
| { kind: "category"; name: (typeof weaponCategories)[number]["name"] }
| { kind: "sub"; id: SubWeaponId }
| { kind: "special"; id: SpecialWeaponId };
interface WeaponGroup {
key: string;
name: string;
iconPath: string | null;
icon: WeaponGroupIconValue;
weaponIds: MainWeaponId[];
}
function WeaponGroupIcon({ icon }: { icon: WeaponGroupIconValue }) {
switch (icon.kind) {
case "category":
return <Image path={weaponCategoryUrl(icon.name)} alt="" size={24} />;
case "sub":
return <SubWeaponImage subWeaponId={icon.id} size={24} />;
case "special":
return <SpecialWeaponImage specialWeaponId={icon.id} size={24} />;
}
}
function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
if (categorization === "category") {
return weaponCategories.map((category) => ({
key: category.name,
name: category.name.toLowerCase(),
iconPath: weaponCategoryUrl(category.name),
icon: { kind: "category" as const, name: category.name },
weaponIds: [...category.weaponIds] as MainWeaponId[],
}));
}
@@ -183,7 +198,7 @@ function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
return {
key: `sub-${subId}`,
name: `SUB_${subId}`,
iconPath: subWeaponImageUrl(subId as SubWeaponId),
icon: { kind: "sub" as const, id: subId as SubWeaponId },
weaponIds: weaponsWithSub,
};
})
@@ -200,7 +215,7 @@ function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
return {
key: `special-${specialId}`,
name: `SPECIAL_${specialId}`,
iconPath: specialWeaponImageUrl(specialId as SpecialWeaponId),
icon: { kind: "special" as const, id: specialId as SpecialWeaponId },
weaponIds: weaponsWithSpecial,
};
})

View File

@@ -11,6 +11,14 @@ const RANGE_COMPARISONS: [MainWeaponId, MainWeaponId][] = [
[2070, 7010], // Snipewriter 5H > Tri-Stringer
];
describe("getWeaponsWithRange", () => {
test("keeps a separate slot for each copy of a duplicated weapon", () => {
const weapons = getWeaponsWithRange([40, 40, 70]);
expect(weapons.map((weapon) => weapon.slot)).toEqual([0, 1, 2]);
});
});
describe("weapon range comparisons", () => {
test.each(RANGE_COMPARISONS)(
"weapon %i has more range than weapon %i",

View File

@@ -217,6 +217,8 @@ export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
export interface WeaponWithRange {
weaponId: MainWeaponId;
/** Position in the comp, kept through the filtering so duplicates stay apart */
slot: number;
range: number;
blastRadius?: number;
rangeType: "calculated" | "direct" | "unsupported";
@@ -227,10 +229,11 @@ export function getWeaponsWithRange(
weaponIds: MainWeaponId[],
): WeaponWithRange[] {
return weaponIds
.map((weaponId) => {
.map((weaponId, slot) => {
const result = getWeaponRange(weaponId);
return {
weaponId,
slot,
...result,
};
})

View File

@@ -0,0 +1,351 @@
.preview {
padding: var(--s-4);
border-radius: var(--radius-box);
background-color: var(--color-bg);
color: var(--color-text);
}
.sectionTitle {
font-size: var(--font-xl);
border-bottom: 2px solid var(--color-border);
}
.tokenName {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
word-break: break-all;
}
.swatch {
flex-shrink: 0;
width: 1rem;
height: 1rem;
border: 1px solid var(--color-border-high);
border-radius: var(--radius-selector);
}
.strikethrough {
text-decoration: line-through;
}
.annotated {
container-type: inline-size;
position: relative;
}
.annotatedGrid {
display: grid;
gap: var(--s-6);
@container (width > 44rem) {
grid-template-columns: minmax(0, 30rem) 18rem;
justify-content: space-between;
gap: var(--s-24);
align-items: center;
}
}
.stage {
padding: var(--s-6);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
}
.notes {
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: 0;
margin: 0;
list-style: none;
}
.note {
display: flex;
gap: var(--s-2);
padding: var(--s-1-5);
border-radius: var(--radius-field);
cursor: default;
&[data-active="true"] {
background-color: var(--color-bg-high);
}
}
.marker,
.pin {
display: grid;
flex-shrink: 0;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-full);
background-color: var(--color-text);
color: var(--color-text-inverse);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
}
.pin {
position: absolute;
translate: -50% -50%;
pointer-events: none;
}
.arrows {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.arrow {
fill: none;
stroke: var(--color-text-high);
stroke-width: 1.5;
opacity: 0.6;
&[data-active="true"] {
stroke: var(--color-text);
stroke-width: 2;
opacity: 1;
}
}
.arrowHead {
fill: var(--color-text-high);
&[data-active="true"] {
fill: var(--color-text);
}
}
.targetHighlight {
position: absolute;
outline: 2px dashed var(--color-text);
outline-offset: 3px;
border-radius: var(--radius-field);
pointer-events: none;
}
.noteTarget {
display: inline-block;
width: fit-content;
}
.mockCard {
padding: var(--s-4);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
}
.mockTitle {
width: fit-content;
color: var(--color-text);
font-size: var(--font-md);
font-weight: var(--weight-bold);
}
.mockMeta {
width: fit-content;
color: var(--color-text-high);
font-size: var(--font-sm);
}
.mockDivider {
width: 100%;
margin: 0;
border: none;
border-top: var(--border-style);
}
.mockNeutralChip {
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.mockAccentChip {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-selector);
background-color: var(--color-bg-accent);
color: var(--color-fg-accent);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.mockTabs {
display: flex;
gap: var(--s-4);
border-bottom: var(--border-style);
}
.mockTab {
padding-block: var(--s-1-5);
margin-bottom: calc(var(--border-width) * -1);
border-bottom: var(--border-width) solid transparent;
color: var(--color-text-high);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
&[data-active] {
border-color: var(--color-fg-accent);
color: var(--color-fg-accent);
}
}
.mockRow,
.mockRowHighlighted {
display: flex;
justify-content: space-between;
padding: var(--s-1-5) var(--s-2);
border-radius: var(--radius-field);
font-size: var(--font-sm);
}
.mockRowHighlighted {
background-color: var(--color-bg-accent);
color: var(--color-text);
font-weight: var(--weight-semi);
}
.mockLink {
width: fit-content;
color: var(--color-fg-accent);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
text-decoration: underline;
}
.mockFocused {
width: fit-content;
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
outline: var(--focus-ring);
outline-offset: 2px;
font-size: var(--font-sm);
}
.mockSecondBadge {
padding: 0 var(--s-1-5);
border-radius: var(--radius-selector);
background-color: var(--color-fill-second);
color: var(--color-fg-on-second);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
}
.mockStat {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
}
.mockTrack {
height: 0.5rem;
border-radius: var(--radius-full);
background-color: var(--color-bg-higher);
}
.mockBar {
height: 100%;
border-radius: var(--radius-full);
background-color: var(--color-fg-second);
}
.mockIconText {
display: inline-flex;
align-items: center;
gap: var(--s-1);
width: fit-content;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.mockSecondTint {
padding: var(--s-2) var(--s-3);
border-radius: var(--radius-field);
background-color: var(--color-bg-second);
color: var(--color-text);
font-size: var(--font-sm);
}
.mockErrorInput {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 16rem;
height: var(--field-size);
padding-inline: var(--field-padding);
border: var(--border-width) solid var(--color-error);
border-radius: var(--radius-field);
background-color: var(--color-bg);
font-size: var(--font-sm);
& svg {
color: var(--color-error);
}
}
.pairings {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--s-3);
}
.pairing {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-2);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
}
.pairingSample {
padding: var(--s-4) var(--s-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
font-size: var(--font-lg);
font-weight: var(--weight-bold);
}
.verdict,
.contrastBadge {
padding: 0 var(--s-1-5);
border-radius: var(--radius-selector);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.verdict[data-verdict="do"],
.contrastBadge[data-level="pass"] {
background-color: var(--color-success-low);
color: var(--color-success-high);
}
.verdict[data-verdict="dont"],
.contrastBadge[data-level="fail"] {
background-color: var(--color-error-low);
color: var(--color-error-high);
}
.contrastBadge[data-level="large"] {
background-color: var(--color-warning-low);
color: var(--color-warning-high);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { Ability } from "~/components/Ability";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
@@ -202,6 +203,7 @@ export default function ComponentsShowcasePage() {
return (
<Main className="stack lg">
<h1>Components</h1>
<Link to="/components/colors">Color tokens →</Link>
{SECTIONS.map(({ id, component: Component }) => (
<Component key={id} id={id} />
))}

View File

@@ -55,10 +55,12 @@
}
.countBadgeAction {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
}
.alertBadge {
color: var(--color-warning);
background-color: var(--color-warning-low);
border: 1.5px solid var(--color-warning);
color: var(--color-warning-high);
}

View File

@@ -83,12 +83,7 @@ export function CompGraphic({
<GraphicSectionDivider>
{t("analyzer:comp.weaponRanges")}
</GraphicSectionDivider>
<RangeChart
weapons={weaponsWithRange.map((weapon) => ({
...weapon,
slot: weaponIds.indexOf(weapon.weaponId),
}))}
/>
<RangeChart weapons={weaponsWithRange} />
</>
) : null}
{showCombos && topCombos.length > 0 ? (

View File

@@ -2,7 +2,7 @@
--graphic-row-bg: var(--color-bg-high);
--graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent);
--graphic-text-dim: var(--color-text-high);
--graphic-accent: var(--color-text-accent);
--graphic-accent: var(--color-fg-accent);
--graphic-first: oklch(87% 0.13 85);
--graphic-second: oklch(83% 0.015 268);
--graphic-third: oklch(74% 0.09 55);
@@ -17,7 +17,7 @@
background:
radial-gradient(
ellipse 90% 45% at 50% -5%,
color-mix(in oklch, var(--color-accent) 25%, transparent),
color-mix(in oklch, var(--color-fg-accent) 25%, transparent),
transparent 70%
),
var(--color-bg);

View File

@@ -17,7 +17,7 @@
padding: 0;
border: none;
background-color: transparent;
color: var(--color-text-accent);
color: var(--color-fg-accent);
font-size: var(--font-md);
font-weight: var(--weight-bold);
outline: initial;

View File

@@ -18,7 +18,7 @@
content: "";
position: absolute;
inset: 0;
background-color: var(--color-second);
background-color: var(--color-fg-second);
opacity: 0.3;
pointer-events: none;
}

Some files were not shown because too many files have changed in this diff Show More