mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
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:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,5 +3,6 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "never"
|
||||
}
|
||||
},
|
||||
"js/ts.experimental.useTsgo": false
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
192
apps/web/src/lib/components/Avatar.svelte
Normal file
192
apps/web/src/lib/components/Avatar.svelte
Normal 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>
|
||||
20
apps/web/src/lib/components/Flag.svelte
Normal file
20
apps/web/src/lib/components/Flag.svelte
Normal 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>
|
||||
48
apps/web/src/lib/components/Image.svelte
Normal file
48
apps/web/src/lib/components/Image.svelte
Normal 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>
|
||||
55
apps/web/src/lib/components/InfoPopover.svelte
Normal file
55
apps/web/src/lib/components/InfoPopover.svelte
Normal 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>
|
||||
66
apps/web/src/lib/components/Main.svelte
Normal file
66
apps/web/src/lib/components/Main.svelte
Normal 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>
|
||||
46
apps/web/src/lib/components/MetaTags.svelte
Normal file
46
apps/web/src/lib/components/MetaTags.svelte
Normal 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>
|
||||
22
apps/web/src/lib/components/ModeImage.svelte
Normal file
22
apps/web/src/lib/components/ModeImage.svelte
Normal 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)} />
|
||||
63
apps/web/src/lib/components/Placement.svelte
Normal file
63
apps/web/src/lib/components/Placement.svelte
Normal 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}
|
||||
48
apps/web/src/lib/components/TierImage.svelte
Normal file
48
apps/web/src/lib/components/TierImage.svelte
Normal 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>
|
||||
61
apps/web/src/lib/components/WeaponImage.svelte
Normal file
61
apps/web/src/lib/components/WeaponImage.svelte
Normal 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} />
|
||||
187
apps/web/src/lib/components/WeaponSelect.svelte
Normal file
187
apps/web/src/lib/components/WeaponSelect.svelte
Normal 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>
|
||||
18
apps/web/src/lib/features/auth/user-state.ts
Normal file
18
apps/web/src/lib/features/auth/user-state.ts
Normal 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);
|
||||
}
|
||||
108
apps/web/src/lib/features/leaderboards/TopTenPlayer.svelte
Normal file
108
apps/web/src/lib/features/leaderboards/TopTenPlayer.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
17
apps/web/src/lib/features/leaderboards/leaderboards-types.ts
Normal file
17
apps/web/src/lib/features/leaderboards/leaderboards-types.ts
Normal 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"]>;
|
||||
50
apps/web/src/lib/features/mmr/SeasonSelect.svelte
Normal file
50
apps/web/src/lib/features/mmr/SeasonSelect.svelte
Normal 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>
|
||||
45
apps/web/src/lib/modules/i18n/messages.ts
Normal file
45
apps/web/src/lib/modules/i18n/messages.ts
Normal 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}`);
|
||||
}
|
||||
84
apps/web/src/lib/utils/i18n.ts
Normal file
84
apps/web/src/lib/utils/i18n.ts
Normal 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;
|
||||
}
|
||||
26
apps/web/src/lib/utils/strings.ts
Normal file
26
apps/web/src/lib/utils/strings.ts
Normal 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();
|
||||
}
|
||||
84
apps/web/src/lib/utils/urls.ts
Normal file
84
apps/web/src/lib/utils/urls.ts
Normal 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);
|
||||
25
apps/web/src/routes/+layout.server.ts
Normal file
25
apps/web/src/routes/+layout.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
13
packages/components/README.md
Normal file
13
packages/components/README.md
Normal 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
|
||||
21
packages/components/package.json
Normal file
21
packages/components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
214
packages/components/src/Button.svelte
Normal file
214
packages/components/src/Button.svelte
Normal 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>
|
||||
67
packages/components/src/ChipRadio.svelte
Normal file
67
packages/components/src/ChipRadio.svelte
Normal 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>
|
||||
44
packages/components/src/ChipRadioGroup.svelte
Normal file
44
packages/components/src/ChipRadioGroup.svelte
Normal 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>
|
||||
181
packages/components/src/Menu.svelte
Normal file
181
packages/components/src/Menu.svelte
Normal 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>
|
||||
112
packages/components/src/MenuItem.svelte
Normal file
112
packages/components/src/MenuItem.svelte
Normal 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>
|
||||
132
packages/components/src/Popover.svelte
Normal file
132
packages/components/src/Popover.svelte
Normal 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>
|
||||
579
packages/components/src/Select.svelte
Normal file
579
packages/components/src/Select.svelte
Normal 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>
|
||||
72
packages/components/src/SelectItem.svelte
Normal file
72
packages/components/src/SelectItem.svelte
Normal 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>
|
||||
51
packages/components/src/SelectItemSection.svelte
Normal file
51
packages/components/src/SelectItemSection.svelte
Normal 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>
|
||||
83
packages/components/src/Tab.svelte
Normal file
83
packages/components/src/Tab.svelte
Normal 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>
|
||||
32
packages/components/src/TabList.svelte
Normal file
32
packages/components/src/TabList.svelte
Normal 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>
|
||||
27
packages/components/src/TabPanel.svelte
Normal file
27
packages/components/src/TabPanel.svelte
Normal 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}
|
||||
285
packages/components/src/Tabs.svelte
Normal file
285
packages/components/src/Tabs.svelte
Normal 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>
|
||||
14
packages/components/src/index.ts
Normal file
14
packages/components/src/index.ts
Normal 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";
|
||||
25
packages/components/src/menu-context.ts
Normal file
25
packages/components/src/menu-context.ts
Normal 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;
|
||||
}
|
||||
27
packages/components/src/select-context.ts
Normal file
27
packages/components/src/select-context.ts
Normal 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;
|
||||
}
|
||||
23
packages/components/src/tabs-context.ts
Normal file
23
packages/components/src/tabs-context.ts
Normal 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
24
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user