mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 21:58:08 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">;
|
||||
|
||||
99
app/components/LFGPostGraphic.module.css
Normal file
99
app/components/LFGPostGraphic.module.css
Normal 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;
|
||||
}
|
||||
188
app/components/LFGPostGraphic.tsx
Normal file
188
app/components/LFGPostGraphic.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
gap: var(--s-1-5);
|
||||
|
||||
&.active {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
284
app/components/elements/floating-layer.test.ts
Normal file
284
app/components/elements/floating-layer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
241
app/components/elements/floating-layer.ts
Normal file
241
app/components/elements/floating-layer.ts
Normal 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}`;
|
||||
}
|
||||
23
app/components/elements/tests/keyboard.ts
Normal file
23
app/components/elements/tests/keyboard.ts
Normal 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"));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
426
app/components/elements/useFloatingLayer.browser.test.tsx
Normal file
426
app/components/elements/useFloatingLayer.browser.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
317
app/components/elements/useFloatingLayer.ts
Normal file
317
app/components/elements/useFloatingLayer.ts
Normal 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`;
|
||||
}
|
||||
99
app/components/elements/useScrollIntoView.browser.test.tsx
Normal file
99
app/components/elements/useScrollIntoView.browser.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
51
app/components/elements/useScrollIntoView.ts
Normal file
51
app/components/elements/useScrollIntoView.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
.chevron,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
37
app/components/layout/GlobalSearch.browser.test.tsx
Normal file
37
app/components/layout/GlobalSearch.browser.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.listBox {
|
||||
max-height: 325px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--s-2);
|
||||
outline: none;
|
||||
|
||||
57
app/components/layout/TopNavMenus.browser.test.tsx
Normal file
57
app/components/layout/TopNavMenus.browser.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
231
app/features/associations/routes/associations.test.ts
Normal file
231
app/features/associations/routes/associations.test.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
.dayDot {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
.inkGridApFocused {
|
||||
color: var(--color-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
}
|
||||
|
||||
.bar {
|
||||
background-color: var(--color-text-accent);
|
||||
background-color: var(--color-fg-accent);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -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">,
|
||||
) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user