Files
wyrm/src/lib/components/TabbedDialog.svelte
T

148 lines
4.0 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// A dialog of tabs: the about panel, how to play, the rules. The address
// names it, so a plain link opens it: #rules opens the rules, #guide/notation
// how to play at its notation. It closes with its button, a click outside
// it, or Escape. The tab last read is the one it reopens on. `open` tells
// the page whether it is showing.
import { untrack, type Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Tabs, { type Tab } from './Tabs.svelte';
let {
address,
title,
label = title,
tabs,
intro,
open = $bindable(false)
}: { address: string; title: string; label?: string; tabs: Tab[]; intro?: Snippet; open?: boolean } = $props();
let current = $state('');
// The dialog is open exactly while the router's address names it. Opening, choosing a tab and
// closing all navigate, so the address and the dialog cannot disagree.
const [named, tab] = $derived.by(() => {
const [name, t] = page.url.hash.slice(1).split('/');
return [name === address, t] as const;
});
$effect(() => {
open = named;
});
$effect(() => {
if (named && tabs.some((t) => t.id === tab)) untrack(() => (current = tab));
});
// The address follows the chosen tab, so what is being read can be linked to.
$effect(() => {
if (named && current) untrack(() => current !== tab && show(current));
});
const bare = () => `${page.url.pathname}${page.url.search}`;
const quietly = { replaceState: true, noScroll: true, keepFocus: true };
function show(id: string) {
goto(`${bare()}#${address}/${id}`, quietly);
}
function close() {
goto(bare(), quietly);
}
function onKey(e: KeyboardEvent) {
if (named && e.key === 'Escape') close();
}
</script>
<svelte:window onkeydown={onKey} />
{#if named}
<div class="scrim anchored" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="{address}-title">
<header>
<h2 id="{address}-title">{title}</h2>
<button type="button" class="close" aria-label="Close" onclick={close}>×</button>
</header>
<div class="body">
{#if intro}<div class="intro">{@render intro()}</div>{/if}
<Tabs {tabs} {label} bind:current />
</div>
</div>
</div>
{/if}
<style>
/* Its own backdrop: not every game has another dialog to share one with. */
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
/* Held near the top, not centered: a tab of another length must not move the tabs under the pointer. */
.anchored {
place-items: start center;
padding-top: max(16px, 6vh);
}
/* min-width 0 all the way down, so wide content scrolls in place instead of widening the dialog past a phone's screen. */
.panel {
width: min(100%, 44rem);
min-width: 0;
max-height: 100%;
display: flex;
flex-direction: column;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.4rem 1.2rem;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.6rem;
}
.close {
background: none;
border: none;
color: inherit;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
}
.body {
overflow: auto;
min-height: 12rem;
min-width: 0;
}
.intro {
color: var(--bone-dim);
}
.body :global(a) {
color: var(--frost);
}
.body :global(h3) {
font-size: 1rem;
font-style: italic;
margin: 0.9rem 0 0.2rem;
color: var(--bone);
}
.body :global(h3:first-child) {
margin-top: 0;
}
/* The tab strip stays in view while a long tab scrolls beneath it. */
.body :global([role='tablist']) {
position: sticky;
top: 0;
z-index: 1;
background: var(--slate);
}
</style>