mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-25 21:00:28 -05:00
Merge remote-tracking branch 'origin/main' into public-league
# Conflicts: # app/features/tournament-bracket/routes/to.$id.divisions.module.css
This commit is contained in:
@@ -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">;
|
||||
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
@@ -18,6 +30,10 @@
|
||||
margin: auto;
|
||||
outline: none;
|
||||
animation: zoom-in-95 300ms ease-out;
|
||||
|
||||
@media (width < 600px) {
|
||||
--dialog-padding-block: var(--s-4);
|
||||
}
|
||||
}
|
||||
|
||||
.blurredBackdrop::backdrop {
|
||||
@@ -40,6 +56,7 @@
|
||||
min-width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
inset-block: 0;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding-block-start: calc(env(safe-area-inset-top) + var(--s-6));
|
||||
@@ -67,7 +84,7 @@
|
||||
}
|
||||
|
||||
.noHeading {
|
||||
margin-block-start: -14px;
|
||||
margin-block-start: calc(10px - var(--dialog-padding-block));
|
||||
}
|
||||
|
||||
.heading {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SendouButtonProps,
|
||||
} from "~/components/elements/Button";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useScrollLockWhileOpen } from "~/hooks/useScrollLock";
|
||||
import {
|
||||
useReportModalOpen,
|
||||
useTopLayerViewTransitionStyle,
|
||||
@@ -73,10 +74,20 @@ function DialogElement({
|
||||
ref,
|
||||
}: DialogElementProps) {
|
||||
const topLayerStyle = useTopLayerViewTransitionStyle();
|
||||
const dialogRef = React.useRef<HTMLDialogElement>(null);
|
||||
const backdropPressHandlers = useBackdropDismiss(isDismissable);
|
||||
useScrollLockWhileOpen(dialogRef);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
ref={(dialog) => {
|
||||
dialogRef.current = dialog;
|
||||
if (typeof ref === "function") {
|
||||
ref(dialog);
|
||||
} else if (ref) {
|
||||
ref.current = dialog;
|
||||
}
|
||||
}}
|
||||
id={id}
|
||||
style={topLayerStyle}
|
||||
className={clsx(className, {
|
||||
@@ -85,27 +96,44 @@ function DialogElement({
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
tabIndex={-1}
|
||||
closedby={isDismissable ? "any" : "closerequest"}
|
||||
closedby="closerequest"
|
||||
onClose={onClose}
|
||||
onClick={isDismissable ? closeOnBackdropClick : undefined}
|
||||
{...backdropPressHandlers}
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Safari 26 is missing `closedby`, close on backdrop clicks manually
|
||||
function closeOnBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
// Native `closedby` closes on pointer up so the click can land on stuff like buttons underneath the backdrop
|
||||
// We just roll our own click handler here because that can't "leak" through
|
||||
function useBackdropDismiss(enabled: boolean | undefined) {
|
||||
const pressStartedOnBackdropRef = React.useRef(false);
|
||||
|
||||
if (!enabled) return {};
|
||||
|
||||
return {
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDialogElement>) => {
|
||||
pressStartedOnBackdropRef.current = isOnBackdrop(event);
|
||||
},
|
||||
onClick: (event: React.MouseEvent<HTMLDialogElement>) => {
|
||||
if (pressStartedOnBackdropRef.current && isOnBackdrop(event)) {
|
||||
event.currentTarget.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isOnBackdrop(event: React.MouseEvent<HTMLDialogElement>) {
|
||||
if (event.target !== event.currentTarget) return false;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const outside =
|
||||
|
||||
return (
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right ||
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom;
|
||||
if (outside) {
|
||||
event.currentTarget.close();
|
||||
}
|
||||
event.clientY > rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,15 +8,14 @@ import {
|
||||
} from "~/utils/roving-focus";
|
||||
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
|
||||
import { Image } from "../Image";
|
||||
import { useAnchorPositioning } from "./anchor-positioning";
|
||||
import styles from "./Menu.module.css";
|
||||
import {
|
||||
focusLeftTo,
|
||||
isOwnToggle,
|
||||
useAnchorSafeId,
|
||||
usePopoverTargetOnceHydrated,
|
||||
useShowPopoverOnOpen,
|
||||
} from "./Popover";
|
||||
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
|
||||
import { useFloatingLayer } from "./useFloatingLayer";
|
||||
|
||||
type MenuPlacement = "bottom start" | "bottom end" | "bottom right";
|
||||
|
||||
@@ -27,7 +26,7 @@ interface SendouMenuProps {
|
||||
children: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
placement?: MenuPlacement;
|
||||
/** Render the items while closed too, so the menu works before hydration (and without JavaScript). */
|
||||
/** Render the items while closed too, so they are in the server markup and ready the moment the menu opens. */
|
||||
eager?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,10 +43,9 @@ export function SendouMenu({
|
||||
popoverClassName,
|
||||
eager,
|
||||
}: SendouMenuProps) {
|
||||
const uid = useAnchorSafeId();
|
||||
const popoverId = `${React.useId()}-menu`;
|
||||
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
|
||||
const topLayerStyle = useTopLayerViewTransitionStyle();
|
||||
const popoverId = `${uid}-menu`;
|
||||
const anchorName = `--menu-anchor-${uid}`;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -63,12 +61,10 @@ export function SendouMenu({
|
||||
open,
|
||||
onOpen: () => setOpen(true),
|
||||
});
|
||||
useCloseOnScrollClip(open, popoverRef, () =>
|
||||
popoverRef.current?.hidePopover(),
|
||||
);
|
||||
useAnchorPositioning({
|
||||
|
||||
useFloatingLayer({
|
||||
isOpen: open,
|
||||
popoverRef,
|
||||
floatingRef: popoverRef,
|
||||
getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null,
|
||||
placement:
|
||||
opensLeft || (placement && placement !== "bottom start")
|
||||
@@ -115,11 +111,10 @@ export function SendouMenu({
|
||||
<span
|
||||
ref={triggerContainerRef}
|
||||
className={styles.triggerContainer}
|
||||
style={{ "--menu-anchor": anchorName } as React.CSSProperties}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{React.cloneElement(trigger, {
|
||||
popoverTarget: popoverId,
|
||||
popoverTarget,
|
||||
"aria-expanded": open,
|
||||
"aria-haspopup": "menu",
|
||||
})}
|
||||
@@ -132,15 +127,8 @@ export function SendouMenu({
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.popover, "scrollbar", popoverClassName, {
|
||||
[styles.scrolling]: scrolling,
|
||||
[styles.opensLeft]: opensLeft,
|
||||
})}
|
||||
style={
|
||||
{
|
||||
positionAnchor: anchorName,
|
||||
...topLayerStyle,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
data-placement={placement}
|
||||
style={topLayerStyle}
|
||||
onBeforeToggle={onBeforeToggle}
|
||||
onToggle={onToggle}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -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,21 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
||||
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
|
||||
import {
|
||||
type AnchorPlacement,
|
||||
useAnchorPositioning,
|
||||
} from "./anchor-positioning";
|
||||
import styles from "./Popover.module.css";
|
||||
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
|
||||
import { type FloatingPlacement, useFloatingLayer } from "./useFloatingLayer";
|
||||
import { useScrollIntoView } from "./useScrollIntoView";
|
||||
|
||||
export type PopoverPlacement = AnchorPlacement;
|
||||
|
||||
/** `useId` values hold characters CSS idents can't (e.g. `:`), strip them for anchor names. */
|
||||
export function useAnchorSafeId() {
|
||||
return React.useId().replace(/[^a-zA-Z0-9-]/g, "");
|
||||
}
|
||||
export type PopoverPlacement = FloatingPlacement;
|
||||
|
||||
/**
|
||||
* `toggle` does not bubble natively but React propagates it anyway, so an
|
||||
@@ -26,6 +19,10 @@ export function isOwnToggle(event: React.ToggleEvent<HTMLElement>) {
|
||||
return event.target === event.currentTarget;
|
||||
}
|
||||
|
||||
export function usePopoverTargetOnceHydrated(popoverId: string) {
|
||||
return useHydrated() ? popoverId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a popover once React has committed `open`, so content mounted only
|
||||
* while open is in the popover's first painted frame instead of appearing a
|
||||
@@ -33,7 +30,7 @@ export function isOwnToggle(event: React.ToggleEvent<HTMLElement>) {
|
||||
* browser's own open (the trigger's `popoverTarget`) is cancelled there and
|
||||
* redone through `onOpen` in the next frame, still before it paints, as a
|
||||
* popover cannot be shown from inside the show operation being cancelled.
|
||||
* Call it before `useAnchorPositioning` so the popover is showing by the time
|
||||
* Call it before `useFloatingLayer` so the popover is showing by the time
|
||||
* that measures it.
|
||||
*/
|
||||
export function useShowPopoverOnOpen({
|
||||
@@ -86,10 +83,10 @@ export function focusLeftTo(
|
||||
|
||||
/**
|
||||
* Popover opened by `trigger` (a SendouButton); controlled or uncontrolled. Renders through the
|
||||
* native popover API with CSS anchor positioning.
|
||||
* native popover API, placed next to the trigger by `useFloatingLayer`.
|
||||
*
|
||||
* With `eager` the content is rendered while closed too, so the popover opens with its content
|
||||
* before hydration (and without JavaScript altogether).
|
||||
* With `eager` the content is rendered while closed too, so it is in the server markup and there
|
||||
* is nothing left to mount when the popover opens.
|
||||
*/
|
||||
export function SendouPopover({
|
||||
children,
|
||||
@@ -108,9 +105,8 @@ export function SendouPopover({
|
||||
isOpen?: boolean;
|
||||
eager?: boolean;
|
||||
}) {
|
||||
const uid = useAnchorSafeId();
|
||||
const popoverId = `${uid}-popover`;
|
||||
const anchorName = `--popover-anchor-${uid}`;
|
||||
const popoverId = `${React.useId()}-popover`;
|
||||
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
|
||||
|
||||
const [isControlled] = React.useState(isOpen !== undefined);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
|
||||
@@ -153,13 +149,15 @@ export function SendouPopover({
|
||||
open,
|
||||
onOpen: () => setOpen(true),
|
||||
});
|
||||
useCloseOnScrollClip(open, popoverRef, () => setOpen(false));
|
||||
useAnchorPositioning({
|
||||
useScrollIntoView(
|
||||
open,
|
||||
() => triggerContainerRef.current?.firstElementChild ?? null,
|
||||
);
|
||||
useFloatingLayer({
|
||||
isOpen: open,
|
||||
popoverRef,
|
||||
floatingRef: popoverRef,
|
||||
getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null,
|
||||
placement,
|
||||
constrainHeight: true,
|
||||
});
|
||||
|
||||
const onToggle = (event: React.ToggleEvent<HTMLDivElement>) => {
|
||||
@@ -189,11 +187,10 @@ export function SendouPopover({
|
||||
<span
|
||||
ref={triggerContainerRef}
|
||||
className={styles.triggerContainer}
|
||||
style={{ "--popover-anchor": anchorName } as React.CSSProperties}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{React.cloneElement(trigger, {
|
||||
popoverTarget: popoverId,
|
||||
popoverTarget,
|
||||
"aria-haspopup": "dialog",
|
||||
})}
|
||||
</span>
|
||||
@@ -202,15 +199,9 @@ export function SendouPopover({
|
||||
id={popoverId}
|
||||
popover="auto"
|
||||
className={clsx(styles.content, popoverClassName)}
|
||||
style={
|
||||
{
|
||||
positionAnchor: anchorName,
|
||||
...topLayerStyle,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
style={topLayerStyle}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
data-placement={placement}
|
||||
onBeforeToggle={onBeforeToggle}
|
||||
onToggle={onToggle}
|
||||
onBlur={onBlur}
|
||||
@@ -235,38 +226,28 @@ export function SendouAnchoredPopover({
|
||||
triggerRef: React.RefObject<HTMLElement | null>;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const uid = useAnchorSafeId();
|
||||
const anchorName = `--popover-anchor-${uid}`;
|
||||
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
const topLayerStyle = useTopLayerViewTransitionStyle();
|
||||
|
||||
// before the positioning effect, so the content is placed by its first paint
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const popover = popoverRef.current;
|
||||
if (!popover) return;
|
||||
|
||||
if (isOpen) {
|
||||
trigger?.style.setProperty("anchor-name", anchorName);
|
||||
if (!popover.matches(":popover-open")) {
|
||||
popover.showPopover();
|
||||
}
|
||||
} else if (popover.matches(":popover-open")) {
|
||||
popover.hidePopover();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return () => {
|
||||
trigger?.style.removeProperty("anchor-name");
|
||||
};
|
||||
}, [isOpen, triggerRef, anchorName]);
|
||||
|
||||
useCloseOnScrollClip(isOpen, popoverRef, () => onOpenChange(false));
|
||||
useAnchorPositioning({
|
||||
useScrollIntoView(isOpen, () => triggerRef.current);
|
||||
useFloatingLayer({
|
||||
isOpen,
|
||||
popoverRef,
|
||||
floatingRef: popoverRef,
|
||||
getAnchor: () => triggerRef.current,
|
||||
constrainHeight: true,
|
||||
});
|
||||
|
||||
const onToggle = (event: React.ToggleEvent<HTMLDivElement>) => {
|
||||
@@ -286,9 +267,7 @@ export function SendouAnchoredPopover({
|
||||
ref={popoverRef}
|
||||
popover="auto"
|
||||
className={styles.content}
|
||||
style={
|
||||
{ positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties
|
||||
}
|
||||
style={topLayerStyle}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -7,16 +7,15 @@ import { SendouButton } from "~/components/elements/Button";
|
||||
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
||||
import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus";
|
||||
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
|
||||
import { Image } from "../Image";
|
||||
import { useAnchorPositioning } from "./anchor-positioning";
|
||||
import {
|
||||
focusLeftTo,
|
||||
isOwnToggle,
|
||||
useAnchorSafeId,
|
||||
usePopoverTargetOnceHydrated,
|
||||
useShowPopoverOnOpen,
|
||||
} from "./Popover";
|
||||
import styles from "./Select.module.css";
|
||||
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
|
||||
import { useFloatingLayer } from "./useFloatingLayer";
|
||||
import { useScrollIntoView } from "./useScrollIntoView";
|
||||
|
||||
export type SelectKey = string | number;
|
||||
|
||||
@@ -96,7 +95,7 @@ export interface SendouSelectProps<T extends object> {
|
||||
|
||||
/**
|
||||
* A customizable select component with optional search functionality,
|
||||
* rendered through the native popover API with CSS anchor positioning.
|
||||
* rendered through the native popover API and placed by `useFloatingLayer`.
|
||||
*
|
||||
* Options mount only while the popover is open (plus the selected one, so the
|
||||
* trigger can show it); the trigger's content is read straight from the
|
||||
@@ -144,11 +143,11 @@ export function SendouSelect<T extends object>({
|
||||
children,
|
||||
}: SendouSelectProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const uid = useAnchorSafeId();
|
||||
const uid = React.useId();
|
||||
const topLayerStyle = useTopLayerViewTransitionStyle();
|
||||
const popoverId = `${uid}-select-popover`;
|
||||
const popoverTarget = usePopoverTargetOnceHydrated(popoverId);
|
||||
const listboxId = `${uid}-select-listbox`;
|
||||
const anchorName = `--select-anchor-${uid}`;
|
||||
const labelId = label ? `${uid}-select-label` : undefined;
|
||||
const valueId = `${uid}-select-value`;
|
||||
const triggerId = `${uid}-select-trigger`;
|
||||
@@ -203,13 +202,11 @@ export function SendouSelect<T extends object>({
|
||||
open,
|
||||
onOpen: () => setOpen(true),
|
||||
});
|
||||
useCloseOnScrollClip(open, popoverRef, () => setOpen(false));
|
||||
useAnchorPositioning({
|
||||
useScrollIntoView(open, () => triggerElementRef.current);
|
||||
useFloatingLayer({
|
||||
isOpen: open,
|
||||
popoverRef,
|
||||
floatingRef: popoverRef,
|
||||
getAnchor: () => triggerElementRef.current,
|
||||
matchAnchorWidth: true,
|
||||
constrainHeight: true,
|
||||
});
|
||||
// after positioning, so the selection scrolls into the space the list ends up with
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
@@ -539,8 +536,7 @@ export function SendouSelect<T extends object>({
|
||||
: undefined
|
||||
}
|
||||
data-required={isRequired || undefined}
|
||||
popoverTarget={popoverId}
|
||||
style={{ anchorName } as React.CSSProperties}
|
||||
popoverTarget={popoverTarget}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span
|
||||
@@ -575,12 +571,7 @@ export function SendouSelect<T extends object>({
|
||||
id={popoverId}
|
||||
popover="auto"
|
||||
className={clsx(styles.popover, popoverClassName)}
|
||||
style={
|
||||
{
|
||||
positionAnchor: anchorName,
|
||||
...topLayerStyle,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
style={topLayerStyle}
|
||||
onBeforeToggle={onPopoverBeforeToggle}
|
||||
onToggle={onPopoverToggle}
|
||||
onKeyDown={onPopoverKeyDown}
|
||||
@@ -909,14 +900,14 @@ function SelectOption(props: SendouSelectItemProps) {
|
||||
|
||||
interface SendouSelectItemSectionProps {
|
||||
heading: string;
|
||||
headingImgPath?: string;
|
||||
headingImg?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SendouSelectItemSection({
|
||||
heading,
|
||||
headingImgPath,
|
||||
headingImg,
|
||||
children,
|
||||
className,
|
||||
}: SendouSelectItemSectionProps) {
|
||||
@@ -930,9 +921,7 @@ export function SendouSelectItemSection({
|
||||
// biome-ignore lint/a11y/useSemanticElements: a fieldset would carry form semantics this listbox section does not have
|
||||
<div role="group" aria-label={heading}>
|
||||
<div className={clsx(className, styles.categoryHeading)}>
|
||||
{headingImgPath ? (
|
||||
<Image path={headingImgPath} size={28} alt="" />
|
||||
) : null}
|
||||
{headingImg}
|
||||
{heading}
|
||||
<div className={styles.categoryDivider} />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]}.`}
|
||||
|
||||
@@ -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";
|
||||
@@ -74,19 +74,48 @@ const NAV_CATEGORIES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface MenuOpenState {
|
||||
isOpen: boolean;
|
||||
anotherIsOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TopNavMenus() {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
|
||||
const openStateOf = (name: string): MenuOpenState => ({
|
||||
isOpen: openMenu === name,
|
||||
anotherIsOpen: openMenu !== null && openMenu !== name,
|
||||
onOpenChange: (open) =>
|
||||
setOpenMenu((current) => {
|
||||
if (open) return name;
|
||||
return current === name ? null : current;
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className={styles.container}>
|
||||
{NAV_CATEGORIES.map((category) => (
|
||||
<CategoryMenu key={category.name} category={category} />
|
||||
<CategoryMenu
|
||||
key={category.name}
|
||||
category={category}
|
||||
openState={openStateOf(category.name)}
|
||||
/>
|
||||
))}
|
||||
{process.env.NODE_ENV === "development" ? <DevMenu /> : null}
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<DevMenu openState={openStateOf("dev")} />
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function DevMenu() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
function takeOverOnHover(openState: MenuOpenState, event: PointerEvent) {
|
||||
if (openState.anotherIsOpen && event.pointerType !== "touch") {
|
||||
openState.onOpenChange(true);
|
||||
}
|
||||
}
|
||||
|
||||
function DevMenu({ openState }: { openState: MenuOpenState }) {
|
||||
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
|
||||
const location = useLocation();
|
||||
const returnTo = `${location.pathname}${location.search}`;
|
||||
@@ -98,7 +127,10 @@ function DevMenu() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
onPointerEnter={() => setIsPreviewSuppressed(false)}
|
||||
onPointerEnter={(event) => {
|
||||
setIsPreviewSuppressed(false);
|
||||
takeOverOnHover(openState, event);
|
||||
}}
|
||||
>
|
||||
Dev
|
||||
<ChevronDown className={styles.menuButtonChevron} />
|
||||
@@ -106,8 +138,8 @@ function DevMenu() {
|
||||
}
|
||||
popoverClassName={styles.menuPopover}
|
||||
placement="bottom start"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
isOpen={openState.isOpen}
|
||||
onOpenChange={openState.onOpenChange}
|
||||
eager
|
||||
>
|
||||
<div className={styles.menuContent}>
|
||||
@@ -140,7 +172,7 @@ function DevMenu() {
|
||||
to={item.url}
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
openState.onOpenChange(false);
|
||||
setIsPreviewSuppressed(true);
|
||||
}}
|
||||
>
|
||||
@@ -155,7 +187,7 @@ function DevMenu() {
|
||||
))}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
{!isOpen && !isPreviewSuppressed ? (
|
||||
{!openState.isOpen && !isPreviewSuppressed ? (
|
||||
<div className={styles.preview}>
|
||||
{DEV_IMPERSONATE_ITEMS.map((item) => (
|
||||
<Form
|
||||
@@ -198,11 +230,12 @@ function DevMenu() {
|
||||
|
||||
function CategoryMenu({
|
||||
category,
|
||||
openState,
|
||||
}: {
|
||||
category: (typeof NAV_CATEGORIES)[number];
|
||||
openState: MenuOpenState;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "front"]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
|
||||
const user = useUser();
|
||||
const isStaff = user?.roles.includes("STAFF") ?? false;
|
||||
@@ -221,7 +254,10 @@ function CategoryMenu({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
onPointerEnter={() => setIsPreviewSuppressed(false)}
|
||||
onPointerEnter={(event) => {
|
||||
setIsPreviewSuppressed(false);
|
||||
takeOverOnHover(openState, event);
|
||||
}}
|
||||
>
|
||||
{t(`front:nav.${category.name}`)}
|
||||
<ChevronDown className={styles.menuButtonChevron} />
|
||||
@@ -229,8 +265,8 @@ function CategoryMenu({
|
||||
}
|
||||
popoverClassName={styles.menuPopover}
|
||||
placement="bottom start"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
isOpen={openState.isOpen}
|
||||
onOpenChange={openState.onOpenChange}
|
||||
eager
|
||||
>
|
||||
<div className={styles.menuContent}>
|
||||
@@ -241,7 +277,7 @@ function CategoryMenu({
|
||||
prefetch="intent"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
openState.onOpenChange(false);
|
||||
setIsPreviewSuppressed(true);
|
||||
}}
|
||||
>
|
||||
@@ -256,7 +292,7 @@ function CategoryMenu({
|
||||
))}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
{!isOpen && !isPreviewSuppressed ? (
|
||||
{!openState.isOpen && !isPreviewSuppressed ? (
|
||||
<div className={styles.preview}>
|
||||
{visibleItems.map((item) => (
|
||||
<Link
|
||||
|
||||
@@ -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";
|
||||
@@ -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 ?? (() => {});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -172,7 +172,8 @@
|
||||
}
|
||||
|
||||
.dialogTagUser {
|
||||
background-color: var(--color-accent);
|
||||
background-color: var(--color-fill-accent);
|
||||
color: var(--color-fg-on-accent);
|
||||
}
|
||||
|
||||
@keyframes lightbox-zoom-in {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,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);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
|
||||
.cardRanked {
|
||||
box-shadow: inset 0 -3px 0 var(--color-accent);
|
||||
box-shadow: inset 0 -3px 0 var(--color-fg-accent);
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
|
||||
@@ -16,10 +16,10 @@ avatar slot down to the entry icons */
|
||||
justify-content: center;
|
||||
width: 2.5em;
|
||||
height: 2.25em;
|
||||
background-color: var(--color-text-accent);
|
||||
background-color: var(--color-fill-accent);
|
||||
border-radius: var(--radius-field);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-inverse);
|
||||
color: var(--color-fg-on-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
top: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--color-fill-accent);
|
||||
color: var(--color-fg-on-accent);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
padding: 1px 4px;
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
.pronounsTag {
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
padding: 1px 5px;
|
||||
@@ -69,7 +69,7 @@
|
||||
.messageUser {
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-sm);
|
||||
color: oklch(from var(--color-text-accent) l c var(--chat-hue));
|
||||
color: oklch(from var(--color-fg-accent) l c var(--chat-hue));
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -88,8 +88,8 @@
|
||||
|
||||
.sendButton.sendButton {
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--color-fill-accent);
|
||||
color: var(--color-fg-on-accent);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled {
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
.roomLink {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
text-decoration: underline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
gap: var(--s-1);
|
||||
height: 36px;
|
||||
padding: 0 var(--s-2);
|
||||
color: var(--color-text-inverse);
|
||||
color: var(--color-text-on-light);
|
||||
font-weight: var(--weight-semi);
|
||||
|
||||
&[data-slot-color="yellow"] {
|
||||
@@ -331,7 +331,7 @@
|
||||
|
||||
.inkTimeLabel {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-second);
|
||||
color: var(--color-fg-second);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
padding: var(--s-0-5) var(--s-0-5);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import {
|
||||
Image,
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
@@ -9,11 +14,7 @@ import type {
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
abilityImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { abilityImageUrl } from "~/utils/urls";
|
||||
import { LETHAL_DAMAGE } from "../comp-analyzer-constants";
|
||||
import {
|
||||
useSingleWeaponCombos,
|
||||
@@ -201,9 +202,8 @@ function WeaponIcon({
|
||||
}: WeaponIconProps) {
|
||||
if (isSubWeapon) {
|
||||
return (
|
||||
<Image
|
||||
path={subWeaponImageUrl(subWeaponId)}
|
||||
alt=""
|
||||
<SubWeaponImage
|
||||
subWeaponId={subWeaponId}
|
||||
size={18}
|
||||
className={styles.subSpecialWeaponIcon}
|
||||
/>
|
||||
@@ -212,9 +212,8 @@ function WeaponIcon({
|
||||
|
||||
if (isSpecialWeapon) {
|
||||
return (
|
||||
<Image
|
||||
path={specialWeaponImageUrl(specialWeaponId)}
|
||||
alt=""
|
||||
<SpecialWeaponImage
|
||||
specialWeaponId={specialWeaponId}
|
||||
size={18}
|
||||
className={styles.subSpecialWeaponIcon}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,6 @@ export function RangeVisualization({ weaponIds }: RangeVisualizationProps) {
|
||||
maxRange={maxRange}
|
||||
minY={minY}
|
||||
maxY={maxY}
|
||||
weaponIds={weaponIds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -98,7 +97,6 @@ interface TrajectoryChartProps {
|
||||
maxRange: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
function TrajectoryChart({
|
||||
@@ -107,7 +105,6 @@ function TrajectoryChart({
|
||||
maxRange,
|
||||
minY,
|
||||
maxY,
|
||||
weaponIds,
|
||||
}: TrajectoryChartProps) {
|
||||
const chartWidth = 600;
|
||||
const chartHeight = 200;
|
||||
@@ -180,12 +177,11 @@ function TrajectoryChart({
|
||||
return (
|
||||
<div className={styles.chartContainer}>
|
||||
<div className={styles.weaponLegend}>
|
||||
{weapons.map((weapon, index) => {
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
{weapons.map((weapon) => {
|
||||
const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length];
|
||||
return (
|
||||
<div
|
||||
key={`${weapon.weaponId}-${index}`}
|
||||
key={`${weapon.weaponId}-${weapon.slot}`}
|
||||
className={styles.weaponLegendItem}
|
||||
>
|
||||
<WeaponImage
|
||||
@@ -262,13 +258,12 @@ function TrajectoryChart({
|
||||
})}
|
||||
|
||||
{/* Weapon trajectories */}
|
||||
{weapons.map((weapon, index) => {
|
||||
{weapons.map((weapon) => {
|
||||
if (!weapon.trajectory) return null;
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length];
|
||||
return (
|
||||
<path
|
||||
key={`${weapon.weaponId}-${index}`}
|
||||
key={`${weapon.weaponId}-${weapon.slot}`}
|
||||
d={trajectoryToPath(weapon.trajectory)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
@@ -278,16 +273,15 @@ function TrajectoryChart({
|
||||
})}
|
||||
|
||||
{/* Blast radius circles */}
|
||||
{weapons.map((weapon, index) => {
|
||||
{weapons.map((weapon) => {
|
||||
if (!weapon.blastRadius || !weapon.trajectory) return null;
|
||||
const groundPoint = getGroundIntersection(weapon.trajectory);
|
||||
if (!groundPoint) return null;
|
||||
const slotIndex = weaponIds.indexOf(weapon.weaponId);
|
||||
const color = SLOT_COLORS[slotIndex % SLOT_COLORS.length];
|
||||
const color = SLOT_COLORS[weapon.slot % SLOT_COLORS.length];
|
||||
const radiusPixels = xScale(weapon.blastRadius);
|
||||
return (
|
||||
<circle
|
||||
key={`blast-${weapon.weaponId}-${index}`}
|
||||
key={`blast-${weapon.weaponId}-${weapon.slot}`}
|
||||
cx={xScale(groundPoint.z)}
|
||||
cy={yScale(0)}
|
||||
r={radiusPixels}
|
||||
|
||||
@@ -83,6 +83,22 @@ describe("SelectedWeapons", () => {
|
||||
expect(kitIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders a row per copy when the same weapon is picked twice", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0, 0],
|
||||
});
|
||||
|
||||
const rows = screen.container.querySelectorAll(
|
||||
'[data-testid^="selected-weapon-"]',
|
||||
);
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const emptySlots = screen.container.querySelectorAll(
|
||||
'[class*="weaponNameEmpty"]',
|
||||
);
|
||||
expect(emptySlots.length).toBe(MAX_WEAPONS - 2);
|
||||
});
|
||||
|
||||
test("shows empty slots for remaining positions", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0],
|
||||
@@ -135,6 +151,21 @@ describe("SelectedWeapons", () => {
|
||||
expect(onRemove).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test("calls onRemove with the clicked index when the same weapon is picked twice", async () => {
|
||||
const onRemove = vi.fn();
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [0, 0],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
const removeButtons = screen.container.querySelectorAll(
|
||||
'[class*="removeButton"]',
|
||||
);
|
||||
(removeButtons[1] as HTMLElement).click();
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test("does not render remove button for empty slots", async () => {
|
||||
const screen = await renderSelectedWeapons({
|
||||
selectedWeaponIds: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
@@ -15,15 +16,18 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import clsx from "clsx";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import {
|
||||
Image,
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
abilityImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { abilityImageUrl } from "~/utils/urls";
|
||||
import { MAX_WEAPONS } from "../comp-analyzer-constants";
|
||||
import styles from "./SelectedWeapons.module.css";
|
||||
|
||||
@@ -39,6 +43,14 @@ export function SelectedWeapons({
|
||||
onReorder,
|
||||
}: SelectedWeaponsProps) {
|
||||
const { t } = useTranslation(["weapons", "analyzer"]);
|
||||
const [rowsState, setRows] = useState(() =>
|
||||
reconcileRows([], selectedWeaponIds),
|
||||
);
|
||||
|
||||
const rows = reconcileRows(rowsState, selectedWeaponIds);
|
||||
if (rows !== rowsState) {
|
||||
setRows(rows);
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
@@ -50,16 +62,16 @@ export function SelectedWeapons({
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId);
|
||||
const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const newIds = [...selectedWeaponIds];
|
||||
const [removed] = newIds.splice(oldIndex, 1);
|
||||
newIds.splice(newIndex, 0, removed);
|
||||
const oldIndex = rows.findIndex((row) => row.id === active.id);
|
||||
const newIndex = rows.findIndex((row) => row.id === over.id);
|
||||
|
||||
onReorder(newIds);
|
||||
}
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newRows = arrayMove(rows, oldIndex, newIndex);
|
||||
setRows(newRows);
|
||||
onReorder(newRows.map((row) => row.weaponId));
|
||||
};
|
||||
|
||||
const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length;
|
||||
@@ -73,13 +85,14 @@ export function SelectedWeapons({
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedWeaponIds}
|
||||
items={rows.map((row) => row.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{selectedWeaponIds.map((weaponId, index) => (
|
||||
{rows.map((row, index) => (
|
||||
<SortableWeaponRow
|
||||
key={weaponId}
|
||||
weaponId={weaponId}
|
||||
key={row.id}
|
||||
rowId={row.id}
|
||||
weaponId={row.weaponId}
|
||||
index={index}
|
||||
onRemove={onRemove}
|
||||
showDragHandle={showDragHandle}
|
||||
@@ -104,7 +117,36 @@ export function SelectedWeapons({
|
||||
);
|
||||
}
|
||||
|
||||
interface WeaponRow {
|
||||
/** Identity of the slot rather than of the weapon, so rows of the same weapon stay apart while dragging */
|
||||
id: string;
|
||||
weaponId: MainWeaponId;
|
||||
}
|
||||
|
||||
/** Returns the same rows when they already match, so a reorder of identical weapons is not undone */
|
||||
function reconcileRows(
|
||||
rows: WeaponRow[],
|
||||
weaponIds: MainWeaponId[],
|
||||
): WeaponRow[] {
|
||||
const alreadyMatching =
|
||||
rows.length === weaponIds.length &&
|
||||
rows.every((row, index) => row.weaponId === weaponIds[index]);
|
||||
if (alreadyMatching) return rows;
|
||||
|
||||
const unclaimed = [...rows];
|
||||
|
||||
return weaponIds.map((weaponId) => {
|
||||
const matchIndex = unclaimed.findIndex((row) => row.weaponId === weaponId);
|
||||
if (matchIndex === -1) {
|
||||
return { id: nanoid(), weaponId };
|
||||
}
|
||||
|
||||
return unclaimed.splice(matchIndex, 1)[0];
|
||||
});
|
||||
}
|
||||
|
||||
interface SortableWeaponRowProps {
|
||||
rowId: string;
|
||||
weaponId: MainWeaponId;
|
||||
index: number;
|
||||
onRemove: (index: number) => void;
|
||||
@@ -112,6 +154,7 @@ interface SortableWeaponRowProps {
|
||||
}
|
||||
|
||||
function SortableWeaponRow({
|
||||
rowId,
|
||||
weaponId,
|
||||
index,
|
||||
onRemove,
|
||||
@@ -125,7 +168,7 @@ function SortableWeaponRow({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: weaponId });
|
||||
} = useSortable({ id: rowId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -173,16 +216,11 @@ function SortableWeaponRow({
|
||||
</div>
|
||||
<div className={styles.subSpecialContainer}>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={subWeaponImageUrl(params.subWeaponId)}
|
||||
alt={t(`weapons:SUB_${params.subWeaponId}`)}
|
||||
size={24}
|
||||
/>
|
||||
<SubWeaponImage subWeaponId={params.subWeaponId} size={24} />
|
||||
</div>
|
||||
<div className={styles.kitIcon}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(params.specialWeaponId)}
|
||||
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
|
||||
<SpecialWeaponImage
|
||||
specialWeaponId={params.specialWeaponId}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// note: dev only component, not used in production code
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import { specialWeaponImageUrl } from "~/utils/urls";
|
||||
import { SpecialWeaponImage } from "~/components/Image";
|
||||
import {
|
||||
getSpecialsWithRange,
|
||||
type SpecialWeaponWithRange,
|
||||
@@ -15,8 +13,6 @@ const RANGE_TYPE_COLOR: Record<SpecialWeaponWithRange["rangeType"], string> = {
|
||||
};
|
||||
|
||||
export function SpecialRangeVisualization() {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
const specials = getSpecialsWithRange();
|
||||
if (specials.length === 0) {
|
||||
return null;
|
||||
@@ -53,12 +49,9 @@ export function SpecialRangeVisualization() {
|
||||
|
||||
return (
|
||||
<div key={special.specialWeaponId} className={styles.row}>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(special.specialWeaponId)}
|
||||
width={28}
|
||||
height={28}
|
||||
alt={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
|
||||
title={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
|
||||
<SpecialWeaponImage
|
||||
specialWeaponId={special.specialWeaponId}
|
||||
size={28}
|
||||
/>
|
||||
<div className={styles.track}>
|
||||
{blastWidth > 0 ? (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import { SpecialWeaponImage, SubWeaponImage } from "~/components/Image";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls";
|
||||
import {
|
||||
SPECIAL_CATEGORY_ORDER,
|
||||
SPECIAL_WEAPON_CATEGORIES,
|
||||
@@ -86,11 +85,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) {
|
||||
className={styles.categoryItem}
|
||||
data-first={index === 0}
|
||||
>
|
||||
<Image
|
||||
path={subWeaponImageUrl(item.subId)}
|
||||
alt={t(`weapons:SUB_${item.subId}`)}
|
||||
size={20}
|
||||
/>
|
||||
<SubWeaponImage subWeaponId={item.subId} size={20} />
|
||||
<span className={styles.categoryName}>
|
||||
{t(`analyzer:comp.subCategory.${item.category}`)}
|
||||
</span>
|
||||
@@ -109,11 +104,7 @@ export function WeaponCategories({ selectedWeaponIds }: WeaponCategoriesProps) {
|
||||
className={styles.categoryItem}
|
||||
data-first={index === 0}
|
||||
>
|
||||
<Image
|
||||
path={specialWeaponImageUrl(item.specialId)}
|
||||
alt={t(`weapons:SPECIAL_${item.specialId}`)}
|
||||
size={20}
|
||||
/>
|
||||
<SpecialWeaponImage specialWeaponId={item.specialId} size={20} />
|
||||
<span className={styles.categoryName}>
|
||||
{t(`analyzer:comp.specialCategory.${item.category}`)}
|
||||
</span>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
transition: border-color 0.1s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-text-accent);
|
||||
border-color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import {
|
||||
Image,
|
||||
SpecialWeaponImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type {
|
||||
@@ -13,11 +18,7 @@ import {
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
weaponCategoryUrl,
|
||||
} from "~/utils/urls";
|
||||
import { weaponCategoryUrl } from "~/utils/urls";
|
||||
import { MAX_WEAPONS } from "../comp-analyzer-constants";
|
||||
import type { CategorizationType } from "../comp-analyzer-types";
|
||||
import styles from "./WeaponGrid.module.css";
|
||||
@@ -112,9 +113,7 @@ export function WeaponGrid({
|
||||
{groupedWeapons.map((group) => (
|
||||
<div key={group.key} className={styles.categorySection}>
|
||||
<div className={styles.categoryHeader}>
|
||||
{group.iconPath ? (
|
||||
<Image path={group.iconPath} alt="" size={24} />
|
||||
) : null}
|
||||
<WeaponGroupIcon icon={group.icon} />
|
||||
<span className={styles.categoryName}>
|
||||
{group.name.startsWith("SUB_") ||
|
||||
group.name.startsWith("SPECIAL_")
|
||||
@@ -155,19 +154,35 @@ export function WeaponGrid({
|
||||
);
|
||||
}
|
||||
|
||||
type WeaponGroupIconValue =
|
||||
| { kind: "category"; name: (typeof weaponCategories)[number]["name"] }
|
||||
| { kind: "sub"; id: SubWeaponId }
|
||||
| { kind: "special"; id: SpecialWeaponId };
|
||||
|
||||
interface WeaponGroup {
|
||||
key: string;
|
||||
name: string;
|
||||
iconPath: string | null;
|
||||
icon: WeaponGroupIconValue;
|
||||
weaponIds: MainWeaponId[];
|
||||
}
|
||||
|
||||
function WeaponGroupIcon({ icon }: { icon: WeaponGroupIconValue }) {
|
||||
switch (icon.kind) {
|
||||
case "category":
|
||||
return <Image path={weaponCategoryUrl(icon.name)} alt="" size={24} />;
|
||||
case "sub":
|
||||
return <SubWeaponImage subWeaponId={icon.id} size={24} />;
|
||||
case "special":
|
||||
return <SpecialWeaponImage specialWeaponId={icon.id} size={24} />;
|
||||
}
|
||||
}
|
||||
|
||||
function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
|
||||
if (categorization === "category") {
|
||||
return weaponCategories.map((category) => ({
|
||||
key: category.name,
|
||||
name: category.name.toLowerCase(),
|
||||
iconPath: weaponCategoryUrl(category.name),
|
||||
icon: { kind: "category" as const, name: category.name },
|
||||
weaponIds: [...category.weaponIds] as MainWeaponId[],
|
||||
}));
|
||||
}
|
||||
@@ -183,7 +198,7 @@ function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
|
||||
return {
|
||||
key: `sub-${subId}`,
|
||||
name: `SUB_${subId}`,
|
||||
iconPath: subWeaponImageUrl(subId as SubWeaponId),
|
||||
icon: { kind: "sub" as const, id: subId as SubWeaponId },
|
||||
weaponIds: weaponsWithSub,
|
||||
};
|
||||
})
|
||||
@@ -200,7 +215,7 @@ function groupWeaponsByType(categorization: CategorizationType): WeaponGroup[] {
|
||||
return {
|
||||
key: `special-${specialId}`,
|
||||
name: `SPECIAL_${specialId}`,
|
||||
iconPath: specialWeaponImageUrl(specialId as SpecialWeaponId),
|
||||
icon: { kind: "special" as const, id: specialId as SpecialWeaponId },
|
||||
weaponIds: weaponsWithSpecial,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -11,6 +11,14 @@ const RANGE_COMPARISONS: [MainWeaponId, MainWeaponId][] = [
|
||||
[2070, 7010], // Snipewriter 5H > Tri-Stringer
|
||||
];
|
||||
|
||||
describe("getWeaponsWithRange", () => {
|
||||
test("keeps a separate slot for each copy of a duplicated weapon", () => {
|
||||
const weapons = getWeaponsWithRange([40, 40, 70]);
|
||||
|
||||
expect(weapons.map((weapon) => weapon.slot)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weapon range comparisons", () => {
|
||||
test.each(RANGE_COMPARISONS)(
|
||||
"weapon %i has more range than weapon %i",
|
||||
|
||||
@@ -217,6 +217,8 @@ export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
|
||||
|
||||
export interface WeaponWithRange {
|
||||
weaponId: MainWeaponId;
|
||||
/** Position in the comp, kept through the filtering so duplicates stay apart */
|
||||
slot: number;
|
||||
range: number;
|
||||
blastRadius?: number;
|
||||
rangeType: "calculated" | "direct" | "unsupported";
|
||||
@@ -227,10 +229,11 @@ export function getWeaponsWithRange(
|
||||
weaponIds: MainWeaponId[],
|
||||
): WeaponWithRange[] {
|
||||
return weaponIds
|
||||
.map((weaponId) => {
|
||||
.map((weaponId, slot) => {
|
||||
const result = getWeaponRange(weaponId);
|
||||
return {
|
||||
weaponId,
|
||||
slot,
|
||||
...result,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
.preview {
|
||||
padding: var(--s-4);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: var(--font-xl);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tokenName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
font-size: var(--font-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid var(--color-border-high);
|
||||
border-radius: var(--radius-selector);
|
||||
}
|
||||
|
||||
.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.annotated {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.annotatedGrid {
|
||||
display: grid;
|
||||
gap: var(--s-6);
|
||||
|
||||
@container (width > 44rem) {
|
||||
grid-template-columns: minmax(0, 30rem) 18rem;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-24);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
padding: var(--s-6);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-1-5);
|
||||
border-radius: var(--radius-field);
|
||||
cursor: default;
|
||||
|
||||
&[data-active="true"] {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
.marker,
|
||||
.pin {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-text);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.pin {
|
||||
position: absolute;
|
||||
translate: -50% -50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arrows {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: none;
|
||||
stroke: var(--color-text-high);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.6;
|
||||
|
||||
&[data-active="true"] {
|
||||
stroke: var(--color-text);
|
||||
stroke-width: 2;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.arrowHead {
|
||||
fill: var(--color-text-high);
|
||||
|
||||
&[data-active="true"] {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.targetHighlight {
|
||||
position: absolute;
|
||||
outline: 2px dashed var(--color-text);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-field);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.noteTarget {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.mockCard {
|
||||
padding: var(--s-4);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.mockTitle {
|
||||
width: fit-content;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.mockMeta {
|
||||
width: fit-content;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.mockDivider {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-top: var(--border-style);
|
||||
}
|
||||
|
||||
.mockNeutralChip {
|
||||
padding: var(--s-1) var(--s-2-5);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.mockAccentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-2-5);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.mockTabs {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
border-bottom: var(--border-style);
|
||||
}
|
||||
|
||||
.mockTab {
|
||||
padding-block: var(--s-1-5);
|
||||
margin-bottom: calc(var(--border-width) * -1);
|
||||
border-bottom: var(--border-width) solid transparent;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
|
||||
&[data-active] {
|
||||
border-color: var(--color-fg-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.mockRow,
|
||||
.mockRowHighlighted {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-1-5) var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.mockRowHighlighted {
|
||||
background-color: var(--color-bg-accent);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.mockLink {
|
||||
width: fit-content;
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mockFocused {
|
||||
width: fit-content;
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.mockSecondBadge {
|
||||
padding: 0 var(--s-1-5);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-fill-second);
|
||||
color: var(--color-fg-on-second);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.mockStat {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.mockTrack {
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.mockBar {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-fg-second);
|
||||
}
|
||||
|
||||
.mockIconText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
width: fit-content;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.mockSecondTint {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-second);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.mockErrorInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 16rem;
|
||||
height: var(--field-size);
|
||||
padding-inline: var(--field-padding);
|
||||
border: var(--border-width) solid var(--color-error);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-sm);
|
||||
|
||||
& svg {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.pairings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.pairing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.pairingSample {
|
||||
padding: var(--s-4) var(--s-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.verdict,
|
||||
.contrastBadge {
|
||||
padding: 0 var(--s-1-5);
|
||||
border-radius: var(--radius-selector);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.verdict[data-verdict="do"],
|
||||
.contrastBadge[data-level="pass"] {
|
||||
background-color: var(--color-success-low);
|
||||
color: var(--color-success-high);
|
||||
}
|
||||
|
||||
.verdict[data-verdict="dont"],
|
||||
.contrastBadge[data-level="fail"] {
|
||||
background-color: var(--color-error-low);
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
.contrastBadge[data-level="large"] {
|
||||
background-color: var(--color-warning-low);
|
||||
color: var(--color-warning-high);
|
||||
}
|
||||
1052
app/features/components-showcase/routes/components.colors.tsx
Normal file
1052
app/features/components-showcase/routes/components.colors.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
@@ -202,6 +203,7 @@ export default function ComponentsShowcasePage() {
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<h1>Components</h1>
|
||||
<Link to="/components/colors">Color tokens →</Link>
|
||||
{SECTIONS.map(({ id, component: Component }) => (
|
||||
<Component key={id} id={id} />
|
||||
))}
|
||||
|
||||
@@ -55,10 +55,12 @@
|
||||
}
|
||||
|
||||
.countBadgeAction {
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--color-fill-accent);
|
||||
color: var(--color-fg-on-accent);
|
||||
}
|
||||
|
||||
.alertBadge {
|
||||
color: var(--color-warning);
|
||||
background-color: var(--color-warning-low);
|
||||
border: 1.5px solid var(--color-warning);
|
||||
color: var(--color-warning-high);
|
||||
}
|
||||
|
||||
@@ -83,12 +83,7 @@ export function CompGraphic({
|
||||
<GraphicSectionDivider>
|
||||
{t("analyzer:comp.weaponRanges")}
|
||||
</GraphicSectionDivider>
|
||||
<RangeChart
|
||||
weapons={weaponsWithRange.map((weapon) => ({
|
||||
...weapon,
|
||||
slot: weaponIds.indexOf(weapon.weaponId),
|
||||
}))}
|
||||
/>
|
||||
<RangeChart weapons={weaponsWithRange} />
|
||||
</>
|
||||
) : null}
|
||||
{showCombos && topCombos.length > 0 ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
--graphic-row-bg: var(--color-bg-high);
|
||||
--graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent);
|
||||
--graphic-text-dim: var(--color-text-high);
|
||||
--graphic-accent: var(--color-text-accent);
|
||||
--graphic-accent: var(--color-fg-accent);
|
||||
--graphic-first: oklch(87% 0.13 85);
|
||||
--graphic-second: oklch(83% 0.015 268);
|
||||
--graphic-third: oklch(74% 0.09 55);
|
||||
@@ -17,7 +17,7 @@
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 90% 45% at 50% -5%,
|
||||
color-mix(in oklch, var(--color-accent) 25%, transparent),
|
||||
color-mix(in oklch, var(--color-fg-accent) 25%, transparent),
|
||||
transparent 70%
|
||||
),
|
||||
var(--color-bg);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-bold);
|
||||
outline: initial;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-second);
|
||||
background-color: var(--color-fg-second);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user