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

# Conflicts:
#	app/features/scanner/components/EventsSummary.module.css
#	app/features/scanner/components/ScannerApp.module.css
#	app/features/scanner/components/ScannerChrome.module.css
#	app/features/scanner/components/SourceSelect.module.css
#	app/features/scanner/components/VodPage.module.css
This commit is contained in:
Kalle
2026-09-23 21:19:58 +03:00
530 changed files with 14481 additions and 4445 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

@@ -0,0 +1,99 @@
.typeBadge {
font-size: var(--font-xs);
font-weight: var(--weight-extra);
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: right;
color: var(--graphic-accent);
}
.details {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
}
.weapons {
display: flex;
flex-wrap: wrap;
gap: var(--s-1);
padding: var(--s-1-5);
background-color: oklch(0% 0 0 / 0.3);
border-radius: var(--radius-field);
}
.pills {
display: flex;
flex-shrink: 0;
gap: var(--s-1-5);
}
.pill {
padding: var(--s-1) var(--s-2);
font-size: var(--font-2xs);
font-weight: var(--weight-extra);
color: var(--graphic-text-dim);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-full);
}
.textBox {
padding: var(--s-3);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.members {
/* keep in sync with MEMBER_WEAPON_SIZE */
--member-weapon-size: 28px;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
/* rows without weapons stay as tall as the ones with them */
.member {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: minmax(
calc(var(--member-weapon-size) + 2 * var(--s-1)),
auto
);
align-items: center;
gap: var(--s-2);
padding: var(--s-1-5) var(--s-2);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
& .weapons {
padding: var(--s-1);
}
}
.memberName {
display: flex;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
line-height: 1.3;
}
/* claims the free space like the header title, so a name the raster measures wider still fits */
.memberUsername {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,188 @@
import { useTranslation } from "react-i18next";
import type { Tables } from "~/db/tables";
import {
GraphicContainer,
GraphicDateSubtitle,
GraphicFooter,
GraphicHeader,
GraphicSiteUrl,
GraphicTitle,
} from "~/features/img-export/components/Graphic";
import type { LFGType } from "~/features/lfg/lfg-constants";
import { lfgSearchParams } from "~/features/lfg/lfg-search-params";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { LFG_PAGE, resolveAvatarUrl } from "~/utils/urls";
import { Avatar } from "./Avatar";
import { Flag } from "./Flag";
import { WeaponImage } from "./Image";
import styles from "./LFGPostGraphic.module.css";
const GRAPHIC_WIDTH = 480;
const WEAPON_SIZE = 40;
const MEMBER_WEAPON_SIZE = 28;
interface LFGPostGraphicUser {
username: string;
discordId: string;
discordAvatar: Tables["User"]["discordAvatar"];
customAvatarUrl: string | null;
country: string | null;
weaponPool: Array<{
weaponSplId: MainWeaponId;
isFavorite: boolean | number;
isTenStar: boolean | number;
}>;
}
export interface LFGPostGraphicPost {
id: number;
type: LFGType;
text: string;
updatedAt: number;
timezone: string;
languages: UnifiedLanguageCode[] | null;
author: LFGPostGraphicUser;
team: {
name: string;
avatarUrl: string | null;
members: Array<LFGPostGraphicUser & { id: number }>;
} | null;
}
/** Search param carrying the post id so the QR code lands on the post itself */
export const lfgPostGraphicPath = (postId: number) =>
lfgSearchParams.href(LFG_PAGE, { post: postId });
export function LFGPostGraphic({ post }: { post: LFGPostGraphicPost }) {
return (
<GraphicContainer width={GRAPHIC_WIDTH}>
<PostHeader post={post} />
<div className={styles.details}>
{post.team ? null : <WeaponsRow weapons={post.author.weaponPool} />}
<DetailPills post={post} />
</div>
{post.team ? <TeamMembersList members={post.team.members} /> : null}
<div className={styles.textBox}>{post.text}</div>
<GraphicFooter>
<GraphicSiteUrl path={lfgPostGraphicPath(post.id)} />
</GraphicFooter>
</GraphicContainer>
);
}
function PostHeader({ post }: { post: LFGPostGraphicPost }) {
const { t } = useTranslation(["lfg"]);
const avatarProps = post.team
? {
avatarUrl: post.team.avatarUrl ?? undefined,
identiconInput: post.team.name,
}
: {
avatarUrl: userAvatarUrl(post.author),
identiconInput: post.author.discordId,
};
return (
<GraphicHeader
{...avatarProps}
titleRow={
<>
{!post.team && post.author.country ? (
<Flag countryCode={post.author.country} tiny />
) : null}
<GraphicTitle>{post.team?.name ?? post.author.username}</GraphicTitle>
</>
}
subtitle={
<GraphicDateSubtitle date={databaseTimestampToDate(post.updatedAt)} />
}
trailing={
<div className={styles.typeBadge}>{t(`lfg:types.${post.type}`)}</div>
}
alignTrailingWithTitle
/>
);
}
function WeaponsRow({
weapons,
size = WEAPON_SIZE,
}: {
weapons: LFGPostGraphicUser["weaponPool"];
size?: number;
}) {
if (weapons.length === 0) return null;
return (
<div className={styles.weapons}>
{weapons.map((weapon) => (
<WeaponImage key={weapon.weaponSplId} weapon={weapon} size={size} />
))}
</div>
);
}
function DetailPills({ post }: { post: LFGPostGraphicPost }) {
return (
<div className={styles.pills}>
<div className={styles.pill}>{timezoneOffsetLabel(post.timezone)}</div>
{post.languages && post.languages.length > 0 ? (
<div className={styles.pill}>{languagesLabel(post.languages)}</div>
) : null}
</div>
);
}
function TeamMembersList({
members,
}: {
members: NonNullable<LFGPostGraphicPost["team"]>["members"];
}) {
return (
<div className={styles.members}>
{members.map((member) => (
<div key={member.id} className={styles.member}>
<Avatar
url={userAvatarUrl(member)}
identiconInput={member.discordId}
size="xxs"
alt=""
/>
<div className={styles.memberName}>
{member.country ? <Flag countryCode={member.country} tiny /> : null}
<span className={styles.memberUsername}>{member.username}</span>
</div>
<WeaponsRow weapons={member.weaponPool} size={MEMBER_WEAPON_SIZE} />
</div>
))}
</div>
);
}
function userAvatarUrl(user: LFGPostGraphicUser) {
return resolveAvatarUrl({
customAvatarUrl: user.customAvatarUrl,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
size: "lg",
});
}
function languagesLabel(languages: UnifiedLanguageCode[]) {
return languages.join(" / ").toUpperCase();
}
/** e.g. "GMT+3", absolute rather than relative to the viewer since the image travels */
function timezoneOffsetLabel(timezone: string) {
const offsetPart = new Intl.DateTimeFormat("en", {
timeZone: timezone,
timeZoneName: "shortOffset",
})
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName");
return offsetPart?.value ?? timezone;
}

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 {
@@ -39,7 +40,7 @@ import { EventsList } from "./EventsList";
import { LinkButton } from "./elements/Button";
import { isOwnToggle } from "./elements/Popover";
import { Image } from "./Image";
import { LazyChatSidebar } from "./layout/LazyChatSidebar";
import { ChatSidebar } from "./layout/ChatSidebar";
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
import {
NotificationContent,
@@ -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);
@@ -649,7 +651,7 @@ function ChatPanel({
>
<div className={styles.panelDialog}>
{isOpen ? (
<LazyChatSidebar onClose={() => panelRef.current?.hidePopover()} />
<ChatSidebar onClose={() => panelRef.current?.hidePopover()} />
) : null}
</div>
</div>

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:
@@ -19,13 +31,17 @@
outline: none;
animation: zoom-in-95 300ms ease-out;
&::backdrop {
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 300ms ease-out;
@media (width < 600px) {
--dialog-padding-block: var(--s-4);
}
}
.blurredBackdrop::backdrop {
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 300ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
@@ -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,11 +8,18 @@ import {
type SendouButtonProps,
} from "~/components/elements/Button";
import { useHydrated } from "~/hooks/useHydrated";
import { useScrollLockWhileOpen } from "~/hooks/useScrollLock";
import {
useReportModalOpen,
useTopLayerViewTransitionStyle,
} from "~/utils/view-transition";
import styles from "./Dialog.module.css";
interface DialogElementProps {
id?: string;
className?: string;
/** The standard dialog backdrop: dimmed and blurred page behind. */
blurredBackdrop?: boolean;
isDismissable?: boolean;
onClose?: () => void;
"aria-label"?: string;
@@ -33,6 +40,7 @@ interface DialogElementProps {
*/
export function SendouModal({ ref, ...rest }: DialogElementProps) {
const isHydrated = useHydrated();
useReportModalOpen(isHydrated);
if (!isHydrated) return null;
return createPortal(
@@ -57,6 +65,7 @@ export function SendouModal({ ref, ...rest }: DialogElementProps) {
function DialogElement({
id,
className,
blurredBackdrop,
isDismissable,
onClose,
"aria-label": ariaLabel,
@@ -64,35 +73,67 @@ function DialogElement({
children,
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}
className={className}
style={topLayerStyle}
className={clsx(className, {
[styles.blurredBackdrop]: blurredBackdrop,
})}
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. */
@@ -182,6 +223,7 @@ function TriggeredDialog({
const dialogId = React.useId();
const dialogRef = React.useRef<HTMLDialogElement>(null);
const [open, setOpen] = React.useState(false);
useReportModalOpen(open);
const [contentKey, remountContent] = React.useReducer(
(key: number) => key + 1,
@@ -307,6 +349,7 @@ function dialogElementProps(
className: clsx(className, styles.modal, "scrollbar", {
[styles.fullScreenModal]: isFullScreen,
}),
blurredBackdrop: true,
isDismissable,
onClose,
"aria-label": ariaLabel,

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

@@ -6,16 +6,16 @@ import {
focusMoveForKey,
rovingFocusIndex,
} 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";
@@ -26,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;
}
@@ -43,9 +43,9 @@ export function SendouMenu({
popoverClassName,
eager,
}: SendouMenuProps) {
const uid = useAnchorSafeId();
const popoverId = `${uid}-menu`;
const anchorName = `--menu-anchor-${uid}`;
const popoverId = `${React.useId()}-menu`;
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
const topLayerStyle = useTopLayerViewTransitionStyle();
const [open, setOpen] = React.useState(false);
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -61,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")
@@ -113,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",
})}
@@ -130,10 +127,8 @@ export function SendouMenu({
tabIndex={-1}
className={clsx(styles.popover, "scrollbar", popoverClassName, {
[styles.scrolling]: scrolling,
[styles.opensLeft]: opensLeft,
})}
style={{ positionAnchor: anchorName } 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,20 +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 {
type AnchorPlacement,
useAnchorPositioning,
} from "./anchor-positioning";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
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
@@ -25,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
@@ -32,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({
@@ -85,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,
@@ -107,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);
@@ -117,6 +114,7 @@ export function SendouPopover({
const popoverRef = React.useRef<HTMLDivElement>(null);
const triggerContainerRef = React.useRef<HTMLSpanElement>(null);
const topLayerStyle = useTopLayerViewTransitionStyle();
const setOpen = (next: boolean) => {
if (!isControlled) {
@@ -151,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>) => {
@@ -187,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>
@@ -200,10 +199,9 @@ export function SendouPopover({
id={popoverId}
popover="auto"
className={clsx(styles.content, popoverClassName)}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={topLayerStyle}
role="dialog"
tabIndex={-1}
data-placement={placement}
onBeforeToggle={onBeforeToggle}
onToggle={onToggle}
onBlur={onBlur}
@@ -228,37 +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>) => {
@@ -278,7 +267,7 @@ export function SendouAnchoredPopover({
ref={popoverRef}
popover="auto"
className={styles.content}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={topLayerStyle}
role="dialog"
tabIndex={-1}
aria-label={ariaLabel}

View File

@@ -18,6 +18,7 @@ export function SendouRadioGroup({
isDisabled,
className,
"aria-label": ariaLabel,
onMouseDown,
children,
}: {
value: string | null;
@@ -25,12 +26,18 @@ export function SendouRadioGroup({
isDisabled?: boolean;
className?: string;
"aria-label"?: string;
onMouseDown?: (e: React.MouseEvent) => void;
children: React.ReactNode;
}) {
const name = React.useId();
return (
<div role="radiogroup" aria-label={ariaLabel} className={className}>
<div
role="radiogroup"
aria-label={ariaLabel}
className={className}
onMouseDown={onMouseDown}
>
<RadioGroupContext value={{ name, value, isDisabled, onChange }}>
{children}
</RadioGroupContext>

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

@@ -6,16 +6,16 @@ import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus";
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
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;
@@ -95,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
@@ -143,10 +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`;
@@ -201,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(() => {
@@ -537,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
@@ -573,7 +571,7 @@ export function SendouSelect<T extends object>({
id={popoverId}
popover="auto"
className={clsx(styles.popover, popoverClassName)}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={topLayerStyle}
onBeforeToggle={onPopoverBeforeToggle}
onToggle={onPopoverToggle}
onKeyDown={onPopoverKeyDown}
@@ -902,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) {
@@ -923,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

@@ -158,8 +158,9 @@ function RoomList({ onClose }: { onClose?: () => void }) {
const byRecency = (a: ChatRoomListItem, b: ChatRoomListItem) =>
(b.latestMessageAt ?? 0) - (a.latestMessageAt ?? 0) || b.id - a.id;
// the context's copy of the room over the route's: it carries the live unread count
const routeRooms = useCurrentRouteChatRooms().flatMap((entry) => {
const room = chatContext.roomForId(entry.roomId);
const room = chatContext.roomForId(entry.room.id);
return room ? [{ ...entry, room }] : [];
});
@@ -363,7 +364,7 @@ function SingleChatView({
const chatContext = useChatContext()!;
const roomDisplay = useRoomDisplay();
const routeLabel = useCurrentRouteChatRooms().find(
(entry) => entry.roomId === room?.id,
(entry) => entry.room.id === room?.id,
)?.label;
const otherRoomsUnreadCount = chatContext.rooms

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]}.`}
@@ -343,6 +343,7 @@ function GlobalSearchContent({
onChange={handleSearchTypeChange}
aria-label="Search type"
className={styles.searchTypeRadioGroup}
onMouseDown={preventFocusLeavingInput}
>
{SEARCH_TYPES.map((type) => (
<SendouRadio
@@ -418,6 +419,10 @@ function GlobalSearchContent({
);
}
function preventFocusLeavingInput(e: React.MouseEvent) {
e.preventDefault();
}
type SearchResult = NonNullable<SearchLoaderData>["results"][number];
function getResultKey(result: SearchResult): string {

View File

@@ -1,18 +0,0 @@
import * as React from "react";
import { Placeholder } from "../Placeholder";
const ChatSidebar = React.lazy(() =>
import("./ChatSidebar").then((module) => ({ default: module.ChatSidebar })),
);
export function preloadChatSidebar() {
void import("./ChatSidebar");
}
export function LazyChatSidebar({ onClose }: { onClose?: () => void }) {
return (
<React.Suspense fallback={<Placeholder />}>
<ChatSidebar onClose={onClose} />
</React.Suspense>
);
}

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";
@@ -75,19 +75,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}`;
@@ -99,7 +128,10 @@ function DevMenu() {
<button
type="button"
className={styles.menuButton}
onPointerEnter={() => setIsPreviewSuppressed(false)}
onPointerEnter={(event) => {
setIsPreviewSuppressed(false);
takeOverOnHover(openState, event);
}}
>
Dev
<ChevronDown className={styles.menuButtonChevron} />
@@ -107,8 +139,8 @@ function DevMenu() {
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={openState.isOpen}
onOpenChange={openState.onOpenChange}
eager
>
<div className={styles.menuContent}>
@@ -141,7 +173,7 @@ function DevMenu() {
to={item.url}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
openState.onOpenChange(false);
setIsPreviewSuppressed(true);
}}
>
@@ -156,7 +188,7 @@ function DevMenu() {
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
{!openState.isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{DEV_IMPERSONATE_ITEMS.map((item) => (
<Form
@@ -199,11 +231,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;
@@ -222,7 +255,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} />
@@ -230,8 +266,8 @@ function CategoryMenu({
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={openState.isOpen}
onOpenChange={openState.onOpenChange}
eager
>
<div className={styles.menuContent}>
@@ -242,7 +278,7 @@ function CategoryMenu({
prefetch="intent"
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
openState.onOpenChange(false);
setIsPreviewSuppressed(true);
}}
>
@@ -257,7 +293,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";
@@ -49,9 +50,9 @@ import { MobileNav } from "../MobileNav";
import { NotificationDot } from "../NotificationDot";
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
import { StreamListItems } from "../StreamListItems";
import { ChatSidebar } from "./ChatSidebar";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { LazyChatSidebar } from "./LazyChatSidebar";
import { LogInButtonContainer } from "./LogInButtonContainer";
import { authErrorSearchParams } from "./layout-search-params";
import { NotificationPopover, useNotifications } from "./NotificationPopover";
@@ -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 ?? (() => {});
@@ -451,7 +453,7 @@ export function Layout({
aria-label={t("common:chat.sidebar.title")}
onClose={() => setChatSidebarModalOpenAndSync(false)}
>
<LazyChatSidebar />
<ChatSidebar />
</SendouModal>
) : null}
<form
@@ -518,7 +520,7 @@ export function Layout({
showLeaderboard && styles.sidebarFuseSpace,
)}
>
<LazyChatSidebar onClose={() => setChatSidebarOpen(false)} />
<ChatSidebar onClose={() => setChatSidebarOpen(false)} />
</div>
) : null}
{typeof authError === "string" ? (

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

@@ -4,6 +4,7 @@ import type * as React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useWebHaptics } from "web-haptics/react";
import { useCooldown } from "~/hooks/useCooldown";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
@@ -43,6 +44,7 @@ interface MatchActionTabProps {
onSubmit?: (data: { winnerId: number; ko?: boolean }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
submitCooldownUntil?: number | null;
actionButtons?: React.ReactNode;
secondaryAction?: React.ReactNode;
}
@@ -56,6 +58,7 @@ export function MatchActionTab({
onSubmit,
isSubmitting,
setEnding,
submitCooldownUntil = null,
actionButtons,
secondaryAction,
}: MatchActionTabProps) {
@@ -64,8 +67,9 @@ export function MatchActionTab({
const [isKo, setIsKo] = useState(false);
const [confirming, setConfirming] = useState(false);
const { trigger } = useWebHaptics();
const cooldownSecondsLeft = useCooldown(submitCooldownUntil);
const canSubmit = winnerId !== null;
const canSubmit = winnerId !== null && cooldownSecondsLeft === 0;
const isOnTeam =
ownTeamId != null &&
@@ -177,7 +181,9 @@ export function MatchActionTab({
className={styles.submit}
testId="report-score-button"
>
{t("common:actions.submit")}
{cooldownSecondsLeft > 0
? `${t("common:actions.submit")} (${cooldownSecondsLeft})`
: t("common:actions.submit")}
</SendouButton>
</div>
)}

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

@@ -5,6 +5,8 @@ import { faker } from "../core/faker";
type Options = {
/** Members besides the owner, who is an admin of the association regardless. */
memberUserIds?: number[];
/** Members who can also share the association's invite link. */
managerUserIds?: number[];
};
/** Creates associations. `userId` is the owner, added as its admin by the repository. */
@@ -13,12 +15,26 @@ export const { create } = defineFactory({
name: faker.company.name(),
}),
insert: AssociationRepository.insert,
applyOptions: async (association, { memberUserIds }: Options) => {
for (const userId of memberUserIds ?? []) {
applyOptions: async (
association,
{ memberUserIds, managerUserIds }: Options,
) => {
for (const userId of [
...(memberUserIds ?? []),
...(managerUserIds ?? []),
]) {
await AssociationRepository.insertMember({
associationId: association.id,
userId,
});
}
for (const userId of managerUserIds ?? []) {
await AssociationRepository.updateMemberRole({
associationId: association.id,
userId,
role: "MANAGER",
});
}
},
});

View File

@@ -4,7 +4,10 @@ import type { DBBoolean } from "~/db/tables";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import type { CustomThemeVar } from "~/features/theme/theme-constants";
import type { TeamPickPool } from "~/features/tournament/tournament-constants";
import type {
TeamPickPool,
TournamentRoundSection,
} from "~/features/tournament/tournament-constants";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type {
@@ -133,7 +136,13 @@ export interface SeedingSnapshot {
export interface PreparedMaps {
authorId: number;
createdAt: number;
maps: Array<TournamentRoundMaps & { roundId: number; groupId: number }>;
/** Keyed by the local round ids of the bracket preview the maps were picked against; groups share one map list per round number. */
maps: Array<
TournamentRoundMaps & {
roundId: number;
section: TournamentRoundSection | null;
}
>;
eliminationTeamCount?: number;
}

View File

@@ -50,6 +50,7 @@ import type {
TOURNAMENT_STAGE_TYPES,
TournamentAuditLogType,
TournamentMapPickingStyle,
TournamentRoundSection,
TournamentStaffRole,
} from "~/features/tournament/tournament-constants";
import type {
@@ -680,8 +681,9 @@ export interface TournamentBadgeOwner {
}
/**
* Groups rounds together. In round-robin and swiss a group is a pool; in elimination it is a bracket
* (single: the bracket + optional consolation final, double: upper, lower + optional grand final).
* A set of participants of a stage that play among themselves: a pool of a round robin or swiss stage,
* or an elimination bracket whose rounds {@link TournamentRound.section} splits into winners, losers
* and finals. Every elimination stage has one group for now.
*/
export interface TournamentGroup {
id: GeneratedAlways<number>;
@@ -752,8 +754,11 @@ export interface TournamentResult {
export interface TournamentRound {
groupId: number;
id: GeneratedAlways<number>;
/** Restarts from 1 per group, and in an elimination group per {@link TournamentRound.section}. */
number: number;
stageId: number;
/** Part of the elimination group the round belongs to. `null` in round robin and swiss. */
section: TournamentRoundSection | null;
maps: JSONColumnType<TournamentRoundMaps>;
/** Datetime the round is played by default (leagues). Null = no default play time, the round is played whenever. */
defaultPlayTime: number | null;
@@ -913,6 +918,10 @@ export interface Friendship {
userOneId: number;
userTwoId: number;
createdAt: Generated<number>;
/** userOne keeps this friend at the top of their friends list */
isPinnedByUserOne: Generated<DBBoolean>;
/** userTwo keeps this friend at the top of their friends list */
isPinnedByUserTwo: Generated<DBBoolean>;
}
/** Pending friend request from one user to another. */
@@ -1269,7 +1278,8 @@ export interface Association {
export interface AssociationMember {
userId: number;
associationId: number;
role: "MEMBER" | "ADMIN";
/** MANAGER can also share the invite link, ADMIN (one per association) can also manage the members */
role: "MEMBER" | "MANAGER" | "ADMIN";
}
export interface Notification {

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

@@ -1,3 +1,15 @@
.thumbnailButton {
all: unset;
display: block;
cursor: pointer;
/* thumbnails have square corners, a rounded ring would leave the art's corners outside it */
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
}
.thumbnail {
cursor: pointer;
transition: all 0.2s ease;
@@ -7,38 +19,170 @@
}
}
.lightbox {
width: 100vw;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #fff;
outline: none;
overflow: hidden;
}
.lightboxBody {
width: 100%;
height: 100%;
display: grid;
place-items: center;
padding: var(--s-4);
cursor: zoom-out;
}
.lightboxFigure {
--lightbox-max-width: calc(100vw - 2 * var(--s-4));
--lightbox-max-height: calc(100dvh - 2 * var(--s-4));
position: relative;
margin: 0;
max-width: var(--lightbox-max-width);
max-height: var(--lightbox-max-height);
border-radius: var(--radius-box);
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: lightbox-zoom-in 200ms ease-out;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.lightboxFigureSized {
aspect-ratio: var(--aspect-ratio);
width: min(
var(--lightbox-max-width),
calc(var(--lightbox-max-height) * var(--aspect-ratio))
);
}
/* nothing to look at, so the details take over the figure instead of floating over the image */
.lightboxFigureFailed {
display: flex;
flex-direction: column;
width: min(var(--lightbox-max-width), 22rem);
background-color: rgba(0, 0, 0, 0.6);
.lightboxInfo {
position: static;
opacity: 1;
padding-block-start: var(--s-3);
background: none;
}
}
.lightboxFailed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-2);
min-height: 10rem;
padding: var(--s-4);
color: rgba(255, 255, 255, 0.7);
font-size: var(--font-sm);
text-align: center;
}
.lightboxPlaceholder {
display: block;
width: 100%;
height: auto;
max-width: var(--lightbox-max-width);
max-height: var(--lightbox-max-height);
object-fit: contain;
}
.lightboxImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 250ms ease-out;
}
.lightboxImgSettled {
opacity: 1;
}
.lightboxInfo {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
padding: var(--s-8) var(--s-4) var(--s-3);
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
text-align: center;
transition: opacity 200ms ease-out;
@media (hover: hover) {
opacity: 0;
.lightboxFigure:hover &,
.lightboxFigure:focus-within & {
opacity: 1;
}
}
}
.lightboxInfoHidden {
opacity: 0;
}
.lightboxDate {
font-size: var(--font-2xs);
color: rgba(255, 255, 255, 0.7);
}
.lightboxDescription {
font-size: var(--font-sm);
text-wrap: balance;
}
.tagsContainer {
display: flex;
gap: var(--s-1) var(--s-2);
justify-content: center;
flex-wrap: wrap;
}
.dialogTag {
background-color: #fff;
border-radius: var(--radius-box);
color: #000;
font-size: var(--font-2xs);
padding-inline: var(--s-1);
margin-block: var(--s-1) var(--s-0-5);
}
.dialogTagUser {
background-color: var(--color-accent);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
}
.dialogDescription {
font-size: var(--font-sm);
text-align: center;
color: #fff;
}
.dialogImg {
max-width: 100%;
max-height: 75vh;
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.tagsContainer {
display: flex;
gap: var(--s-0-5) var(--s-2);
justify-content: center;
flex-wrap: wrap;
margin-block-start: var(--s-0-5);
@keyframes lightbox-zoom-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -1,11 +1,11 @@
import clsx from "clsx";
import { SquarePen, Trash, Unlink, X } from "lucide-react";
import { ImageOff, SquarePen, Trash, Unlink } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouModal } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Pagination } from "~/components/Pagination";
import { artPage, newArtPage, userArtPage } from "~/features/art/art-urls";
@@ -24,6 +24,8 @@ import type { ListedArt } from "../art-types";
import { previewUrl } from "../art-utils";
import styles from "./ArtGrid.module.css";
const preloadedImageUrls = new Set<string>();
export function ArtGrid({
arts,
enablePreview = false,
@@ -33,6 +35,7 @@ export function ArtGrid({
enablePreview?: boolean;
showUploadDate?: boolean;
}) {
const [bigArtId, setBigArtId] = useSearchParam(artGridSearchParams, "big");
const {
itemsToDisplay,
everythingVisible,
@@ -44,13 +47,13 @@ export function ArtGrid({
} = usePagination({
items: arts,
pageSize: ART_PER_PAGE,
initialPage: pageOfArt(arts, bigArtId),
});
const [bigArtId, setBigArtId] = useSearchParam(artGridSearchParams, "big");
const isHydrated = useHydrated();
if (!isHydrated) return null;
const bigArt = itemsToDisplay.find((art) => art.id === bigArtId);
const bigArt = arts.find((art) => art.id === bigArtId);
return (
<>
@@ -84,69 +87,110 @@ export function ArtGrid({
}
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const [imageSettled, setImageSettled] = React.useState(false);
const dialogRef = React.useRef<HTMLDialogElement>(null);
const [infoVisible, setInfoVisible] = React.useState(true);
const [imageSettled, imageRef] = useImageSettled();
const [aspectRatio, placeholderRef] = useImageAspectRatio();
const { t } = useTranslation(["art"]);
const { formatter } = useDateTimeFormat({
year: "numeric",
month: "numeric",
day: "numeric",
});
const imageFailed = aspectRatio === "FAILED";
const dateText = formatter.format(databaseTimestampToDate(art.createdAt));
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest("a")) return;
if (target.closest("figure") && !deviceCanHover()) {
setInfoVisible((visible) => !visible);
return;
}
dialogRef.current?.close();
};
return (
<SendouDialog
heading={formatter.format(databaseTimestampToDate(art.createdAt)) ?? ""}
<SendouModal
ref={dialogRef}
className={styles.lightbox}
blurredBackdrop
onClose={close}
isFullScreen
aria-label={art.description || dateText}
>
<img
alt=""
src={art.url}
loading="lazy"
className={styles.dialogImg}
onLoad={() => setImageSettled(true)}
onError={() => setImageSettled(true)}
/>
{art.tags || art.linkedUsers ? (
<div
className={clsx(styles.tagsContainer, { invisible: !imageSettled })}
>
{art.linkedUsers?.map((user) => (
<Link
to={userPage(user)}
key={user.discordId}
className={clsx(styles.dialogTag, styles.dialogTagUser)}
>
{user.username}
</Link>
))}
{art.tags?.map((tag) => (
<Link
to={artPage(tag.name)}
key={tag.id}
className={styles.dialogTag}
>
#{tag.name}
</Link>
))}
</div>
) : null}
{art.description ? (
<div
className={clsx(styles.dialogDescription, {
invisible: !imageSettled,
{/* biome-ignore lint/a11y/noStaticElementInteractions: click-anywhere to close, Escape is handled by the dialog */}
<div className={styles.lightboxBody} onClick={handleClick}>
<figure
className={clsx(styles.lightboxFigure, {
[styles.lightboxFigureSized]: typeof aspectRatio === "number",
[styles.lightboxFigureFailed]: imageFailed,
})}
style={
typeof aspectRatio === "number"
? ({ "--aspect-ratio": aspectRatio } as React.CSSProperties)
: undefined
}
>
{art.description}
</div>
) : null}
<SendouButton
variant="destructive"
className="mx-auto mt-6"
onClick={close}
icon={<X />}
>
Close
</SendouButton>
</SendouDialog>
<img
alt=""
src={previewUrl(art.url)}
className={styles.lightboxPlaceholder}
ref={placeholderRef}
/>
{imageFailed ? (
<div className={styles.lightboxFailed}>
<ImageOff />
{t("art:imageFailed")}
</div>
) : (
<img
alt=""
src={art.url}
className={clsx(styles.lightboxImg, {
[styles.lightboxImgSettled]: imageSettled,
})}
ref={imageRef}
/>
)}
<figcaption
className={clsx(styles.lightboxInfo, {
[styles.lightboxInfoHidden]: !infoVisible,
})}
>
<time className={styles.lightboxDate}>{dateText}</time>
{art.description ? (
<div className={styles.lightboxDescription}>
{art.description}
</div>
) : null}
{art.tags || art.linkedUsers ? (
<div className={styles.tagsContainer}>
{art.linkedUsers?.map((user) => (
<Link
to={userPage(user)}
key={user.discordId}
className={clsx(styles.dialogTag, styles.dialogTagUser)}
>
{user.username}
</Link>
))}
{art.tags?.map((tag) => (
<Link
to={artPage(tag.name)}
key={tag.id}
className={styles.dialogTag}
>
#{tag.name}
</Link>
))}
</div>
) : null}
</figcaption>
</figure>
</div>
</SendouModal>
);
}
@@ -163,24 +207,35 @@ function ImagePreview({
}) {
const canEdit = useHasPermission(art, "EDIT");
const canUnlink = useHasPermission(art, "UNLINK");
const [imageSettled, setImageSettled] = React.useState(false);
const [imageSettled, imageRef] = useImageSettled();
const { t } = useTranslation(["common", "art"]);
const formatDistanceToNow = useFormatDistanceToNow();
const img = (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
const image = (
<img
alt=""
src={previewUrl(art.url)}
loading="lazy"
onClick={onClick}
onLoad={() => setImageSettled(true)}
onError={() => setImageSettled(true)}
ref={imageRef}
className={enablePreview ? styles.thumbnail : undefined}
data-testid="art-image"
/>
);
const img = onClick ? (
<button
type="button"
onClick={onClick}
onPointerEnter={() => preloadImage(art.url)}
className={styles.thumbnailButton}
aria-label={art.description || t("art:openImage")}
>
{image}
</button>
) : (
image
);
if (!art.author && canEdit) {
return (
<div>
@@ -301,3 +356,91 @@ function ImagePreview({
</Link>
);
}
/**
* Whether the image has finished loading (or failed to), and the ref to give it.
*
* Native listeners rather than `onLoad`/`onError` because React drops those
* events when the image settles right after mounting, e.g. when it comes from
* the browser cache.
*/
function useImageSettled() {
const [imageSettled, setImageSettled] = React.useState(false);
const imageRef = (image: HTMLImageElement | null) => {
if (!image) return;
if (image.complete) {
setImageSettled(true);
return;
}
const settle = () => setImageSettled(true);
image.addEventListener("load", settle);
image.addEventListener("error", settle);
return () => {
image.removeEventListener("load", settle);
image.removeEventListener("error", settle);
};
};
return [imageSettled, imageRef] as const;
}
/**
* Aspect ratio of the image once it has loaded, "FAILED" if it never will,
* and the ref to give it.
*/
function useImageAspectRatio() {
const [aspectRatio, setAspectRatio] = React.useState<
number | "FAILED" | null
>(null);
const imageRef = (image: HTMLImageElement | null) => {
if (!image) return;
const measure = () => {
setAspectRatio(
image.naturalWidth > 0 && image.naturalHeight > 0
? image.naturalWidth / image.naturalHeight
: "FAILED",
);
};
if (image.complete) {
measure();
return;
}
image.addEventListener("load", measure);
image.addEventListener("error", measure);
return () => {
image.removeEventListener("load", measure);
image.removeEventListener("error", measure);
};
};
return [aspectRatio, imageRef] as const;
}
/** Page the art is on, so that a shared `?big=` link renders the page containing it. */
function pageOfArt(arts: ListedArt[], artId: number | null) {
if (typeof artId !== "number") return 1;
const index = arts.findIndex((art) => art.id === artId);
if (index === -1) return 1;
return Math.floor(index / ART_PER_PAGE) + 1;
}
/** Touch devices have no hover to reveal the lightbox info with, so a tap on the image toggles it instead. */
function deviceCanHover() {
return window.matchMedia("(hover: hover)").matches;
}
/** Warms the browser cache so the full-size image is ready by the time the lightbox opens. */
function preloadImage(url: string) {
if (preloadedImageUrls.has(url)) return;
preloadedImageUrls.add(url);
new Image().src = url;
}

View File

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

View File

@@ -1,5 +1,5 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { Tables, TablesInsertable } from "~/db/tables";
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
@@ -88,14 +88,54 @@ async function findBy(
.where("Association.id", "=", args.associationId)
.execute();
return associations.map((a) => ({
...a,
permissions: {
MANAGE: (a.members ?? [])
.filter((member) => member.role === "ADMIN")
.map((user) => user.id),
},
}));
return associations.map((a) => {
const members = a.members ?? [];
const adminIds = memberIdsWithRole(members, "ADMIN");
const managerIds = memberIdsWithRole(members, "MANAGER");
return {
...a,
members: a.members?.map((member) => ({
...member,
permissions: {
REMOVE: memberRemoverIds({ member, adminIds, managerIds }),
},
})),
permissions: {
MANAGE: adminIds,
MANAGE_INVITE_LINK: [...adminIds, ...managerIds],
},
};
});
}
function memberIdsWithRole(
members: Array<{ id: number; role: Tables["AssociationMember"]["role"] }>,
role: Tables["AssociationMember"]["role"],
) {
return members
.filter((member) => member.role === role)
.map((member) => member.id);
}
/** Admins can remove anyone but themselves, managers only regular members. */
function memberRemoverIds({
member,
adminIds,
managerIds,
}: {
member: { id: number; role: Tables["AssociationMember"]["role"] };
adminIds: Array<number>;
managerIds: Array<number>;
}) {
const removerIds =
member.role === "ADMIN"
? []
: member.role === "MANAGER"
? adminIds
: [...adminIds, ...managerIds];
return removerIds.filter((id) => id !== member.id);
}
const DEFAULT_VIRTUAL_ASSOCIATIONS: Array<AssociationVirtualIdentifier> = [
@@ -191,6 +231,23 @@ export function insertMember({
.execute();
}
export function updateMemberRole({
associationId,
userId,
role,
}: {
associationId: number;
userId: number;
role: Tables["AssociationMember"]["role"];
}) {
return db
.updateTable("AssociationMember")
.set({ role })
.where("associationId", "=", associationId)
.where("userId", "=", userId)
.execute();
}
export function deleteMember({
associationId,
userId,
@@ -205,6 +262,34 @@ export function deleteMember({
.execute();
}
/** Removes the member and, when they were the admin, promotes `newAdminUserId` in their place. */
export function handleMemberLeaving({
associationId,
userId,
newAdminUserId,
}: {
associationId: number;
userId: number;
newAdminUserId?: number;
}) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("AssociationMember")
.where("associationId", "=", associationId)
.where("userId", "=", userId)
.execute();
if (typeof newAdminUserId === "number") {
await trx
.updateTable("AssociationMember")
.set({ role: "ADMIN" })
.where("associationId", "=", associationId)
.where("userId", "=", newAdminUserId)
.execute();
}
});
}
export function deleteById(associationId: number) {
return db.deleteFrom("Association").where("id", "=", associationId).execute();
}

View File

@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { associationsPageActionSchema } from "~/features/associations/associations-schemas";
import * as Association from "~/features/associations/core/Association";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
@@ -21,13 +22,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
switch (data._action) {
case "REMOVE_MEMBER": {
await validateHasManagePermissions(data.associationId);
errorToastIfFalsy(
data.userId !== user.id,
"Cannot remove yourself from the association",
const association = await findAssociation(data.associationId);
const memberToRemove = badRequestIfFalsy(
association.members!.find((member) => member.id === data.userId),
);
requirePermission(memberToRemove, "REMOVE");
await AssociationRepository.deleteMember({
userId: data.userId,
associationId: data.associationId,
@@ -35,15 +36,40 @@ export const action = async ({ request }: ActionFunctionArgs) => {
break;
}
case "ADD_MANAGER":
case "REMOVE_MANAGER": {
const association = await requireAssociationPermission(
data.associationId,
"MANAGE",
);
errorToastIfFalsy(
association.members!.some(
(member) => member.id === data.userId && member.role !== "ADMIN",
),
"Not a member of the association",
);
await AssociationRepository.updateMemberRole({
associationId: data.associationId,
userId: data.userId,
role: data._action === "ADD_MANAGER" ? "MANAGER" : "MEMBER",
});
break;
}
case "DELETE_ASSOCIATION": {
await validateHasManagePermissions(data.associationId);
await requireAssociationPermission(data.associationId, "MANAGE");
await AssociationRepository.deleteById(data.associationId);
break;
}
case "REFRESH_INVITE_CODE": {
await validateHasManagePermissions(data.associationId);
await requireAssociationPermission(
data.associationId,
"MANAGE_INVITE_LINK",
);
await AssociationRepository.refreshInviteCode(data.associationId);
@@ -83,20 +109,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
break;
}
case "LEAVE_ASSOCIATION": {
const association = badRequestIfFalsy(
await AssociationRepository.findById(data.associationId, {
withMembers: true,
}),
);
const association = await findAssociation(data.associationId);
const isAdmin = association.permissions.MANAGE.includes(user.id);
const newAdmin = isAdmin
? Association.resolveNewAdmin(association.members!)
: null;
errorToastIfFalsy(
!association.permissions.MANAGE.includes(user.id),
"You cannot leave an association you manage",
!isAdmin || newAdmin,
"You cannot leave an association you manage without a starred member to take over",
);
await AssociationRepository.deleteMember({
await AssociationRepository.handleMemberLeaving({
userId: user.id,
associationId: data.associationId,
newAdminUserId: newAdmin?.id,
});
return successToast("Left association");
@@ -109,10 +137,19 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return null;
};
async function validateHasManagePermissions(associationId: number) {
const association = badRequestIfFalsy(
async function findAssociation(associationId: number) {
return badRequestIfFalsy(
await AssociationRepository.findById(associationId, { withMembers: true }),
);
requirePermission(association, "MANAGE");
}
async function requireAssociationPermission(
associationId: number,
permission: "MANAGE" | "MANAGE_INVITE_LINK",
) {
const association = await findAssociation(associationId);
requirePermission(association, permission);
return association;
}

View File

@@ -16,6 +16,18 @@ const removeMemberSchema = v.object({
userId: id,
});
const addManagerSchema = v.object({
_action: _action("ADD_MANAGER"),
associationId: id,
userId: id,
});
const removeManagerSchema = v.object({
_action: _action("REMOVE_MANAGER"),
associationId: id,
userId: id,
});
const deleteAssociationSchema = v.object({
_action: _action("DELETE_ASSOCIATION"),
associationId: id,
@@ -38,6 +50,8 @@ const leaveAssociationSchema = v.object({
export const associationsPageActionSchema = v.union([
removeMemberSchema,
addManagerSchema,
removeManagerSchema,
deleteAssociationSchema,
refreshInviteCodeSchema,
joinAssociationSchema,

View File

@@ -181,3 +181,44 @@ describe("isVisible", () => {
expect(Association.isVisible(args)).toBe(true);
});
});
describe("mentionsAssociation", () => {
test.each([
{ why: "no visibility", visibility: null, expected: false },
{
why: "current association",
visibility: { forAssociation: 1 },
expected: true,
},
{
why: "other association",
visibility: { forAssociation: 2 },
expected: false,
},
{
why: "virtual association",
visibility: { forAssociation: "+1" as const },
expected: false,
},
{
why: "association later in the schedule",
visibility: {
forAssociation: 2,
notFoundInstructions: [{ at: 0, forAssociation: 1 }],
},
expected: true,
},
{
why: "schedule going public only",
visibility: {
forAssociation: 2,
notFoundInstructions: [{ at: 0, forAssociation: null }],
},
expected: false,
},
])("$why", ({ visibility, expected }) => {
expect(
Association.mentionsAssociation({ visibility, associationId: 1 }),
).toBe(expected);
});
});

View File

@@ -53,6 +53,24 @@ export function isVisible(args: IsVisibleArgs) {
);
}
/** Whether the association is in the visibility at any point of its schedule, not only right now. */
export function mentionsAssociation({
visibility,
associationId,
}: {
visibility: AssociationVisibility | null;
associationId: number;
}) {
if (!visibility) return false;
return (
visibility.forAssociation === associationId ||
(visibility.notFoundInstructions ?? []).some(
(instruction) => instruction.forAssociation === associationId,
)
);
}
export function isPublic(args: Omit<IsVisibleArgs, "associations">) {
return isVisible({
associations: null,
@@ -60,3 +78,16 @@ export function isPublic(args: Omit<IsVisibleArgs, "associations">) {
visibility: args.visibility,
});
}
/**
* Who becomes the admin after the current one leaves. `null` if no manager can take over,
* in which case the admin can't leave. Of several managers the one with the lowest user id
* (the oldest account) is picked, same as team ownership passing on.
*/
export function resolveNewAdmin<T extends { id: number; role: string }>(
members: Array<T>,
) {
const managers = members.filter((member) => member.role === "MANAGER");
return managers.sort((a, b) => a.id - b.id).at(0) ?? null;
}

View File

@@ -18,7 +18,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const associationsWithInviteCodes = await Promise.all(
associations.map(async (association) => ({
...association,
inviteCode: association.permissions.MANAGE.includes(user.id)
inviteCode: association.permissions.MANAGE_INVITE_LINK.includes(user.id)
? await AssociationRepository.findInviteCodeById(association.id)
: undefined,
})),

View File

@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as AssociationFactory from "~/db/seed/factories/AssociationFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import type { SerializeFrom } from "~/utils/remix";
import {
assertResponseErrored,
wrappedAction,
wrappedLoader,
} from "~/utils/Test";
import { action } from "../actions/associations.server";
import type { associationsPageActionSchema } from "../associations-schemas";
import { loader } from "../loaders/associations.server";
const associationsAction = wrappedAction<typeof associationsPageActionSchema>({
action,
});
const associationsLoader = wrappedLoader<SerializeFrom<typeof loader>>({
loader,
});
const users = UserFactory.pool();
const adminId = () => users.id(1);
const memberId = () => users.id(2);
const otherMemberId = () => users.id(3);
describe("Associations page action", () => {
beforeEach(async () => {
await users.create(3);
});
test("starred member can share the invite link", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ memberUserIds: [memberId()] },
);
const before = await associationsLoader({ user: memberId() });
expect(before.associations[0]!.inviteCode).toBeUndefined();
await associationsAction(
{
_action: "ADD_MANAGER",
associationId: association.id,
userId: memberId(),
},
{ user: adminId() },
);
const after = await associationsLoader({ user: memberId() });
expect(after.associations[0]!.inviteCode).toBeTruthy();
});
test("unstarring takes the invite link away again", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ managerUserIds: [memberId()] },
);
await associationsAction(
{
_action: "REMOVE_MANAGER",
associationId: association.id,
userId: memberId(),
},
{ user: adminId() },
);
const { associations } = await associationsLoader({ user: memberId() });
expect(associations[0]!.inviteCode).toBeUndefined();
});
test("starred member can reset the invite link", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ managerUserIds: [memberId()] },
);
const before = await associationsLoader({ user: memberId() });
await associationsAction(
{ _action: "REFRESH_INVITE_CODE", associationId: association.id },
{ user: memberId() },
);
const after = await associationsLoader({ user: memberId() });
expect(after.associations[0]!.inviteCode).toBeTruthy();
expect(after.associations[0]!.inviteCode).not.toBe(
before.associations[0]!.inviteCode,
);
});
test("unstarred member can't reset the invite link", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ memberUserIds: [memberId()] },
);
await expect(
associationsAction(
{ _action: "REFRESH_INVITE_CODE", associationId: association.id },
{ user: memberId() },
),
).rejects.toThrow();
});
test("starred member can remove an unstarred member", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ managerUserIds: [memberId()], memberUserIds: [otherMemberId()] },
);
await associationsAction(
{
_action: "REMOVE_MEMBER",
associationId: association.id,
userId: otherMemberId(),
},
{ user: memberId() },
);
const { associations } = await associationsLoader({ user: memberId() });
expect(associations[0]!.members!.map((member) => member.id)).not.toContain(
otherMemberId(),
);
});
test("starred member can't remove another starred member", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ managerUserIds: [memberId(), otherMemberId()] },
);
await expect(
associationsAction(
{
_action: "REMOVE_MEMBER",
associationId: association.id,
userId: otherMemberId(),
},
{ user: memberId() },
),
).rejects.toThrow();
});
test("starred member can't remove the admin", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ managerUserIds: [memberId()] },
);
await expect(
associationsAction(
{
_action: "REMOVE_MEMBER",
associationId: association.id,
userId: adminId(),
},
{ user: memberId() },
),
).rejects.toThrow();
});
test("admin can't remove themselves", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ memberUserIds: [memberId()] },
);
await expect(
associationsAction(
{
_action: "REMOVE_MEMBER",
associationId: association.id,
userId: adminId(),
},
{ user: adminId() },
),
).rejects.toThrow();
});
test("member can't star another member", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ memberUserIds: [memberId(), otherMemberId()] },
);
await expect(
associationsAction(
{
_action: "ADD_MANAGER",
associationId: association.id,
userId: otherMemberId(),
},
{ user: memberId() },
),
).rejects.toThrow();
});
test("admin can't leave without a starred member", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ memberUserIds: [memberId()] },
);
const response = await associationsAction(
{ _action: "LEAVE_ASSOCIATION", associationId: association.id },
{ user: adminId() },
);
assertResponseErrored(response, "starred member to take over");
});
test("oldest starred member becomes the admin when the admin leaves", async () => {
const association = await AssociationFactory.create(
{ userId: adminId() },
{ managerUserIds: [otherMemberId(), memberId()] },
);
await associationsAction(
{ _action: "LEAVE_ASSOCIATION", associationId: association.id },
{ user: adminId() },
);
const { associations } = await associationsLoader({ user: memberId() });
expect(associations[0]!.permissions.MANAGE).toEqual([memberId()]);
expect(associations[0]!.members!.map((member) => member.id)).not.toContain(
adminId(),
);
});
});

View File

@@ -1,20 +1,22 @@
import { Check, Clipboard, Trash } from "lucide-react";
import { Check, Clipboard, Star, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { type MetaFunction, Outlet, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { SendouButton } from "~/components/elements/Button";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { UserLink } from "~/components/UserLink";
import { action } from "~/features/associations/actions/associations.server";
import { associationsPage } from "~/features/associations/associations-urls";
import * as Association from "~/features/associations/core/Association";
import {
type AssociationsLoaderData,
loader,
} from "~/features/associations/loaders/associations.server";
import { useUser } from "~/features/auth/core/user";
import { scrimsByAssociationPage } from "~/features/scrims/scrims-urls";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHasPermission } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
@@ -45,7 +47,7 @@ export default function AssociationsPage() {
</div>
<JoinForm />
{data.associations.map((association) => (
<Association key={association.id} association={association} />
<AssociationSection key={association.id} association={association} />
))}
</Main>
);
@@ -89,7 +91,7 @@ function JoinForm() {
);
}
function Association({
function AssociationSection({
association,
}: {
association: AssociationsLoaderData["associations"][number];
@@ -97,6 +99,9 @@ function Association({
const { t } = useTranslation(["common", "scrims"]);
const user = useUser();
const canManage = useHasPermission(association, "MANAGE");
const newAdmin = canManage
? Association.resolveNewAdmin(association.members ?? [])
: null;
return (
<section>
@@ -129,38 +134,58 @@ function Association({
?.username,
})}
</div>
{!canManage ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.leave.title", {
name: association.name,
})}
fields={[
["_action", "LEAVE_ASSOCIATION"],
["associationId", association.id],
]}
submitButtonText={t("scrims:associations.leave.action")}
<div className="stack horizontal sm items-center my-2">
<LinkButton
to={scrimsByAssociationPage(association.id)}
variant="outlined"
size="small"
>
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
className="my-2"
data-testid="leave-team-button"
{t("scrims:associations.viewScrims")}
</LinkButton>
{!canManage || newAdmin ? (
<FormWithConfirm
dialogHeading={
newAdmin
? t("scrims:associations.leave.titleWithNewAdmin", {
name: association.name,
username: newAdmin.username,
})
: t("scrims:associations.leave.title", {
name: association.name,
})
}
fields={[
["_action", "LEAVE_ASSOCIATION"],
["associationId", association.id],
]}
submitButtonText={t("scrims:associations.leave.action")}
>
{t("scrims:associations.leave.action")}
</SendouButton>
</FormWithConfirm>
) : null}
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
data-testid="leave-team-button"
>
{t("scrims:associations.leave.action")}
</SendouButton>
</FormWithConfirm>
) : null}
</div>
<div className="stack sm mt-4">
{association.members?.map((member) => (
<AssociationMember
key={member.id}
member={member}
associationId={association.id}
showControls={canManage && member.id !== user?.id}
canStar={canManage && member.id !== user?.id}
/>
))}
</div>
{canManage ? (
<div className="text-xs text-lighter mt-2">
{t("scrims:associations.manager.explanation")}
</div>
) : null}
{association.inviteCode ? (
<AssociationInviteCodeActions
associationId={association.id}
@@ -213,40 +238,80 @@ function AssociationInviteCodeActions({
function AssociationMember({
member,
associationId,
showControls,
canStar,
}: {
member: NonNullable<
AssociationsLoaderData["associations"][number]["members"]
>[number];
associationId: number;
showControls?: boolean;
canStar: boolean;
}) {
const { t } = useTranslation(["common", "scrims"]);
const canRemove = useHasPermission(member, "REMOVE");
return (
<div className="stack horizontal sm items-center justify-between">
<UserLink user={member} />
{showControls ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {
username: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["userId", member.id],
["associationId", associationId],
["_action", "REMOVE_MEMBER"],
]}
>
<SendouButton
shape="square"
icon={<Trash className="small-icon" />}
className="small-text"
variant="minimal-destructive"
size="small"
type="submit"
<div className="stack horizontal sm items-center">
<UserLink user={member} />
{!canStar && member.role === "MANAGER" ? (
<Star
className="small-icon"
fill="currentColor"
role="img"
aria-label={t("scrims:associations.manager.label")}
/>
</FormWithConfirm>
) : null}
</div>
{canStar || canRemove ? (
<div className="stack horizontal sm items-center">
{canStar ? (
<ActionButton
schema={associationsPageActionSchema}
action={
member.role === "MANAGER" ? "REMOVE_MANAGER" : "ADD_MANAGER"
}
fields={{ userId: member.id, associationId }}
shape="square"
variant="minimal"
size="small"
className="small-text"
icon={
<Star
className="small-icon"
fill={member.role === "MANAGER" ? "currentColor" : "none"}
/>
}
aria-label={t(
member.role === "MANAGER"
? "scrims:associations.manager.remove"
: "scrims:associations.manager.add",
{ username: member.username },
)}
/>
) : null}
{canRemove ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {
username: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["userId", member.id],
["associationId", associationId],
["_action", "REMOVE_MEMBER"],
]}
>
<SendouButton
shape="square"
icon={<Trash className="small-icon" />}
className="small-text"
variant="minimal-destructive"
size="small"
type="submit"
/>
</FormWithConfirm>
) : null}
</div>
) : null}
</div>
);

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

@@ -9,7 +9,13 @@ import {
stringConstant,
textField,
} from "~/form/fields";
import { _action, id, superRefine, type ValidationCtx } from "~/utils/schema";
import {
_action,
id,
revalidateRoot,
superRefine,
type ValidationCtx,
} from "~/utils/schema";
import { AVAILABILITY } from "./availability-constants";
const DAY_MINUTES = 24 * 60;
@@ -54,7 +60,7 @@ export const saveWeekSchema = v.object({
export const dismissScheduleNudgeSchema = v.object({
_action: _action("DISMISS_SCHEDULE_NUDGE"),
revalidateRoot: v.optional(v.nullable(v.literal(true))),
revalidateRoot,
});
export const saveScheduleVisibilitySchema = v.object({
@@ -63,7 +69,7 @@ export const saveScheduleVisibilitySchema = v.object({
label: "labels.scheduleSharedWith",
minLength: 0,
}),
revalidateRoot: v.optional(v.nullable(v.literal(true))),
revalidateRoot,
});
export const eventsActionSchema = v.union([

View File

@@ -122,7 +122,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

@@ -39,7 +39,11 @@ export default function BracketTestLayout() {
const { totalRounds, wbRounds, lbRounds } = countRounds(data, isDoubleElim);
if (isDoubleElim) {
simulateCompletedRoundsByGroup(data, completedWbRounds, completedLbRounds);
simulateCompletedRoundsBySection(
data,
completedWbRounds,
completedLbRounds,
);
} else {
simulateCompletedRounds(data, Math.min(completedRounds, totalRounds));
}
@@ -213,29 +217,23 @@ function countRounds(data: BracketData, isDoubleElim: boolean) {
if (!isDoubleElim) return { totalRounds, wbRounds: 0, lbRounds: 0 };
const wbGroupId = data.group.find((g) => g.number === 1)?.id;
const lbGroupId = data.group.find((g) => g.number === 2)?.id;
const wbRounds = data.round.filter((r) => r.groupId === wbGroupId).length;
const lbRounds = data.round.filter((r) => r.groupId === lbGroupId).length;
const wbRounds = data.round.filter((r) => r.section === "winners").length;
const lbRounds = data.round.filter((r) => r.section === "losers").length;
return { totalRounds, wbRounds, lbRounds };
}
function simulateCompletedRoundsByGroup(
function simulateCompletedRoundsBySection(
data: BracketData,
wbCompleted: number,
lbCompleted: number,
) {
const wbGroupId = data.group.find((g) => g.number === 1)?.id;
const lbGroupId = data.group.find((g) => g.number === 2)?.id;
const completedRoundIds = new Set<number>();
for (const round of data.round) {
if (round.groupId === wbGroupId && round.number <= wbCompleted) {
if (round.section === "winners" && round.number <= wbCompleted) {
completedRoundIds.add(round.id);
}
if (round.groupId === lbGroupId && round.number <= lbCompleted) {
if (round.section === "losers" && round.number <= lbCompleted) {
completedRoundIds.add(round.id);
}
}

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

@@ -815,15 +815,7 @@ async function updateTournamentTables(
// the teams' picks were made against the old settings, so they pick again
if (changedMapPickingStyle || changedMapPool) {
await trx
.deleteFrom("MapPoolMap")
.where("tournamentTeamId", "in", (eb) =>
eb
.selectFrom("TournamentTeam")
.select("id")
.where("tournamentId", "=", tournamentId),
)
.execute();
await resetTeamMapPicks({ tournamentId, args }, trx);
}
if (
@@ -841,6 +833,47 @@ async function updateTournamentTables(
}
}
/**
* Deletes every team's map picks. Teams that had picked are also checked out when picks are still
* a check-in requirement, as otherwise they would enter the bracket without a pool.
*/
async function resetTeamMapPicks(
{
tournamentId,
args,
}: { tournamentId: number; args: Pick<UpdateArgs, "mapPickingStyle"> },
trx: Transaction<DB>,
) {
// before the picks go, as the teams to check out are the ones that have them
if (args.mapPickingStyle !== "TO") {
await trx
.deleteFrom("TournamentTeamCheckIn")
.where("bracketIdx", "is", null)
.where("tournamentTeamId", "in", (eb) =>
eb
.selectFrom("MapPoolMap")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"MapPoolMap.tournamentTeamId",
)
.select("TournamentTeam.id")
.where("TournamentTeam.tournamentId", "=", tournamentId),
)
.execute();
}
await trx
.deleteFrom("MapPoolMap")
.where("tournamentTeamId", "in", (eb) =>
eb
.selectFrom("TournamentTeam")
.select("id")
.where("tournamentId", "=", tournamentId),
)
.execute();
}
function teamPickSettings(
args: Pick<CreateArgs, "mapPickingStyle" | "teamPick" | "isFullTournament">,
) {

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "vitest";
import * as BadgeFactory from "~/db/seed/factories/BadgeFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
@@ -21,6 +22,8 @@ const editAction = wrappedAction<typeof calendarNewSchemaServer>({
const users = UserFactory.pool();
const badgeManagingAuthorId = () => users.id(1);
const teamPickAuthorId = () => users.id(5);
const teamPickTeamMemberIds = () => users.ids(4);
describe("calendar new action: editing an event with badge prizes", () => {
let orgAdminId: number;
@@ -168,6 +171,7 @@ describe("calendar new action: bracket URL", () => {
describe("calendar new action: team picked tournament", () => {
beforeEach(async () => {
await UserFactory.createRegular(null, { roles: ["TOURNAMENT_ORGANIZER"] });
await users.create(5);
});
test("saves the team pick settings and the custom pool", async () => {
@@ -211,4 +215,37 @@ describe("calendar new action: team picked tournament", () => {
customPool.serialized,
);
});
test("changing the pick settings resets the teams' picks and checks them out", async () => {
const tournament = await TournamentFactory.create({
authorId: teamPickAuthorId(),
mapPickingStyle: "AUTO",
teamPick: { modes: [{ mode: "SZ", count: 2 }], pool: "ALL" },
});
await TournamentTeamFactory.create(
{
tournamentId: tournament.id,
memberUserIds: teamPickTeamMemberIds(),
mapPool: new MapPool({ ...MapPool.EMPTY.parsed, SZ: [1, 2] }),
},
{ isCheckedIn: true },
);
const res = await editAction(
calendarNewFormValues({
eventToEditId: tournament.eventId,
bracketUrl: "https://sendou.ink",
mapPickingStyle: "AUTO",
teamPickModes: ["SZ"],
teamPickCounts: [{ mode: "SZ", count: 3 }],
teamPickPool: "ALL",
}),
{ user: teamPickAuthorId() },
);
expect(res.fieldErrors).toBeUndefined();
const edited = await tournamentFromDB(tournament.id);
expect(edited.ctx.teams[0].hasMapPool).toBe(0);
expect(edited.ctx.teams[0].checkIns).toEqual([]);
});
});

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