Leaderboards page + Svelte UI kit foundations

- packages/components (@sendou/components): Button, ChipRadio(+Group), Tabs set (handrolled ARIA tabs), Menu, Popover, Select stack (search, sections, keyboard nav) — native popover API instead of react-aria
- app components: Main, Image/WeaponImage/ModeImage/TierImage, Avatar (canvas identicon port), Flag, Placement, InfoPopover, MetaTags, WeaponSelect, SeasonSelect
- leaderboards feature UI: Leaderboards + PlayersTable/TeamTable/XPTable/OwnEntryPeek/TeamStaffMenu/TopTenPlayer, scoped styles ported from top-search.module.css
- root layout serializes the lean user; hasRole() reads it from page.data
- skip/unskip commands go single-flight via .updates() on the client-held query instance
- SSR verified against the differ seed database
This commit is contained in:
Kalle
2026-08-16 11:48:15 +03:00
parent 2c67745715
commit 2aaf937ff9
49 changed files with 3922 additions and 2 deletions

View File

@@ -3,5 +3,6 @@
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "never"
}
},
"js/ts.experimental.useTsgo": false
}

View File

@@ -19,6 +19,8 @@
},
"dependencies": {
"@epic-web/cachified": "5.6.3",
"@lucide/svelte": "0.544.0",
"@sendou/components": "workspace:*",
"@sendou/in-game-lists": "workspace:*",
"@sendou/utils": "workspace:*",
"date-fns": "4.4.0",

View File

@@ -0,0 +1,192 @@
<script lang="ts" module>
const dimensions = {
xxxs: 16,
xxxsm: 20,
xxs: 24,
xxsm: 32,
xs: 36,
sm: 44,
xsm: 62,
md: 81,
xmd: 94,
lg: 125,
} as const;
const identiconCache = new Map<string, string>();
const IDENTICON_CACHE_MAX = 500;
function hashString(str: string) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
}
function generateColors(hash: number) {
const hue = hash % 360;
const saturation = 65 + ((hash >>> 8) % 20);
const lightness = 50 + ((hash >>> 16) % 20);
return {
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
}
export function generateIdenticon(input: string, size = 128, gridSize = 5) {
const cacheKey = `${input}-${size}-${gridSize}`;
const cached = identiconCache.get(cacheKey);
if (cached) return cached;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
const insetRatio = 1 / Math.sqrt(2);
const cellSize = Math.floor((size * insetRatio) / gridSize);
const actualSize = cellSize * gridSize;
const offset = Math.floor((size - actualSize) / 2);
const halfGrid = Math.ceil(gridSize / 2);
const patternHash = hashString(input);
const colorHash = hashString(input.split("").reverse().join(""));
const colors = generateColors(colorHash);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors.foreground;
const path = new Path2D();
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < halfGrid; col++) {
const bitIndex = row * halfGrid + col;
const shouldFill = (patternHash >>> bitIndex) & 1;
if (shouldFill) {
const x = offset + col * cellSize;
const y = offset + row * cellSize;
path.rect(x, y, cellSize, cellSize);
const mirrorCol = gridSize - 1 - col;
if (col !== mirrorCol) {
const mirrorX = offset + mirrorCol * cellSize;
path.rect(mirrorX, y, cellSize, cellSize);
}
}
}
}
ctx.fill(path);
const dataUrl = canvas.toDataURL();
if (identiconCache.size >= IDENTICON_CACHE_MAX) {
identiconCache.clear();
}
identiconCache.set(cacheKey, dataUrl);
return dataUrl;
}
</script>
<script lang="ts">
import { browser } from "$app/env";
import { BLANK_IMAGE_URL, resolveAvatarUrl } from "#lib/utils/urls.ts";
interface Props {
user?: {
discordId: string;
discordAvatar: string | null;
customAvatarUrl?: string | null;
};
url?: string | null;
identiconInput?: string;
class?: string;
alt?: string;
size?: keyof typeof dimensions;
loading?: "lazy" | "eager";
}
let {
user,
url,
identiconInput,
size = "sm",
class: className,
alt = "",
loading = "lazy",
}: Props = $props();
let isErrored = $state(false);
// an <img> can finish loading (and fail) before hydration attaches onerror, so that
// error is missed — re-check on mount and fall back manually so SSR'd avatars still heal
function checkAlreadyErrored(img: HTMLImageElement) {
if (img.complete && img.naturalWidth === 0) isErrored = true;
}
const identiconSource = $derived(
identiconInput ?? user?.discordId ?? "unknown",
);
const userAvatarUrl = $derived(
user
? resolveAvatarUrl({
customAvatarUrl: user.customAvatarUrl,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: undefined,
);
const avatarUrl = $derived(url ?? userAvatarUrl);
const src = $derived(
avatarUrl && !isErrored
? avatarUrl
: browser
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL,
);
</script>
<div class={["avatarWrapper", className]}>
<img
{src}
{alt}
title={alt ? alt : undefined}
width={dimensions[size]}
height={dimensions[size]}
{loading}
onerror={() => {
isErrored = true;
}}
{@attach checkAlreadyErrored}
/>
</div>
<style>
.avatarWrapper {
flex-shrink: 0;
width: fit-content;
height: fit-content;
background-color: var(--color-bg-higher);
border-radius: var(--radius-avatar);
overflow: hidden;
& img {
display: block;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getLocale } from "#lib/paraglide/runtime.js";
import { countryCodeToTranslatedName } from "#lib/utils/i18n.ts";
interface Props {
countryCode: string;
tiny?: boolean;
}
let { countryCode, tiny = false }: Props = $props();
</script>
<span
class={[`twf twf-${countryCode.toLowerCase()}`, { "twf-s": tiny }]}
data-testid="flag-{countryCode}"
title={countryCodeToTranslatedName({
countryCode,
language: getLocale(),
})}
></span>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
interface Props {
path: string;
alt: string;
title?: string;
class?: string;
containerClass?: string;
width?: number;
height?: number;
size?: number;
style?: string;
containerStyle?: string;
testId?: string;
onclick?: () => void;
loading?: "lazy" | "eager";
}
let {
path,
alt,
title,
class: className,
containerClass,
width,
height,
size,
style,
containerStyle,
testId,
onclick,
loading = "lazy",
}: Props = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div {title} class={containerClass} style={containerStyle} {onclick}>
<img
{alt}
src="{path}.avif"
class={className}
width={size ?? width}
height={size ?? height}
{style}
draggable="false"
{loading}
data-testid={testId}
/>
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { Popover } from "@sendou/components";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
tiny?: boolean;
class?: string;
}
let { children, tiny = false, class: className }: Props = $props();
</script>
<Popover>
{#snippet trigger(triggerProps)}
<button
type="button"
class={["trigger", className, { triggerTiny: tiny }]}
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
>
?
</button>
{/snippet}
{@render children()}
</Popover>
<style>
.trigger {
border: var(--border-style-high);
border-radius: 100%;
background-color: transparent;
color: var(--color-text);
font-size: var(--font-md);
padding: var(--s-0-5);
width: var(--selector-size);
height: var(--selector-size);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.triggerTiny {
width: var(--selector-size-sm);
height: var(--selector-size-sm);
font-size: var(--font-xs);
}
</style>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
halfWidth?: boolean;
bigger?: boolean;
breakoutContainer?: boolean;
style?: string;
testId?: string;
}
let {
children,
class: className,
halfWidth,
bigger,
breakoutContainer,
style,
testId,
}: Props = $props();
</script>
<main
class={["main", "normal", { narrow: halfWidth, wide: bigger }, className]}
data-main-breakout={breakoutContainer || undefined}
data-testid={testId}
{style}
>
{@render children()}
</main>
<style>
.main {
container-type: inline-size;
padding: var(--layout-main-padding);
margin-bottom: var(--s-32);
min-height: calc(100dvh - var(--layout-nav-height));
/* Fill the whole content area (sidebars already excluded by the flex
layout) while staying a query container, so a descendant can break out
of the page max-width and size against the full width via cqw units. */
&[data-main-breakout] {
max-width: none;
}
}
.normal {
width: 100%;
max-width: 48rem;
margin-inline: auto;
}
.narrow {
width: 100%;
max-width: 24rem;
margin-inline: auto;
}
.wide {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}
</style>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { page } from "$app/state";
import { Config } from "#lib/config.ts";
import { truncateBySentence } from "#lib/utils/strings.ts";
interface Props {
title: string;
ogTitle?: string;
description?: string;
url?: string;
image?: { url: string; dimensions?: { width: number; height: number } };
}
let { title, ogTitle, description, url, image }: Props = $props();
const ROOT_URL = "https://sendou.ink";
const COMMON_PREVIEW_IMAGE = `${Config.staticAssetsUrl}/img/layout/common-preview.png`;
const fullTitle = $derived(
title === "sendou.ink" ? title : `${title} | sendou.ink`,
);
const truncatedDescription = $derived(
description ? truncateBySentence(description, 300) : null,
);
const ogUrl = $derived(`${ROOT_URL}${url ?? page.url.pathname}`);
</script>
<svelte:head>
<title>{fullTitle}</title>
<meta property="og:title" content={ogTitle ?? title} />
{#if truncatedDescription}
<meta name="description" content={truncatedDescription} />
<meta property="og:description" content={truncatedDescription} />
{/if}
<meta property="og:site_name" content="sendou.ink" />
<meta property="og:type" content="website" />
<meta property="og:url" content={ogUrl} />
<meta property="og:image" content={image?.url ?? COMMON_PREVIEW_IMAGE} />
{#if !image}
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1080" />
{:else if image.dimensions}
<meta property="og:image:width" content={String(image.dimensions.width)} />
<meta property="og:image:height" content={String(image.dimensions.height)} />
{/if}
</svelte:head>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { modeLongName } from "#lib/modules/i18n/messages.ts";
import { modeImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
interface Props {
mode: string;
title?: string;
class?: string;
width?: number;
height?: number;
size?: number;
testId?: string;
loading?: "lazy" | "eager";
}
let { mode, title, ...rest }: Props = $props();
const label = $derived(title ?? modeLongName(mode));
</script>
<Image {...rest} alt={label} title={label} path={modeImageUrl(mode)} />

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { getLocale } from "#lib/paraglide/runtime.js";
import { ordinalSuffix } from "#lib/utils/i18n.ts";
import {
FIRST_PLACEMENT_ICON_PATH,
SECOND_PLACEMENT_ICON_PATH,
THIRD_PLACEMENT_ICON_PATH,
} from "#lib/utils/urls.ts";
interface Props {
placement: number;
iconClass?: string;
textClass?: string;
size?: number;
textOnly?: boolean;
showAsSuperscript?: boolean;
}
let {
placement,
iconClass,
textClass,
size = 20,
textOnly = false,
showAsSuperscript = true,
}: Props = $props();
const suffix = $derived(ordinalSuffix(placement, getLocale()));
const isSuperscript = $derived(showAsSuperscript && suffix.startsWith("^"));
const ordinalSuffixText = $derived(suffix.replace(/^\^/, ""));
const iconPath = $derived.by(() => {
if (textOnly) return null;
switch (placement) {
case 3:
return THIRD_PLACEMENT_ICON_PATH;
case 2:
return SECOND_PLACEMENT_ICON_PATH;
case 1:
return FIRST_PLACEMENT_ICON_PATH;
default:
return null;
}
});
const placementString = $derived(`${placement}${ordinalSuffixText}`);
</script>
{#if !iconPath}
<span class={textClass}
>{placement}{#if isSuperscript}<sup>{ordinalSuffixText}</sup
>{:else}{ordinalSuffixText}{/if}</span
>
{:else}
<img
alt={placementString}
title={placementString}
src={iconPath}
class={iconClass}
height={size}
width={size}
/>
{/if}

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import type { TierName } from "#lib/features/mmr/mmr-constants.ts";
import { TIER_PLUS_URL, tierImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
interface Props {
tier: { name: TierName; isPlus: boolean };
class?: string;
width?: number;
}
let { tier, class: className, width = 200 }: Props = $props();
const title = $derived(`${tier.name}${tier.isPlus ? "+" : ""}`);
const height = $derived(width * 0.8675);
</script>
<div class={["tierContainer", className]} style="width: {width}px">
<Image
path={tierImageUrl(tier.name)}
{width}
{height}
alt={title}
{title}
containerClass="tierImg"
/>
{#if tier.isPlus}
<Image
path={TIER_PLUS_URL}
{width}
{height}
alt={title}
{title}
containerClass="tierImg"
/>
{/if}
</div>
<style>
.tierContainer {
display: grid;
}
.tierContainer :global(.tierImg) {
grid-column: 1;
grid-row: 1;
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { mainWeaponName } from "#lib/modules/i18n/messages.ts";
import {
mainWeaponImageUrl,
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
outlinedTenStarMainWeaponImageUrl,
} from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
type WeaponWithStars = {
weaponSplId: MainWeaponId;
isFavorite?: boolean | number;
isTenStar?: boolean | number;
};
interface Props {
weaponSplId?: MainWeaponId;
weapon?: WeaponWithStars;
variant?: "badge" | "badge-5-star" | "badge-10-star" | "build";
title?: string;
class?: string;
width?: number;
height?: number;
size?: number;
testId?: string;
loading?: "lazy" | "eager";
}
let {
weaponSplId: weaponSplIdProp,
weapon,
variant: variantProp,
title,
...rest
}: Props = $props();
const weaponSplId = $derived(weapon?.weaponSplId ?? weaponSplIdProp!);
const variant = $derived(
variantProp ?? (weapon ? resolveWeaponBadgeVariant(weapon) : "badge"),
);
const label = $derived(title ?? mainWeaponName(weaponSplId));
const path = $derived(
variant === "badge"
? outlinedMainWeaponImageUrl(weaponSplId)
: variant === "badge-5-star"
? outlinedFiveStarMainWeaponImageUrl(weaponSplId)
: variant === "badge-10-star"
? outlinedTenStarMainWeaponImageUrl(weaponSplId)
: mainWeaponImageUrl(weaponSplId),
);
function resolveWeaponBadgeVariant(weapon: WeaponWithStars) {
if (weapon.isFavorite && weapon.isTenStar) return "badge-10-star" as const;
if (weapon.isFavorite) return "badge-5-star" as const;
return "badge" as const;
}
</script>
<Image {...rest} alt={label} title={label} {path} />

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { filterWeapon } from "@sendou/in-game-lists/utils";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import { Select, SelectItem, SelectItemSection } from "@sendou/components";
import { mainWeaponName } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import { weaponCategoryUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
import WeaponImage from "./WeaponImage.svelte";
interface Props {
label?: string;
value?: MainWeaponId | null;
initialValue?: MainWeaponId;
onChange?: (weaponId: MainWeaponId | null) => void;
clearable?: boolean;
disabledWeaponIds?: Array<MainWeaponId>;
testId?: string;
isRequired?: boolean;
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
quickSelectWeaponsIds?: Array<MainWeaponId>;
isDisabled?: boolean;
placeholder?: string;
}
let {
label,
value,
initialValue,
onChange,
clearable,
disabledWeaponIds,
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
isDisabled,
placeholder,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = value !== undefined;
let searchValue = $state("");
const allCategories = $derived(
weaponCategories.map((category) => ({
name: category.name,
items: category.weaponIds.map((id) => ({
id: id as MainWeaponId,
name: mainWeaponName(id),
})),
})),
);
const visibleCategories = $derived.by(() => {
if (searchValue === "" && quickSelectWeaponsIds?.length) {
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
if (typeof value === "number") {
weaponIdsToInclude.add(value);
}
return [
{
name: m.common_forms_weaponSearch_quickSelect(),
isQuickSelect: true,
items: allCategories
.flatMap((category) => category.items)
.filter((item) => weaponIdsToInclude.has(item.id))
.sort(
(a, b) =>
quickSelectWeaponsIds.indexOf(a.id) -
quickSelectWeaponsIds.indexOf(b.id),
),
},
];
}
if (searchValue === "") {
return allCategories.map((category) => ({
...category,
isQuickSelect: false,
}));
}
return allCategories
.map((category) => ({
...category,
isQuickSelect: false,
items: category.items.filter((item) =>
filterWeapon({
weapon: { id: item.id, type: "MAIN" },
weaponName: item.name,
searchTerm: searchValue,
}),
),
}))
.filter((category) => category.items.length > 0);
});
function handleSelectionChange(key: string | number | null) {
onChange?.(key === null ? null : (Number(key) as MainWeaponId));
}
</script>
<Select
{label}
aria-label={!label ? m.common_forms_weaponSearch_placeholder() : undefined}
{isDisabled}
{isRequired}
placeholder={placeholder ?? m.common_forms_weaponSearch_placeholder()}
search={{ placeholder: m.common_forms_weaponSearch_search_placeholder() }}
bind:searchValue
selectedKey={isControlled ? (value ?? null) : undefined}
defaultSelectedKey={isControlled ? undefined : initialValue}
onSelectionChange={handleSelectionChange}
{clearable}
{testId}
noResultsText={m.common_noResults()}
>
{#snippet valueContent(selectedKey)}
<span class="item">
<WeaponImage
weaponSplId={Number(selectedKey) as MainWeaponId}
variant="build"
size={24}
class="weaponImg"
/>
<span class="weaponLabel">
{mainWeaponName(Number(selectedKey))}
</span>
</span>
{/snippet}
{#each visibleCategories as category, idx (category.name)}
<SelectItemSection
heading={category.name}
class={idx === 0 ? "pt-0-5" : undefined}
>
{#snippet headingImg()}
{#if !category.isQuickSelect}
<Image path={weaponCategoryUrl(category.name)} size={28} alt="" />
{/if}
{/snippet}
{#each category.items as item (item.id)}
<SelectItem
id={item.id}
textValue={item.name}
isDisabled={disabledWeaponIds?.includes(item.id)}
>
<div class="item">
<WeaponImage
weaponSplId={item.id}
variant="build"
size={24}
class="weaponImg"
/>
<span
class="weaponLabel"
data-testid="weapon-select-option-{item.name}"
>
{item.name}
</span>
</div>
</SelectItem>
{/each}
</SelectItemSection>
{/each}
</Select>
<style>
.item {
display: flex;
gap: var(--s-2);
align-items: center;
}
.item :global(.weaponImg) {
min-width: 24px;
}
.weaponLabel {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,18 @@
import { page } from "$app/state";
import type { Role } from "#lib/modules/permissions/types.ts";
import type { AuthenticatedUser } from "./user-types.ts";
/** The logged in user as serialized to the client by the root layout. */
export type ClientUser = Omit<AuthenticatedUser, "customTheme" | "patronTier">;
/** The logged in user, or `undefined` when logged out. Reactive to navigation. */
export function loggedInUser(): ClientUser | undefined {
return (page.data as { user?: ClientUser }).user;
}
/** Whether the logged in user has the given global role. Always `false` when logged out. */
export function hasRole(role: Role): boolean {
const user = loggedInUser();
if (!user) return false;
return user.roles.includes(role);
}

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import invariant from "@sendou/utils/invariant";
import Flag from "#lib/components/Flag.svelte";
import Image from "#lib/components/Image.svelte";
import Placement from "#lib/components/Placement.svelte";
import { winnersImageUrl } from "#lib/utils/urls.ts";
import { topTenPlayerData } from "./leaderboards-utils.ts";
interface Props {
power?: number;
placement: number;
season: number;
small?: boolean;
}
let { power, placement, season, small = false }: Props = $props();
const data = $derived.by(() => {
const player = topTenPlayerData({ season, placement });
invariant(
player,
`No data for season ${season} and placement ${placement}`,
);
return player;
});
const transformMultiplier = $derived(small ? 1 / 3 : 1);
const containerStyle = $derived.by(() => {
const styles: string[] = [];
if (data.transforms?.top) {
styles.push(
`--winner-top: ${data.transforms.top * transformMultiplier}px`,
);
}
if (data.transforms?.left) {
styles.push(
`--winner-left: ${data.transforms.left * transformMultiplier}px`,
);
}
return styles.join("; ") || undefined;
});
</script>
<div
class={[
"stack horizontal items-center text-main-forced",
{ md: !small, sm: small, "mt-2": small },
]}
>
<div class={["container", { containerSmall: small }]}>
<Image
path={winnersImageUrl({ season, placement })}
alt=""
containerClass="imgContainer"
class="img"
height={small ? 50 : 150}
{containerStyle}
/>
</div>
<div>
<div
class="text-xs text-lighter stack horizontal xxs items-center"
style={placement > 3 ? "margin-block-end: -4px" : undefined}
>
{#if placement <= 3}
<Placement {placement} size={15} iconClass="mr-1" />
{/if}
<Placement {placement} textOnly showAsSuperscript={false} /> place
</div>
{#if !small}
<div class="text-xl font-semi-bold">
<Flag tiny countryCode={data.countryCode} />
{data.name}
</div>
<div class="text-lg font-bold" style="line-height: 1">
{power}
</div>
{/if}
</div>
</div>
<style>
.container {
height: 125px;
width: 125px;
border-radius: var(--radius-avatar);
background-color: var(--color-bg-high);
overflow: hidden;
position: relative;
}
.containerSmall {
height: 41.6667px;
width: 41.6667px;
}
.container :global(.imgContainer) {
position: absolute;
top: var(--winner-top, 5px);
left: var(--winner-left, 25px);
}
.container :global(.img) {
overflow: visible;
max-width: initial;
}
</style>

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { rankedModesShort } from "@sendou/in-game-lists/modes";
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import {
ChipRadio,
ChipRadioGroup,
Tab,
TabList,
TabPanel,
Tabs,
} from "@sendou/components";
import Image from "#lib/components/Image.svelte";
import Main from "#lib/components/Main.svelte";
import ModeImage from "#lib/components/ModeImage.svelte";
import WeaponSelect from "#lib/components/WeaponSelect.svelte";
import SeasonSelect from "#lib/features/mmr/SeasonSelect.svelte";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts";
import { m } from "#lib/paraglide/messages.js";
import {
modeLongName,
weaponCategoryName,
} from "#lib/modules/i18n/messages.ts";
import { navIconUrl, userSeasonsPage, weaponCategoryUrl } from "#lib/utils/urls.ts";
import { leaderboardsSearchParams } from "../leaderboards-search-params.ts";
import { seasonHasTopTen } from "../leaderboards-utils.ts";
import { getLeaderboards } from "../leaderboards.remote.ts";
import OwnEntryPeek from "./OwnEntryPeek.svelte";
import PlayersTable from "./PlayersTable.svelte";
import TeamTable from "./TeamTable.svelte";
import TopTenPlayer from "../TopTenPlayer.svelte";
import XPTable from "./XPTable.svelte";
const params = searchParamsState(leaderboardsSearchParams);
const data = $derived(await getLeaderboards(params.current));
const isAllUserLeaderboard = $derived(params.current.type === "USER");
const selectedTab = $derived(
params.current.type.startsWith("XP")
? "XP"
: params.current.type.startsWith("TEAM")
? "TEAMS"
: "PLAYERS",
);
const showTopTen = $derived(
Boolean(
seasonHasTopTen(data.season) &&
isAllUserLeaderboard &&
data.userLeaderboard,
),
);
const renderNoEntries = $derived(
(data.userLeaderboard && data.userLeaderboard.length === 0) ||
(data.teamLeaderboard && data.teamLeaderboard.length === 0),
);
const selectedWeaponId = $derived(
params.current.type.startsWith("XP-WEAPON")
? (Number(params.current.type.split("-")[2]) as MainWeaponId)
: null,
);
function onTabChange(key: string) {
if (key === selectedTab) return;
if (key === "PLAYERS") return params.set({ type: "USER" });
if (key === "TEAMS") return params.set({ type: "TEAM" });
params.set({ type: "XP-ALL", season: null });
}
</script>
<Main halfWidth>
<div class="stack lg leaderboards">
<Tabs selectedKey={selectedTab} onSelectionChange={onTabChange}>
<TabList>
<Tab id="PLAYERS">
{#snippet icon()}
<Image path={navIconUrl("sendouq")} alt="" width={16} />
{/snippet}
{m.common_leaderboard_tabs_players()}
</Tab>
<Tab id="TEAMS">
{#snippet icon()}
<Image path={navIconUrl("sendouq")} alt="" width={16} />
{/snippet}
{m.common_leaderboard_tabs_teams()}
</Tab>
<Tab id="XP">
{#snippet icon()}
<Image path={navIconUrl("xsearch")} alt="" width={16} />
{/snippet}
{m.common_leaderboard_tabs_xp()}
</Tab>
</TabList>
<TabPanel id="PLAYERS">
<div class="stack md">
<SeasonSelect
label={m.common_leaderboard_season()}
season={data.season}
onChange={(season) => params.set({ season })}
/>
<ChipRadioGroup wrap>
<ChipRadio
name="weapon-category"
value="ALL"
checked={params.current.type === "USER"}
onChange={() => params.set({ type: "USER" })}
>
{m.common_leaderboard_filter_all()}
</ChipRadio>
{#each weaponCategories as category (category.name)}
<ChipRadio
name="weapon-category"
value={category.name}
checked={params.current.type === `USER-${category.name}`}
onChange={() => params.set({ type: `USER-${category.name}` })}
>
<span class="stack horizontal xs items-center">
<Image
path={weaponCategoryUrl(category.name)}
size={18}
alt=""
/>
{weaponCategoryName(category.name)}
</span>
</ChipRadio>
{/each}
</ChipRadioGroup>
</div>
</TabPanel>
<TabPanel id="TEAMS">
<div class="stack md">
<SeasonSelect
label={m.common_leaderboard_season()}
season={data.season}
onChange={(season) => params.set({ season })}
/>
<ChipRadioGroup>
<ChipRadio
name="team-scope"
value="TEAM"
checked={params.current.type === "TEAM"}
onChange={() => params.set({ type: "TEAM" })}
>
{m.common_leaderboard_teams_best()}
</ChipRadio>
<ChipRadio
name="team-scope"
value="TEAM-ALL"
checked={params.current.type === "TEAM-ALL"}
onChange={() => params.set({ type: "TEAM-ALL" })}
>
{m.common_leaderboard_teams_all()}
</ChipRadio>
</ChipRadioGroup>
</div>
</TabPanel>
<TabPanel id="XP">
<div class="stack md">
<ChipRadioGroup wrap>
<ChipRadio
name="xp-mode"
value="XP-ALL"
checked={params.current.type === "XP-ALL"}
onChange={() => params.set({ type: "XP-ALL" })}
>
{m.common_leaderboard_filter_allModes()}
</ChipRadio>
{#each rankedModesShort as mode (mode)}
<ChipRadio
name="xp-mode"
value={`XP-MODE-${mode}`}
checked={params.current.type === `XP-MODE-${mode}`}
onChange={() => params.set({ type: `XP-MODE-${mode}` })}
>
<span class="stack horizontal xs items-center">
<ModeImage {mode} size={18} />
{modeLongName(mode)}
</span>
</ChipRadio>
{/each}
</ChipRadioGroup>
<WeaponSelect
clearable
value={selectedWeaponId}
onChange={(weaponId) =>
params.set({
type: weaponId === null ? "XP-ALL" : `XP-WEAPON-${weaponId}`,
})}
/>
</div>
</TabPanel>
</Tabs>
{#if showTopTen}
<div class="stack lg mx-auto">
{#each data.userLeaderboard!.slice(0, 10) as entry, i (`${entry.id}-${data.season}`)}
<a href={userSeasonsPage({ user: entry, season: data.season })}>
<TopTenPlayer
placement={i + 1}
power={entry.power}
season={data.season}
/>
</a>
{/each}
</div>
{/if}
{#if data.ownEntryPeek}
<OwnEntryPeek
entry={data.ownEntryPeek.entry}
nextTier={data.ownEntryPeek.nextTier}
season={data.season}
/>
{/if}
{#if data.userLeaderboard}
<PlayersTable
entries={data.userLeaderboard}
season={data.season}
showTiers={isAllUserLeaderboard}
showingTopTen={showTopTen}
/>
{/if}
{#if data.teamLeaderboard}
<TeamTable
entries={data.teamLeaderboard}
season={data.season}
queryArgs={params.current}
showQualificationDividers={params.current.type !== "TEAM-ALL"}
/>
{/if}
{#if data.xpLeaderboard}
<XPTable entries={data.xpLeaderboard} />
{/if}
{#if renderNoEntries}
<div class="text-center text-lg text-lighter">
{data.userLeaderboard
? m.common_leaderboard_noPlayers()
: m.common_leaderboard_noTeams()}
</div>
{/if}
{#if !data.xpLeaderboard && data.season === Seasons.current()?.nth}
<div class="text-xs text-lighter text-center">
{m.common_leaderboard_updateInfo()}
</div>
{/if}
</div>
</Main>
<style>
.leaderboards :global(.table) {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.leaderboards :global(.tableRank) {
min-width: 28px;
text-align: right;
}
.leaderboards :global(.tableName) {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 117px;
}
.leaderboards :global(.tierHeader) {
display: flex;
align-items: center;
gap: var(--s-2);
margin-block: var(--s-2);
color: var(--color-text-high);
}
.leaderboards :global(.tableRow) {
background-color: var(--color-bg-high);
display: flex;
padding: var(--s-2) var(--s-3);
align-items: center;
justify-content: space-between;
color: var(--color-text);
transition: 0.1s ease-in-out background-color;
border-radius: 0;
}
.leaderboards :global(.tableRow:first-of-type) {
border-radius: var(--radius-box) var(--radius-box) 0 0;
}
.leaderboards :global(.tableRow:last-of-type) {
border-radius: 0 0 var(--radius-box) var(--radius-box);
}
.leaderboards :global(.tableRow:only-child) {
border-radius: var(--radius-box);
}
.leaderboards :global(a.tableRow:hover) {
background-color: var(--color-bg-higher);
}
.leaderboards :global(.tableRowQualification) {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
justify-content: center;
background-color: var(--color-bg-higher);
display: flex;
gap: var(--s-2);
}
.leaderboards :global(.tableWeapon) {
background-color: var(--color-bg);
border-radius: 100%;
}
.leaderboards :global(.tablePower) {
margin-inline-start: auto;
}
.leaderboards :global(.tableInnerRow) {
display: flex;
align-items: center;
gap: var(--s-3);
width: 100%;
}
.leaderboards :global(.avatar) {
min-width: 24px;
min-height: 24px;
}
.leaderboards :global(.skippedTeam) {
text-decoration: line-through;
}
.leaderboards :global(.skippedTeam a) {
color: var(--color-text-high);
}
</style>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { ordinalToSp } from "#lib/features/mmr/mmr-utils.ts";
import { userSeasonsPage } from "#lib/utils/urls.ts";
import type { OwnEntryPeekData } from "../leaderboards-types.ts";
interface Props {
entry: OwnEntryPeekData["entry"];
nextTier: OwnEntryPeekData["nextTier"];
season: number;
}
let { entry, nextTier, season }: Props = $props();
</script>
<div>
{#if entry.firstOfTier}
<div class="tierHeader">
<TierImage tier={entry.firstOfTier} width={32} />
{entry.firstOfTier.name}{entry.firstOfTier.isPlus ? "+" : ""}
</div>
{/if}
<div>
<a href={userSeasonsPage({ user: entry, season })} class="tableRow">
<div class="tableInnerRow">
<div class="tableRank">{entry.placementRank}</div>
<div>
<Avatar size="xxs" user={entry} />
</div>
{#if typeof entry.weaponSplId === "number"}
<WeaponImage
class="tableWeapon"
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
{/if}
<div class="tableName">{entry.username}</div>
<div class="tablePower">{entry.power}</div>
</div>
</a>
</div>
{#if nextTier}
<div class="text-xs text-lighter ml-auto stack items-end">
{nextTier.name}{nextTier.isPlus ? "+" : ""} @ {ordinalToSp(
nextTier.neededOrdinal!,
)}SP
</div>
{/if}
</div>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { userSeasonsPage } from "#lib/utils/urls.ts";
import type { UserLeaderboardEntry } from "../leaderboards-types.ts";
interface Props {
entries: UserLeaderboardEntry[];
season: number;
showTiers?: boolean;
showingTopTen?: boolean;
}
let { entries, season, showTiers, showingTopTen }: Props = $props();
const shownEntries = $derived(
// hide normal rows that are showed in "fancy" top 10 format
entries.filter((_, i) => !showingTopTen || i > 9),
);
</script>
<div class="table">
{#each shownEntries as entry (entry.entryId)}
{#if entry.firstOfTier && showTiers}
<div class="tierHeader">
<TierImage tier={entry.firstOfTier} width={32} />
{entry.firstOfTier.name}{entry.firstOfTier.isPlus ? "+" : ""}
</div>
{/if}
<a href={userSeasonsPage({ user: entry, season })} class="tableRow">
<div class="tableInnerRow">
<div class="tableRank">{entry.placementRank}</div>
<div>
<Avatar size="xxs" user={entry} />
</div>
{#if typeof entry.weaponSplId === "number"}
<WeaponImage
class="tableWeapon"
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
{/if}
<div class="tableName">{entry.username}</div>
{#if entry.pendingPlusTier}
<div class="text-xs text-theme whitespace-nowrap">
➜ +{entry.pendingPlusTier}
</div>
{/if}
<div class="tablePower">{entry.power.toFixed(2)}</div>
</div>
</a>
{/each}
</div>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { Button, Menu, MenuItem } from "@sendou/components";
import { Ban, MoreHorizontal, RotateCcw } from "@lucide/svelte";
import { getLeaderboards, skipTeam, unskipTeam } from "../leaderboards.remote.ts";
import type { LeaderboardsQueryArgs } from "../leaderboards-schemas.ts";
import type { TeamLeaderboardEntry } from "../leaderboards-types.ts";
interface Props {
entry: Pick<TeamLeaderboardEntry, "identifier" | "isSkipped">;
season: number;
/** The query args of the leaderboard the client currently shows, so the mutation can update it single-flight. */
queryArgs: LeaderboardsQueryArgs;
}
let { entry, season, queryArgs }: Props = $props();
const fields = $derived({ season, identifier: entry.identifier });
</script>
<Menu>
{#snippet trigger(triggerProps)}
<Button
size="miniscule"
variant="outlined"
aria-label="Actions"
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
>
{#snippet icon()}<MoreHorizontal />{/snippet}
</Button>
{/snippet}
{#if entry.isSkipped}
<MenuItem
onAction={() => unskipTeam(fields).updates(getLeaderboards(queryArgs))}
>
{#snippet icon()}<RotateCcw />{/snippet}
Unskip
</MenuItem>
{:else}
<MenuItem
isDestructive
onAction={() => skipTeam(fields).updates(getLeaderboards(queryArgs))}
>
{#snippet icon()}<Ban />{/snippet}
Skip
</MenuItem>
{/if}
</Menu>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import InfoPopover from "#lib/components/InfoPopover.svelte";
import { hasRole } from "#lib/features/auth/user-state.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { m } from "#lib/paraglide/messages.js";
import { teamPage, userPage } from "#lib/utils/urls.ts";
import { TEAM_LEADERBOARD_QUALIFYING_COUNT } from "../leaderboards-constants.ts";
import type { LeaderboardsQueryArgs } from "../leaderboards-schemas.ts";
import type { TeamLeaderboardEntry } from "../leaderboards-types.ts";
import TeamStaffMenu from "./TeamStaffMenu.svelte";
interface Props {
entries: TeamLeaderboardEntry[];
season: number;
queryArgs: LeaderboardsQueryArgs;
showQualificationDividers?: boolean;
}
let {
entries,
season,
queryArgs,
showQualificationDividers: showQualificationDividersProp,
}: Props = $props();
const isStaff = $derived(hasRole("STAFF"));
const showStaffActions = $derived(isStaff && queryArgs.type !== "TEAM-ALL");
const isCurrentSeason = $derived(season === Seasons.current()?.nth);
const showQualificationDividers = $derived(
showQualificationDividersProp && isCurrentSeason && entries.length > 20,
);
</script>
<div class="table">
{#each entries as entry (entry.entryId)}
<div class="tableRow">
<div class="tableInnerRow">
<div class="tableRank">{entry.placementRank}</div>
{#if entry.team?.avatarUrl}
<a href={teamPage(entry.team.customUrl)} title={entry.team.name}>
<Avatar size="xxs" url={entry.team.avatarUrl} class="avatar" />
</a>
{/if}
<div class={["text-xs", { skippedTeam: entry.isSkipped }]}>
{#each entry.members as member, i (member.id)}
<a href={userPage(member)}>{member.username}</a
>{i !== entry.members.length - 1 ? ", " : ""}
{/each}
</div>
<div class="tablePower">{entry.power.toFixed(2)}</div>
{#if showStaffActions}
<TeamStaffMenu {entry} {season} {queryArgs} />
{/if}
</div>
</div>
{#if entry.placementRank === TEAM_LEADERBOARD_QUALIFYING_COUNT && showQualificationDividers}
<div class="tableRow tableRowQualification">
{m.common_leaderboard_qualification()}
<InfoPopover tiny>
{m.common_leaderboard_qualification_info()}
</InfoPopover>
</div>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { topSearchPlayerPage } from "#lib/utils/urls.ts";
import type { XPLeaderboardEntry } from "../leaderboards-types.ts";
interface Props {
entries: XPLeaderboardEntry[];
}
let { entries }: Props = $props();
</script>
<div class="table">
{#each entries as entry (entry.entryId)}
<a href={topSearchPlayerPage(entry.playerId)} class="tableRow">
<div class="tableInnerRow">
<div class="tableRank">{entry.placementRank}</div>
{#if entry.discordId}
<Avatar
size="xxs"
user={{
discordId: entry.discordId,
discordAvatar: entry.discordAvatar,
customAvatarUrl: entry.customAvatarUrl,
}}
/>
{/if}
<WeaponImage
class="tableWeapon"
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
<div>{entry.name}</div>
<div class="tablePower">{entry.power.toFixed(1)}</div>
</div>
</a>
{/each}
</div>

View File

@@ -0,0 +1,17 @@
import type { getLeaderboards } from "./leaderboards.remote.ts";
export type LeaderboardsData = Awaited<ReturnType<typeof getLeaderboards>>;
export type UserLeaderboardEntry = NonNullable<
LeaderboardsData["userLeaderboard"]
>[number];
export type TeamLeaderboardEntry = NonNullable<
LeaderboardsData["teamLeaderboard"]
>[number];
export type XPLeaderboardEntry = NonNullable<
LeaderboardsData["xpLeaderboard"]
>[number];
export type OwnEntryPeekData = NonNullable<LeaderboardsData["ownEntryPeek"]>;

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { Select, SelectItem, SelectItemSection } from "@sendou/components";
import * as Seasons from "./Seasons.ts";
interface Props {
/** Localized word for "season", used both as the select's label and as the prefix of every season's name. */
label: string;
season: number;
onChange: (season: number) => void;
/** If given, seasons it returns `true` for can't be selected. */
isSeasonDisabled?: (season: number) => boolean;
}
let { label, season, onChange, isSeasonDisabled }: Props = $props();
const seasonsByYear = $derived.by(() => {
const grouped = Object.groupBy(Seasons.allStarted(), (seasonNth) =>
Seasons.nthToDateRange(seasonNth).starts.getFullYear(),
);
return Object.entries(grouped)
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA))
.map(([year, seasons]) => ({
year,
seasons: (seasons ?? []).sort((a, b) => b - a),
}));
});
</script>
<Select
{label}
selectedKey={season}
onSelectionChange={(seasonNth) => {
if (seasonNth !== null) onChange(Number(seasonNth));
}}
>
{#each seasonsByYear as { year, seasons } (year)}
<SelectItemSection heading={year}>
{#each seasons as seasonNth (seasonNth)}
<SelectItem
id={seasonNth}
textValue={`${label} ${seasonNth}`}
isDisabled={isSeasonDisabled?.(seasonNth)}
>
{`${label} ${seasonNth}`}
</SelectItem>
{/each}
</SelectItemSection>
{/each}
</Select>

View File

@@ -0,0 +1,45 @@
import { m } from "#lib/paraglide/messages.js";
const messages = m as unknown as Record<
string,
((inputs?: Record<string, unknown>) => string) | undefined
>;
/**
* Resolves a paraglide message by a dynamically built key (e.g. weapon names
* keyed by id). Static call sites should always call the typed `m.*` functions
* directly instead; this exists only for id-indexed message families.
*/
export function dynamicMessage(key: string): string {
return messages[key]?.() ?? key;
}
/** Localized name of a main weapon. */
export function mainWeaponName(weaponSplId: number) {
return dynamicMessage(`weapons_MAIN_${weaponSplId}`);
}
/** Localized name of a sub weapon. */
export function subWeaponName(subWeaponSplId: number) {
return dynamicMessage(`weapons_SUB_${subWeaponSplId}`);
}
/** Localized name of a special weapon. */
export function specialWeaponName(specialWeaponSplId: number) {
return dynamicMessage(`weapons_SPECIAL_${specialWeaponSplId}`);
}
/** Localized long name of a mode (e.g. "Splat Zones"). */
export function modeLongName(mode: string) {
return dynamicMessage(`game_misc_MODE_LONG_${mode}`);
}
/** Localized stage name. */
export function stageName(stageId: number) {
return dynamicMessage(`game_misc_STAGE_${stageId}`);
}
/** Localized weapon category name (e.g. "Shooters"). */
export function weaponCategoryName(category: string) {
return dynamicMessage(`common_weapon_category_${category}`);
}

View File

@@ -0,0 +1,84 @@
import { logger } from "@sendou/utils/logger";
import type { LanguageCode } from "#lib/modules/i18n/languages.ts";
/**
* Returns the localized display name for a given ISO country code using the specified language. If the country code is unknown or the function fails for other reason, returns the country code itself as a fallback.
*/
export function countryCodeToTranslatedName({
countryCode,
language,
}: {
countryCode: string;
language: string;
}) {
if (countryCode === "GB-WLS") return "Wales";
if (countryCode === "GB-SCT") return "Scotland";
if (countryCode === "GB-NIR") return "Northern Ireland";
if (countryCode === "GB-ENG") return "England";
try {
return (
new Intl.DisplayNames([language], { type: "region" }).of(countryCode) ??
countryCode
);
} catch (e) {
logger.error(
`Error getting display name for country code "${countryCode}":`,
e,
);
return countryCode;
}
}
/**
* Ordinal placement suffixes per language, keyed by the CLDR ordinal plural
* category given by `Intl.PluralRules(language, { type: "ordinal" })`. A leading
* `^` marks a suffix that should render as superscript. Languages without a
* written ordinal suffix use explicit `null` – rendering no suffix is preferred
* over borrowing the English one.
*/
const ORDINAL_SUFFIXES: Record<
LanguageCode,
Partial<Record<Intl.LDMLPluralRule, string>> | null
> = {
da: null,
he: null,
nl: null,
en: { one: "^st", two: "^nd", few: "^rd", other: "^th" },
"es-ES": { other: "º" },
"es-US": { other: "^o" },
"fr-CA": { one: "^er", other: "^ème" },
"fr-EU": { one: "^er", other: "^ème" },
de: { other: "^." },
it: { many: "^o", other: "^o" },
ja: { other: "位" },
ko: { other: "^등" },
pl: { other: "^." },
"pt-BR": { other: "^º" },
ru: { other: "^ое" },
zh: { other: "名" },
};
const pluralRulesCache = new Map<string, Intl.PluralRules>();
/**
* Returns the localized ordinal suffix for a placement number in the given
* language (e.g. `"^st"` for `1` in English). A leading `^` marks a suffix the
* caller should render as superscript. Returns an empty string for languages
* without a written ordinal suffix.
*/
export function ordinalSuffix(placement: number, language: string): string {
const category = ordinalPluralRules(language).select(placement);
return ORDINAL_SUFFIXES[language as LanguageCode]?.[category] ?? "";
}
function ordinalPluralRules(language: string): Intl.PluralRules {
let rules = pluralRulesCache.get(language);
if (!rules) {
rules = new Intl.PluralRules(language, { type: "ordinal" });
pluralRulesCache.set(language, rules);
}
return rules;
}

View File

@@ -0,0 +1,26 @@
/** Truncates to at most `max` characters, preferring to cut at a sentence boundary. */
export function truncateBySentence(value: string, max: number) {
if (value.length <= max) {
return value;
}
// a sentence only ends at a terminator followed by whitespace, so that
// e.g. "18.00" does not split in the middle
const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || [];
let result = "";
for (const sentence of sentences) {
if ((result + sentence).length > max) {
break;
}
result += sentence;
}
// when cutting at a sentence boundary would leave most of the budget
// unused, a mid-sentence cut that fills it is more informative
if (result.length < max / 2) {
return value.slice(0, max).trim();
}
return result.trim();
}

View File

@@ -0,0 +1,84 @@
import { Config } from "#lib/config.ts";
const STATIC_ASSETS_URL = Config.staticAssetsUrl;
export const LEADERBOARDS_PAGE = "/leaderboards";
export const SENDOUQ_PAGE = "/q";
export const BLANK_IMAGE_URL = `${STATIC_ASSETS_URL}/img/blank.gif`;
export const TIER_PLUS_URL = `${STATIC_ASSETS_URL}/img/tiers/plus`;
export const FIRST_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/first.svg`;
export const SECOND_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/second.svg`;
export const THIRD_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/third.svg`;
export interface UserLinkArgs {
discordId: string;
customUrl?: string | null;
}
export const userPage = (user: UserLinkArgs) =>
`/u/${user.customUrl ?? user.discordId}`;
export const userSeasonsPage = ({
user,
season,
}: {
user: UserLinkArgs;
season?: number;
}) =>
`${userPage(user)}/seasons${typeof season === "number" ? `?season=${season}` : ""}`;
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
export const topSearchPage = () => "/xsearch";
export const topSearchPlayerPage = (playerId: number) =>
`${topSearchPage()}/player/${playerId}`;
export const navIconUrl = (navItem: string) =>
`${STATIC_ASSETS_URL}/img/layout/${navItem}`;
export const weaponCategoryUrl = (category: string) =>
`${STATIC_ASSETS_URL}/img/weapon-categories/${category}`;
export const mainWeaponImageUrl = (mainWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/main-weapons/${mainWeaponSplId}`;
export const outlinedMainWeaponImageUrl = (mainWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/main-weapons-outlined/${mainWeaponSplId}`;
export const outlinedFiveStarMainWeaponImageUrl = (mainWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/main-weapons-outlined-2/${mainWeaponSplId}`;
export const outlinedTenStarMainWeaponImageUrl = (mainWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/main-weapons-outlined-3/${mainWeaponSplId}`;
export const subWeaponImageUrl = (subWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`;
export const specialWeaponImageUrl = (specialWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`;
export const stageImageUrl = (stageId: number) =>
`${STATIC_ASSETS_URL}/img/stages/${stageId}`;
export const modeImageUrl = (mode: string) =>
`${STATIC_ASSETS_URL}/img/modes/${mode}`;
export const tierImageUrl = (tier: string) =>
`${STATIC_ASSETS_URL}/img/tiers/${tier === "CALCULATING" ? "unranked" : tier.toLowerCase()}`;
export const winnersImageUrl = ({
season,
placement,
}: {
season: number;
placement: number;
}) => `${STATIC_ASSETS_URL}/img/winners/${season}/${placement}`;
export const resolveAvatarUrl = ({
customAvatarUrl,
discordId,
discordAvatar,
size,
}: {
customAvatarUrl?: string | null;
discordId: string;
discordAvatar: string | null;
size: "sm" | "lg";
}) =>
customAvatarUrl ??
(discordAvatar
? `https://cdn.discordapp.com/avatars/${discordId}/${discordAvatar}.webp?size=${size === "lg" ? 240 : 80}`
: undefined);

View File

@@ -0,0 +1,25 @@
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = ({ locals }) => {
const user = locals.user;
return {
user: user
? {
id: user.id,
username: user.username,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
customUrl: user.customUrl,
customAvatarUrl: user.customAvatarUrl,
inGameName: user.inGameName,
friendCode: user.friendCode,
preferences: user.preferences,
languages: user.languages,
plusTier: user.plusTier,
roles: user.roles,
createdAt: user.createdAt,
}
: undefined,
};
};

View File

@@ -1 +1,12 @@
<h1>Leaderboards</h1>
<script lang="ts">
import MetaTags from "#lib/components/MetaTags.svelte";
import Leaderboards from "#lib/features/leaderboards/components/Leaderboards.svelte";
</script>
<MetaTags
title="Leaderboards"
ogTitle="Splatoon leaderboards"
description="Leaderboards of top Splatoon players ranked by their X Battle placements as well as tournament and SendouQ results. Categories per weapon and mode."
/>
<Leaderboards />

View File

@@ -0,0 +1,13 @@
# @sendou/components
The Svelte UI kit for sendou.ink (`svelte-big-bang.md` phase 2+). Design goal:
progressive enhancement over ARIA maximalism — native primitives do the work
where the platform provides them, handwritten ARIA patterns where it doesn't.
Rules:
- components know nothing about sendou.ink: no db types, no i18n — user-visible
strings arrive via props/snippets
- styling uses the app's CSS variables (`vars.css`); components ship scoped
`<style>` blocks only
- consumed as source (`svelte` export condition); no build step

View File

@@ -0,0 +1,21 @@
{
"name": "@sendou/components",
"version": "0.0.1",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"default": "./src/index.ts"
}
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.56.9",
"typescript": "~6.0.3"
}
}

View File

@@ -0,0 +1,214 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
type ButtonVariant =
| "primary"
| "success"
| "destructive"
| "outlined"
| "outlined-success"
| "outlined-destructive"
| "minimal"
| "minimal-success"
| "minimal-destructive";
interface Props extends HTMLButtonAttributes {
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
shape?: "circle" | "square";
icon?: Snippet;
children?: Snippet;
testId?: string;
}
let {
variant,
size,
shape,
icon,
children,
testId,
class: className,
type = "button",
...rest
}: Props = $props();
</script>
<button
{...rest}
{type}
data-testid={testId}
class={[
className,
"button",
variant,
size,
shape,
]}
>
{#if icon}
<span class={["buttonIcon", size, { lonely: !children }]}>
{@render icon()}
</span>
{/if}
{#if children}{@render children()}{/if}
</button>
<style>
.button {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border-radius: var(--radius-field);
appearance: none;
background: var(--color-text-accent);
color: var(--color-text-inverse);
cursor: pointer;
font-size: var(--font-sm);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
user-select: none;
outline-color: var(--color-text-accent);
height: var(--field-size);
white-space: nowrap;
&:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
}
.outlined {
background-color: transparent;
color: var(--color-text-accent);
}
.outlined-success {
border-color: var(--color-success);
background-color: transparent;
color: var(--color-success);
outline-color: var(--color-success);
}
.outlined-destructive {
border-color: var(--color-error);
background-color: transparent;
color: var(--color-error);
outline-color: var(--color-error);
}
.small {
font-size: var(--font-xs);
height: var(--field-size-sm);
}
.miniscule {
font-size: var(--font-2xs);
height: var(--field-size-xs);
}
.big {
font-size: var(--font-md);
height: var(--field-size-lg);
}
.square {
aspect-ratio: 1 / 1;
padding: 0;
}
.circle {
border-radius: 50%;
aspect-ratio: 1 / 1;
padding: 0;
}
.minimal {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-text-accent);
outline: initial;
&:focus-visible {
outline: var(--focus-ring);
}
}
.minimal-success {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-success);
outline-color: var(--color-success);
}
.success {
border-color: var(--color-success);
background-color: var(--color-success);
outline-color: var(--color-success);
}
.destructive {
border-color: var(--color-error);
background-color: var(--color-error);
color: var(--color-text-inverse);
outline-color: var(--color-error);
}
.minimal-destructive {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-error);
outline-color: var(--color-error);
}
span.buttonIcon {
display: inline-flex;
min-width: 20px;
max-width: 20px;
margin-inline-end: var(--s-1-5);
&.lonely {
margin-inline-end: 0 !important;
}
&.small {
min-width: 18px;
max-width: 18px;
margin-inline-end: var(--s-1);
}
&.miniscule {
min-width: 14px;
max-width: 14px;
margin-inline-end: var(--s-1);
}
&.big {
min-width: 28px;
max-width: 28px;
margin-inline-end: var(--s-2);
}
& :global(svg) {
width: 100%;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
name: string;
value: string;
checked: boolean;
onChange: (value: string) => void;
children: Snippet;
}
let { name, value, checked, onChange, children }: Props = $props();
const id = $derived(`chip-radio-${name}-${value}`);
</script>
<input
type="radio"
{id}
{name}
{value}
{checked}
onchange={() => onChange(value)}
class="radio"
/>
<label for={id} class="label">
{@render children()}
</label>
<style>
.label {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
padding: 0 var(--s-2);
height: var(--selector-size);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
color: var(--color-text);
cursor: pointer;
transition: background-color 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.radio {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
&:focus-visible + .label {
outline: var(--focus-ring);
outline-offset: 2px;
}
&:checked + .label {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
orientation?: "horizontal" | "vertical";
wrap?: boolean;
class?: string;
}
let {
children,
orientation = "horizontal",
wrap = false,
class: className,
}: Props = $props();
</script>
<div
class={["group", className]}
data-orientation={orientation}
data-wrap={wrap}
role="radiogroup"
>
{@render children()}
</div>
<style>
.group {
display: flex;
gap: var(--s-1);
width: max-content;
&[data-orientation="vertical"] {
flex-direction: column;
align-items: stretch;
}
&[data-wrap="true"] {
flex-wrap: wrap;
width: auto;
}
}
</style>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setMenuContext, type MenuTriggerProps } from "./menu-context.ts";
interface Props {
trigger: Snippet<[MenuTriggerProps]>;
scrolling?: boolean;
opensLeft?: boolean;
popoverClass?: string;
children: Snippet;
}
let {
trigger,
scrolling,
opensLeft,
popoverClass,
children,
}: Props = $props();
let open = $state(false);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
function setOpen(next: boolean) {
if (open === next) return;
open = next;
if (next) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => {
focusItem("first");
});
} else {
popoverElement?.hidePopover();
}
}
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
popover.style.top = `${rect.bottom + 8}px`;
const popoverWidth = popover.getBoundingClientRect().width;
const alignedLeft = opensLeft
? rect.left
: rect.right - popoverWidth;
popover.style.left = `${Math.max(8, Math.min(alignedLeft, window.innerWidth - popoverWidth - 8))}px`;
}
function menuItems() {
return [
...(popoverElement?.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([aria-disabled="true"])',
) ?? []),
];
}
function focusItem(target: "first" | "last" | "next" | "previous") {
const elements = menuItems();
if (elements.length === 0) return;
const activeIndex = elements.findIndex(
(element) => element === document.activeElement,
);
const targetIndex =
target === "first"
? 0
: target === "last"
? elements.length - 1
: target === "next"
? (activeIndex + 1) % elements.length
: (activeIndex - 1 + elements.length) % elements.length;
elements[targetIndex].focus();
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
focusItem("next");
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
focusItem("previous");
return;
}
if (event.key === "Home") {
event.preventDefault();
focusItem("first");
return;
}
if (event.key === "End") {
event.preventDefault();
focusItem("last");
}
}
setMenuContext({
close() {
setOpen(false);
},
});
const triggerProps: MenuTriggerProps = {
get "aria-expanded"() {
return open;
},
"aria-haspopup": "menu",
onclick: () => setOpen(!open),
};
</script>
<span class="triggerContainer" bind:this={triggerContainer}>
{@render trigger(triggerProps)}
</span>
<!-- svelte-ignore a11y_no_static_element_interactions -- keydown steers the menu inside -->
<div
bind:this={popoverElement}
popover="manual"
class={["popover", "scrollbar", popoverClass, { scrolling }]}
onkeydown={onPopoverKeydown}
>
<div class="itemsContainer" role="menu">
{@render children()}
</div>
</div>
{#if open}
<div class="backdrop" onclick={() => setOpen(false)} aria-hidden="true"></div>
{/if}
<style>
.triggerContainer {
display: contents;
}
.popover {
position: fixed;
margin: 0;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
border: var(--border-style);
width: max-content;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-2);
color: var(--color-text);
}
.scrolling {
max-height: 300px !important;
overflow-y: auto;
}
.itemsContainer {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
&:focus-visible {
outline: none;
}
}
.backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getMenuContext } from "./menu-context.ts";
interface Props {
onAction?: () => void;
href?: string;
icon?: Snippet;
isActive?: boolean;
isDestructive?: boolean;
isDisabled?: boolean;
children: Snippet;
}
let {
onAction,
href,
icon,
isActive,
isDestructive,
isDisabled,
children,
}: Props = $props();
const menu = getMenuContext();
function act() {
if (isDisabled) return;
menu.close();
onAction?.();
}
</script>
{#snippet content()}
{#if icon}
<span class="itemIcon">{@render icon()}</span>
{/if}
{@render children()}
{/snippet}
{#if href}
<a
{href}
class={["item", { itemActive: isActive, itemDestructive: isDestructive, itemDisabled: isDisabled }]}
role="menuitem"
tabindex="-1"
aria-disabled={isDisabled || undefined}
onclick={() => menu.close()}
>
{@render content()}
</a>
{:else}
<button
type="button"
class={["item", { itemActive: isActive, itemDestructive: isDestructive, itemDisabled: isDisabled }]}
role="menuitem"
tabindex="-1"
aria-disabled={isDisabled || undefined}
onclick={act}
>
{@render content()}
</button>
{/if}
<style>
.item {
display: flex;
align-items: center;
font-weight: var(--weight-bold);
font-size: var(--font-xs);
color: var(--color-text);
white-space: nowrap;
gap: var(--s-2);
border-radius: var(--radius-field);
padding: var(--s-1-5) var(--s-3);
background-color: var(--color-bg-high);
width: 100%;
border: 0;
outline: none;
justify-content: flex-start;
transition: background-color 0.15s;
cursor: pointer;
&:hover,
&:focus {
background-color: var(--color-bg-higher);
}
}
.itemDisabled {
color: var(--color-text-high);
cursor: not-allowed;
}
.itemActive {
color: var(--color-text-accent);
}
.itemDestructive {
color: var(--color-error);
}
.itemIcon {
width: 18px;
display: inline-flex;
}
.itemIcon :global(svg) {
width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import type { Snippet } from "svelte";
export interface PopoverTriggerProps {
readonly "aria-expanded": boolean;
readonly "aria-haspopup": "dialog";
onclick: () => void;
}
interface Props {
trigger: Snippet<[PopoverTriggerProps]>;
popoverClass?: string;
onOpenChange?: (isOpen: boolean) => void;
isOpen?: boolean;
children: Snippet;
}
let {
trigger,
popoverClass,
onOpenChange,
isOpen,
children,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = isOpen !== undefined;
let uncontrolledOpen = $state(false);
const open = $derived(isControlled ? Boolean(isOpen) : uncontrolledOpen);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
$effect(() => {
if (open) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => popoverElement?.focus());
} else {
popoverElement?.hidePopover();
}
});
function setOpen(next: boolean) {
if (!isControlled) {
uncontrolledOpen = next;
}
onOpenChange?.(next);
}
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
const popoverRect = popover.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (popoverRect.height + 8 > spaceBelow && rect.top > spaceBelow) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 8}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 8}px`;
}
const centered = rect.left + rect.width / 2 - popoverRect.width / 2;
popover.style.left = `${Math.max(8, Math.min(centered, window.innerWidth - popoverRect.width - 8))}px`;
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
}
}
const triggerProps: PopoverTriggerProps = {
get "aria-expanded"() {
return open;
},
"aria-haspopup": "dialog",
onclick: () => setOpen(!open),
};
</script>
<span class="triggerContainer" bind:this={triggerContainer}>
{@render trigger(triggerProps)}
</span>
<div
bind:this={popoverElement}
popover="manual"
class={["content", popoverClass]}
role="dialog"
tabindex="-1"
onkeydown={onPopoverKeydown}
>
{@render children()}
</div>
{#if open}
<div class="backdrop" onclick={() => setOpen(false)} aria-hidden="true"></div>
{/if}
<style>
.triggerContainer {
display: contents;
}
.content {
position: fixed;
margin: 0;
max-width: min(20rem, calc(100vw - var(--s-4)));
overflow: auto;
padding: var(--s-2);
border: var(--border-style);
border-radius: var(--radius-box);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
white-space: pre-wrap;
background-color: var(--color-bg);
color: var(--color-text);
outline: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,579 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setSelectContext } from "./select-context.ts";
interface Props {
label?: string;
placeholder?: string;
selectedKey?: string | number | null;
defaultSelectedKey?: string | number;
onSelectionChange?: (key: string | number | null) => void;
onOpenChange?: (isOpen: boolean) => void;
clearable?: boolean;
isDisabled?: boolean;
isRequired?: boolean;
testId?: string;
"aria-label"?: string;
search?: { placeholder?: string };
searchValue?: string;
noResultsText?: string;
clearText?: string;
popoverClass?: string;
/** Trigger content for the selected item; falls back to the item's registered text value. */
valueContent?: Snippet<[string | number]>;
children: Snippet;
}
let {
label,
placeholder,
selectedKey,
defaultSelectedKey,
onSelectionChange,
onOpenChange,
clearable = false,
isDisabled,
isRequired,
testId,
"aria-label": ariaLabel,
search,
searchValue = $bindable(""),
noResultsText = "No results",
clearText = "Clear",
popoverClass,
valueContent,
children,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = selectedKey !== undefined;
// svelte-ignore state_referenced_locally -- the default seeds the initial value only
let uncontrolledKey = $state<string | number | null>(
defaultSelectedKey ?? null,
);
const currentKey = $derived(
isControlled ? (selectedKey ?? null) : uncontrolledKey,
);
let open = $state(false);
let focusedKey = $state<string | number | null>(null);
interface RegisteredItem {
element: HTMLElement;
textValue: string;
disabled: boolean;
}
const items = new Map<string | number, RegisteredItem>();
let itemsVersion = $state(0);
let triggerElement = $state<HTMLButtonElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
let searchInputElement = $state<HTMLInputElement | null>(null);
const selectedText = $derived.by(() => {
itemsVersion;
return currentKey !== null
? items.get(currentKey)?.textValue
: undefined;
});
setSelectContext({
get selectedKey() {
return currentKey;
},
get focusedKey() {
return focusedKey;
},
registerItem(key, element, options) {
items.set(key, { element, ...options });
itemsVersion++;
return () => {
items.delete(key);
itemsVersion++;
};
},
select(key) {
commitSelection(key);
},
setFocusedKey(key) {
focusedKey = key;
},
});
function commitSelection(key: string | number | null) {
if (!isControlled) {
uncontrolledKey = key;
}
onSelectionChange?.(key);
setOpen(false);
triggerElement?.focus();
}
function setOpen(next: boolean) {
if (open === next) return;
open = next;
onOpenChange?.(next);
if (next) {
popoverElement?.showPopover();
positionPopover();
focusedKey = currentKey ?? orderedKeys(false)[0] ?? null;
requestAnimationFrame(() => {
if (search) {
searchInputElement?.focus();
} else {
popoverElement?.focus();
}
scrollFocusedIntoView();
});
} else {
popoverElement?.hidePopover();
searchValue = "";
focusedKey = null;
}
}
function positionPopover() {
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
popover.style.width = `${rect.width}px`;
popover.style.left = `${rect.left}px`;
const maxHeight = 300;
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (spaceBelow < 150) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 4}px`;
popover.style.maxHeight = `${Math.min(maxHeight, rect.top - 8)}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 4}px`;
popover.style.maxHeight = `${Math.min(maxHeight, spaceBelow)}px`;
}
}
function orderedKeys(visibleOnly = true) {
void visibleOnly;
return [...items.entries()]
.filter(([, item]) => !item.disabled)
.sort(([, a], [, b]) =>
a.element.compareDocumentPosition(b.element) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1,
)
.map(([key]) => key);
}
function moveFocus(direction: "next" | "previous" | "first" | "last") {
const keys = orderedKeys();
if (keys.length === 0) return;
const currentIndex =
focusedKey === null ? -1 : keys.indexOf(focusedKey);
const targetIndex =
direction === "first"
? 0
: direction === "last"
? keys.length - 1
: direction === "next"
? Math.min(currentIndex + 1, keys.length - 1)
: Math.max(currentIndex - 1, 0);
focusedKey = keys[targetIndex];
scrollFocusedIntoView();
}
function scrollFocusedIntoView() {
if (focusedKey === null) return;
items.get(focusedKey)?.element.scrollIntoView({ block: "nearest" });
}
function onTriggerKeydown(event: KeyboardEvent) {
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
setOpen(true);
}
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerElement?.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveFocus("next");
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveFocus("previous");
return;
}
if (event.key === "Home" && !search) {
event.preventDefault();
moveFocus("first");
return;
}
if (event.key === "End" && !search) {
event.preventDefault();
moveFocus("last");
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (focusedKey !== null && !items.get(focusedKey)?.disabled) {
commitSelection(focusedKey);
}
}
}
function onPopoverToggle(event: Event) {
const toggleEvent = event as ToggleEvent;
if (toggleEvent.newState === "closed" && open) {
open = false;
onOpenChange?.(false);
searchValue = "";
focusedKey = null;
}
}
const hasVisibleItems = $derived.by(() => {
itemsVersion;
return items.size > 0;
});
const uid = $props.id();
const labelId = $derived(label ? `${uid}-select-label` : undefined);
</script>
<svelte:window
onresize={() => open && positionPopover()}
onscroll={() => open && positionPopover()}
/>
<div class="select" data-testid={testId}>
{#if label}
<span class="label" id={labelId}>{label}</span>
{/if}
<button
type="button"
class="button"
bind:this={triggerElement}
disabled={isDisabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
aria-required={isRequired || undefined}
onclick={() => setOpen(!open)}
onkeydown={onTriggerKeydown}
>
<span class="selectValue" data-placeholder={selectedText === undefined ? "true" : undefined}>
{#if currentKey !== null && valueContent}
{@render valueContent(currentKey)}
{:else if selectedText !== undefined}
{selectedText}
{:else}
{placeholder}
{/if}
</span>
<span aria-hidden="true">
<svg
class="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</span>
</button>
{#if clearable && currentKey !== null}
<button
type="button"
class="clearButton"
onclick={() => commitSelection(null)}
>
<svg
class="clearIcon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
{clearText}
</button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -- keydown steers the listbox inside -->
<div
bind:this={popoverElement}
popover="manual"
class={["popover", popoverClass]}
ontoggle={onPopoverToggle}
onkeydown={onPopoverKeydown}
tabindex="-1"
>
{#if search}
<div class="searchField" data-empty={searchValue === "" ? "true" : undefined}>
<svg
class="icon"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="search"
bind:this={searchInputElement}
bind:value={searchValue}
placeholder={search.placeholder}
aria-label="Search"
class="searchInput in-container"
/>
<button
type="button"
class="searchClearButton"
tabindex="-1"
aria-label="Clear"
onclick={() => {
searchValue = "";
searchInputElement?.focus();
}}
>
<svg
class="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{/if}
<div class="listBox scrollbar" role="listbox" aria-labelledby={labelId}>
{@render children()}
{#if !hasVisibleItems}
<div class="noResults">{noResultsText}</div>
{/if}
</div>
</div>
</div>
{#if open}
<div
class="backdrop"
onclick={() => setOpen(false)}
aria-hidden="true"
></div>
{/if}
<style>
.select {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin: 0;
display: block;
text-box: trim-start cap alphabetic;
}
.button {
height: var(--field-size);
padding: 0 var(--field-padding);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
outline: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-1-5);
width: 100%;
cursor: pointer;
&:focus-visible,
&[aria-expanded="true"] {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
}
.selectValue {
font-size: var(--font-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--color-text);
display: flex;
align-items: center;
&[data-placeholder] {
color: var(--color-text-high);
}
}
.icon {
min-width: 18px;
max-width: 18px;
color: var(--color-text-high);
}
.popover {
position: fixed;
margin: 0;
padding: var(--s-1);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
outline: none;
color: var(--color-text);
display: none;
flex-direction: column;
&:popover-open {
display: flex;
}
}
.listBox {
overflow: auto;
flex: 1;
}
.searchField {
position: relative;
display: flex;
gap: var(--s-2);
border-bottom: 1px solid var(--color-border);
border-radius: 0;
accent-color: var(--color-accent);
color: var(--color-text);
outline: none;
padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2))
var(--s-1-5);
margin-block-end: var(--s-1-5);
&[data-empty] .searchClearButton {
visibility: hidden;
}
}
.searchInput {
all: unset;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
letter-spacing: 0.5px;
flex: 1;
min-width: 0;
padding-inline-end: calc(18px + var(--s-2));
&::-webkit-search-cancel-button {
display: none;
}
&::placeholder {
color: var(--color-text-high);
}
}
.searchClearButton {
position: absolute;
right: var(--s-2);
top: 50%;
transform: translateY(-50%);
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-high);
}
.noResults {
font-size: var(--font-md);
font-weight: var(--weight-bold);
text-align: center;
padding-block: var(--s-8);
color: var(--color-text-high);
}
.clearButton {
position: absolute;
bottom: -21px;
right: 9px;
display: flex;
align-items: center;
border: none;
background-color: transparent;
color: var(--color-error);
cursor: pointer;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
height: var(--field-size-xs);
padding: 0;
}
.clearIcon {
min-width: 14px;
max-width: 14px;
margin-inline-end: var(--s-1);
}
.backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getSelectContext } from "./select-context.ts";
interface Props {
id: string | number;
textValue: string;
isDisabled?: boolean;
children: Snippet;
}
let { id, textValue, isDisabled = false, children }: Props = $props();
const select = getSelectContext();
const isSelected = $derived(select.selectedKey === id);
const isFocused = $derived(select.focusedKey === id);
function register(element: HTMLElement) {
return select.registerItem(id, element, {
textValue,
disabled: isDisabled,
});
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -- keyboard handled by the listbox popover -->
<div
class={["item", { itemFocused: isFocused, itemSelected: isSelected }]}
role="option"
tabindex={-1}
aria-selected={isSelected}
aria-disabled={isDisabled || undefined}
data-disabled={isDisabled ? "true" : undefined}
onclick={() => {
if (!isDisabled) select.select(id);
}}
onpointermove={() => {
if (!isDisabled) select.setFocusedKey(id);
}}
{@attach register}
>
{@render children()}
</div>
<style>
.item {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-1-5);
border-radius: var(--radius-field);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
&[data-disabled] {
color: var(--color-text-high);
cursor: not-allowed;
}
}
.itemFocused {
background-color: var(--color-bg-high);
color: var(--color-text);
}
.itemSelected {
color: var(--color-text-accent);
font-weight: var(--weight-bold);
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
heading: string;
headingImg?: Snippet;
class?: string;
children: Snippet;
}
let { heading, headingImg, class: className, children }: Props = $props();
</script>
<div role="group" aria-label={heading}>
<div class={[className, "categoryHeading"]}>
{#if headingImg}{@render headingImg()}{/if}
{heading}
<div class="categoryDivider"></div>
</div>
{@render children()}
</div>
<style>
.categoryHeading {
display: flex;
align-items: center;
gap: var(--s-2);
font-weight: var(--weight-extra);
color: var(--color-text-high);
text-transform: uppercase;
font-size: var(--font-2xs);
padding-block-start: var(--s-3);
padding-block-end: var(--s-1);
padding-inline: var(--s-1-5);
white-space: nowrap;
}
.categoryHeading :global(img) {
border-radius: 100%;
background-color: var(--color-bg-higher);
padding: var(--s-1);
min-width: 28px;
}
.categoryDivider {
background-color: var(--color-border);
width: 100%;
height: 2px;
margin-block: var(--s-2);
}
</style>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
interface Props {
id: string;
icon?: Snippet;
number?: number;
/** Render a warning-colored alert icon to draw attention to this tab. */
alert?: boolean;
children?: Snippet;
}
let { id, icon, number, alert, children }: Props = $props();
const tabs = getTabsContext();
const selected = $derived(tabs.selectedKey === id);
function onkeydown(event: KeyboardEvent) {
const nextKey =
tabs.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const previousKey =
tabs.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
const direction =
event.key === nextKey
? ("next" as const)
: event.key === previousKey
? ("previous" as const)
: event.key === "Home"
? ("first" as const)
: event.key === "End"
? ("last" as const)
: null;
if (!direction) return;
event.preventDefault();
tabs.moveFocus(id, direction);
}
function registerTab(element: HTMLElement) {
return tabs.registerTab(id, element);
}
</script>
<div
class="tabContainer"
role="tab"
id="tab-{id}"
aria-selected={selected}
aria-controls="tabpanel-{id}"
tabindex={selected ? 0 : -1}
data-selected={selected ? "true" : undefined}
onclick={() => tabs.select(id)}
{onkeydown}
{@attach registerTab}
>
<div class="tabButton">
{#if icon}{@render icon()}{/if}
{#if children}{@render children()}{/if}
{#if typeof number === "number" && number !== 0}
<span class="tabNumber">{number}</span>
{/if}
{#if alert}
<svg
class="tabAlert"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
{/if}
</div>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
interface Props {
sticky?: boolean;
/** Should tabs take 100% width with equal distribution? */
fullWidth?: boolean;
"aria-label"?: string;
children: Snippet;
}
let {
sticky,
fullWidth,
"aria-label": ariaLabel,
children,
}: Props = $props();
const tabs = getTabsContext();
</script>
<div class="tabListContainer scrollbar">
<div
class={["tabList", { sticky, fullWidth }]}
role="tablist"
aria-label={ariaLabel}
aria-orientation={tabs.orientation}
>
{@render children()}
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
interface Props {
id: string;
class?: string;
children: Snippet;
}
let { id, class: className, children }: Props = $props();
const tabs = getTabsContext();
const selected = $derived(tabs.selectedKey === id);
</script>
{#if selected}
<div
class={[className, "tabPanel"]}
role="tabpanel"
id="tabpanel-{id}"
aria-labelledby="tab-{id}"
>
{@render children()}
</div>
{/if}

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setTabsContext } from "./tabs-context.ts";
interface Props {
selectedKey?: string | null;
defaultSelectedKey?: string;
onSelectionChange?: (key: string) => void;
orientation?: "horizontal" | "vertical";
/** Should there be padding above the panels. Defaults to true, pass in false if the panel content is managing its own padding. */
padded?: boolean;
/** Hide tabs if only one tab shown? Defaults to true. */
disappearing?: boolean;
class?: string;
children: Snippet;
}
let {
selectedKey,
defaultSelectedKey,
onSelectionChange,
orientation = "horizontal",
padded = true,
disappearing = true,
class: className,
children,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = selectedKey !== undefined;
// svelte-ignore state_referenced_locally -- the default seeds the initial value only
let uncontrolledKey = $state<string | null>(defaultSelectedKey ?? null);
const currentKey = $derived(
isControlled ? (selectedKey ?? null) : uncontrolledKey,
);
const tabs = new Map<string, HTMLElement>();
let tabOrder = $state<string[]>([]);
function select(key: string) {
if (!isControlled) {
uncontrolledKey = key;
}
onSelectionChange?.(key);
}
function registerTab(key: string, element: HTMLElement) {
tabs.set(key, element);
syncOrder();
if (!isControlled && uncontrolledKey === null) {
uncontrolledKey = key;
}
return () => {
tabs.delete(key);
syncOrder();
};
}
function syncOrder() {
tabOrder = [...tabs.entries()]
.sort(([, a], [, b]) =>
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1,
)
.map(([key]) => key);
}
function moveFocus(
fromKey: string,
direction: "next" | "previous" | "first" | "last",
) {
if (tabOrder.length === 0) return;
const fromIndex = tabOrder.indexOf(fromKey);
const targetIndex =
direction === "first"
? 0
: direction === "last"
? tabOrder.length - 1
: direction === "next"
? (fromIndex + 1) % tabOrder.length
: (fromIndex - 1 + tabOrder.length) % tabOrder.length;
const targetKey = tabOrder[targetIndex];
tabs.get(targetKey)?.focus();
select(targetKey);
}
setTabsContext({
get selectedKey() {
return currentKey;
},
get orientation() {
return orientation;
},
select,
registerTab,
moveFocus,
});
</script>
<div
class={[
className,
"root",
{ padded, disappearing, vertical: orientation === "vertical" },
]}
>
{@render children()}
</div>
<style>
.root {
--tabs-gap: var(--s-6);
}
.root:not(.vertical) {
display: flex;
flex-direction: column;
}
.padded:not(.vertical) {
gap: var(--tabs-gap);
}
.disappearing {
&:has(:global(.tabList > div:only-child)) {
gap: 0;
}
& :global(.tabList:has(> div:only-child)) {
display: none;
}
}
.root :global(.tabListContainer) {
overflow-x: auto;
overflow-y: hidden;
}
.root :global(.tabList) {
display: flex;
flex-direction: row;
border-bottom: 2px solid var(--color-border);
min-width: fit-content;
}
.root :global(.tabList svg) {
--icon-size: 16px;
min-width: var(--icon-size);
min-height: var(--icon-size);
max-width: var(--icon-size);
max-height: var(--icon-size);
margin-inline-end: var(--s-1-5);
}
.root :global(.tabContainer) {
min-width: fit-content;
margin-bottom: -2px;
cursor: pointer;
}
.root :global(.tabContainer .tabButton) {
display: flex;
align-items: center;
justify-content: center;
appearance: none;
user-select: none;
height: var(--field-size);
padding: 0 var(--field-padding);
font-weight: var(--weight-bold);
background-color: transparent;
border: none;
font-size: var(--font-xs);
border-radius: 0;
border-bottom: 2px solid transparent;
color: var(--color-text-high);
white-space: nowrap;
flex: 1;
transform: none !important;
}
.root :global(.tabContainer .tabButton img) {
min-width: 16px;
min-height: 16px;
margin-inline-end: var(--s-1-5);
}
.root :global(.tabContainer:focus-visible) {
outline: var(--focus-ring);
outline-offset: -2px;
}
.root :global(.tabContainer[data-selected] .tabButton) {
border-color: var(--color-text-accent);
color: var(--color-text);
}
.root :global(.tabContainer:focus-visible .tabButton) {
color: var(--color-text-accent) !important;
outline: none;
}
.root :global(.fullWidth) {
width: 100%;
min-width: 100%;
}
.root :global(.fullWidth .tabContainer) {
flex: 1;
min-width: 0;
}
.root :global(.tabNumber) {
color: var(--color-text-accent);
margin-inline-start: var(--s-2);
}
.root :global(.tabAlert) {
margin-inline-start: var(--s-1);
margin-inline-end: 0 !important;
color: var(--color-warning);
}
.root :global(.sticky) {
position: sticky;
top: 47px;
z-index: 1;
background-color: var(--color-bg);
}
.vertical {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--tabs-gap);
align-items: start;
}
.vertical :global(.tabListContainer) {
overflow: visible;
position: sticky;
top: var(--layout-sticky-top);
align-self: start;
background-color: var(--color-bg);
z-index: 1;
}
.vertical :global(.tabList) {
flex-direction: column;
border-bottom: none;
border-inline-end: 2px solid var(--color-border);
min-width: 0;
}
.vertical :global(.tabContainer) {
margin-bottom: 0;
margin-inline-end: -2px;
}
.vertical :global(.tabContainer[data-selected] .tabButton) {
border-bottom-color: transparent;
border-inline-end-color: var(--color-text-accent);
}
.vertical :global(.tabButton) {
justify-content: flex-start;
border-bottom: none;
border-inline-end: 2px solid transparent;
text-align: start;
flex: none;
width: 100%;
padding: var(--s-2) var(--s-3);
padding-inline-end: var(--s-6);
}
.vertical :global(.tabNumber) {
margin-inline-start: auto;
padding-inline-start: var(--s-3);
}
.vertical :global(.tabPanel) {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,14 @@
export { default as Button } from "./Button.svelte";
export { default as ChipRadio } from "./ChipRadio.svelte";
export { default as ChipRadioGroup } from "./ChipRadioGroup.svelte";
export { default as Menu } from "./Menu.svelte";
export { default as MenuItem } from "./MenuItem.svelte";
export type { MenuTriggerProps } from "./menu-context.ts";
export { default as Popover } from "./Popover.svelte";
export { default as Select } from "./Select.svelte";
export { default as SelectItem } from "./SelectItem.svelte";
export { default as SelectItemSection } from "./SelectItemSection.svelte";
export { default as Tab } from "./Tab.svelte";
export { default as TabList } from "./TabList.svelte";
export { default as TabPanel } from "./TabPanel.svelte";
export { default as Tabs } from "./Tabs.svelte";

View File

@@ -0,0 +1,25 @@
import { getContext, setContext } from "svelte";
export interface MenuTriggerProps {
readonly "aria-expanded": boolean;
readonly "aria-haspopup": "menu";
onclick: () => void;
}
export interface MenuContext {
close: () => void;
}
const KEY = Symbol("menu");
export function setMenuContext(context: MenuContext) {
setContext(KEY, context);
}
export function getMenuContext(): MenuContext {
const context = getContext<MenuContext>(KEY);
if (!context) {
throw new Error("Menu items must be used inside <Menu>");
}
return context;
}

View File

@@ -0,0 +1,27 @@
import { getContext, setContext } from "svelte";
export interface SelectContext {
readonly selectedKey: string | number | null;
readonly focusedKey: string | number | null;
registerItem: (
key: string | number,
element: HTMLElement,
options: { textValue: string; disabled: boolean },
) => () => void;
select: (key: string | number) => void;
setFocusedKey: (key: string | number | null) => void;
}
const KEY = Symbol("select");
export function setSelectContext(context: SelectContext) {
setContext(KEY, context);
}
export function getSelectContext(): SelectContext {
const context = getContext<SelectContext>(KEY);
if (!context) {
throw new Error("Select items must be used inside <Select>");
}
return context;
}

View File

@@ -0,0 +1,23 @@
import { getContext, setContext } from "svelte";
export interface TabsContext {
readonly selectedKey: string | null;
readonly orientation: "horizontal" | "vertical";
select: (key: string) => void;
registerTab: (key: string, element: HTMLElement) => () => void;
moveFocus: (fromKey: string, direction: "next" | "previous" | "first" | "last") => void;
}
const KEY = Symbol("tabs");
export function setTabsContext(context: TabsContext) {
setContext(KEY, context);
}
export function getTabsContext(): TabsContext {
const context = getContext<TabsContext>(KEY);
if (!context) {
throw new Error("Tabs components must be used inside <Tabs>");
}
return context;
}

24
pnpm-lock.yaml generated
View File

@@ -22,6 +22,12 @@ importers:
'@epic-web/cachified':
specifier: 5.6.3
version: 5.6.3
'@lucide/svelte':
specifier: 0.544.0
version: 0.544.0(svelte@5.56.9)
'@sendou/components':
specifier: workspace:*
version: link:../../packages/components
'@sendou/in-game-lists':
specifier: workspace:*
version: link:../../packages/in-game-lists
@@ -368,6 +374,15 @@ importers:
specifier: 4.1.10
version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))
packages/components:
devDependencies:
svelte:
specifier: ^5.56.9
version: 5.56.9
typescript:
specifier: ~6.0.3
version: 6.0.3
packages/in-game-lists:
devDependencies:
typescript:
@@ -1208,6 +1223,11 @@ packages:
'@lix-js/sdk@0.12.2':
resolution: {integrity: sha512-sKGOPdVKdChS5q60F4VQE4ZTcf0B7ZJM3KGCVQFN2suVNKhHcllbsCiwRYuM5WL7Yu1ql7YexltJ2FCsDqKePg==}
'@lucide/svelte@0.544.0':
resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==}
peerDependencies:
svelte: ^5
'@mjackson/headers@0.10.0':
resolution: {integrity: sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==}
@@ -5904,6 +5924,10 @@ snapshots:
'@lix-js/sdk-linux-x64': 0.12.2
'@lix-js/sdk-win32-x64': 0.12.2
'@lucide/svelte@0.544.0(svelte@5.56.9)':
dependencies:
svelte: 5.56.9
'@mjackson/headers@0.10.0': {}
'@napi-rs/canvas-android-arm64@1.0.3':