Native popovers and dialogs for layout surfaces

- fix skipAnimation never updating in MobileNav (previousPanel was not state)
- declare App.PageData and drop the page.data casts
- migrate Menu, Select and TopNavMenus to popover=auto with CSS anchor
  positioning; delete manual positioning, backdrops and Escape handling
- share Popover's scroll-clip auto-close via closePopoverOnScrollClip
- use <dialog closedby=any> for the tablet sidenav modal and mobile panels;
  tab bar stays visible and switchable with no animation
- add IsMounted helper and use it for the hydrated checks
This commit is contained in:
Kalle
2026-08-16 22:24:44 +03:00
parent c2b47e3422
commit 8d007f4272
22 changed files with 437 additions and 323 deletions

12
apps/web/src/app.d.ts vendored
View File

@@ -1,3 +1,8 @@
import type {
Breadcrumb,
SidebarData,
} from "#lib/components/layout/layout-types.ts";
import type { ClientUser } from "#lib/features/auth/user-state.ts";
import type { AuthenticatedUser } from "#lib/features/auth/user-types.ts";
declare global {
@@ -5,8 +10,13 @@ declare global {
interface Locals {
user: AuthenticatedUser | undefined;
}
interface PageData {
user?: ClientUser;
sidebar?: SidebarData;
sidenavCollapsed?: boolean;
breadcrumbs?: Breadcrumb[];
}
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}

View File

@@ -29,12 +29,12 @@ function patchFetchOnce() {
<script lang="ts">
import { navigating } from "$app/state";
import { IsMounted } from "#lib/utils/is-mounted.svelte.ts";
let hydrated = $state(false);
const mounted = new IsMounted();
$effect(() => {
patchFetchOnce();
hydrated = true;
});
const busy = $derived.by(() => {
@@ -47,7 +47,7 @@ function patchFetchOnce() {
});
</script>
{#if hydrated}
{#if mounted.current}
<div
style="display: none"
data-testid="hydrated"

View File

@@ -47,9 +47,7 @@ const items = $derived.by(() => {
size="small"
shape={compact ? "square" : undefined}
testId="anything-adder-menu-button"
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
{...triggerProps}
>
{#snippet icon()}<Plus />{/snippet}
{#if !compact}{`${m.common_actions_addNew()}…`}{/if}

View File

@@ -115,9 +115,7 @@ function resolveActivity(friend: {
{subtitle}
badge={badgeText}
badgeVariant={streamUrl ? "warning" : "default"}
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
{...triggerProps}
>
{name}
</ListButton>

View File

@@ -23,7 +23,7 @@ let { tabCount, onTabPress }: Props = $props();
<style>
.ghostTabBar {
position: fixed;
bottom: calc(0px - var(--layout-nav-height));
bottom: 0;
left: 0;
right: 0;
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
@@ -31,7 +31,6 @@ let { tabCount, onTabPress }: Props = $props();
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
}
.ghostTab {

View File

@@ -12,13 +12,14 @@ import { setSidenavCollapsed } from "#lib/features/sidenav/sidenav.remote.ts";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import { GIT_COMMIT } from "#lib/utils/git-commit.ts";
import { IsMounted } from "#lib/utils/is-mounted.svelte.ts";
import { EVENTS_PAGE, FRIENDS_PAGE } from "#lib/utils/urls.ts";
import { afterNavigate } from "$app/navigation";
import { page } from "$app/state";
import Footer from "./Footer.svelte";
import FriendMenu from "./FriendMenu.svelte";
import ListLink from "./ListLink.svelte";
import type { NotificationRow, SidebarData } from "./layout-types.ts";
import type { Breadcrumb, NotificationRow } from "./layout-types.ts";
import MobileNav from "./MobileNav.svelte";
import SideNav from "./SideNav.svelte";
import SideNavFooter from "./SideNavFooter.svelte";
@@ -30,14 +31,6 @@ import TopRightButtons from "./TopRightButtons.svelte";
const MAX_DESKTOP_FRIENDS = 4;
interface Breadcrumb {
type: "IMAGE" | "TEXT";
imgPath?: string;
href: string;
text?: string;
identiconInput?: string;
}
interface Props {
children: Snippet;
}
@@ -45,23 +38,18 @@ interface Props {
let { children }: Props = $props();
const user = $derived(loggedInUser());
const sidebarData = $derived((page.data as { sidebar?: SidebarData }).sidebar);
const initialCollapsed =
(page.data as { sidenavCollapsed?: boolean }).sidenavCollapsed ?? false;
const sidebarData = $derived(page.data.sidebar);
const initialCollapsed = page.data.sidenavCollapsed ?? false;
// svelte-ignore state_referenced_locally -- the cookie value seeds the initial state only
let sideNavCollapsed = $state(initialCollapsed);
let sideNavModalOpen = $state(false);
let hydrated = $state(false);
$effect(() => {
hydrated = true;
});
const mounted = new IsMounted();
// the bell data loads lazily like the React app's NotificationsProvider:
// after hydration, never blocking SSR
const notificationsQuery = $derived(
hydrated && user ? getNotifications() : null,
mounted.current && user ? getNotifications() : null,
);
const notifications = $derived<NotificationRow[] | null>(
notificationsQuery?.current?.notifications
@@ -91,9 +79,7 @@ const isFrontPage = $derived(page.url.pathname === "/");
// phase 3); until then the button renders for layout parity with the React app
const noopUntilChatRebuild = () => {};
const breadcrumbs = $derived(
(page.data as { breadcrumbs?: Breadcrumb[] }).breadcrumbs ?? [],
);
const breadcrumbs = $derived(page.data.breadcrumbs ?? []);
const currentPageText = $derived(breadcrumbs.at(-1)?.text);
function unseenRequestsLabel(count: number) {
@@ -170,6 +156,10 @@ afterNavigate(() => {
sideNavModalOpen = false;
});
function showModal(dialog: HTMLDialogElement) {
dialog.showModal();
}
function formatRelativeDate(timestamp: number) {
const locale = getLocale();
const date = new Date(timestamp * 1000);
@@ -273,7 +263,7 @@ function formatRelativeDate(timestamp: number) {
user={event.user ?? undefined}
>
{#snippet subtitle()}
{#if hydrated}
{#if mounted.current}
{formatRelativeDate(event.startsAt)}
{:else}
<span class="invisible">Placeholder</span>
@@ -436,21 +426,20 @@ function formatRelativeDate(timestamp: number) {
</div>
{#if sideNavModalOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="sideNavModalOverlay"
onclick={(event) => {
if (event.target === event.currentTarget) sideNavModalOpen = false;
<dialog
class="sideNavModal"
closedby="any"
onclose={() => {
sideNavModalOpen = false;
}}
{@attach showModal}
>
<div class="sideNavModal">
<SideNav class="sideNavInModal" topCentered={isFrontPage}>
{#snippet top()}{@render siteTitle()}{/snippet}
{#snippet footer()}{@render sideNavFooterContent()}{/snippet}
{@render sideNavChildren()}
</SideNav>
</div>
</div>
<SideNav class="sideNavInModal" topCentered={isFrontPage}>
{#snippet top()}{@render siteTitle()}{/snippet}
{#snippet footer()}{@render sideNavFooterContent()}{/snippet}
{@render sideNavChildren()}
</SideNav>
</dialog>
{/if}
<style>
@@ -591,8 +580,13 @@ function formatRelativeDate(timestamp: number) {
height: calc(var(--layout-nav-height) + env(safe-area-inset-top));
}
.sideNavModalOverlay {
.sideNavModal {
top: calc(var(--layout-nav-height) + env(safe-area-inset-top));
height: calc(100dvh - var(--layout-nav-height) - env(safe-area-inset-top));
&::backdrop {
top: calc(var(--layout-nav-height) + env(safe-area-inset-top));
}
}
}
@@ -711,19 +705,24 @@ function formatRelativeDate(timestamp: number) {
}
}
.sideNavModalOverlay {
position: fixed;
inset: 0;
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 20;
}
.sideNavModal {
height: 100%;
position: fixed;
inset: var(--layout-nav-height) auto 0 0;
margin: 0;
border: none;
padding: 0;
height: calc(100dvh - var(--layout-nav-height));
max-height: none;
width: var(--layout-sidenav-width);
max-width: none;
background: transparent;
border-right: 1.5px solid var(--color-border);
&::backdrop {
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
}
.sideNavModal :global(.sideNavInModal) {

View File

@@ -13,6 +13,7 @@ interface Props {
badge?: string | null;
badgeVariant?: "default" | "warning";
onclick?: () => void;
popovertarget?: string;
"aria-expanded"?: boolean;
"aria-haspopup"?: "menu" | "dialog";
}
@@ -24,6 +25,7 @@ let {
badge,
badgeVariant,
onclick,
popovertarget,
"aria-expanded": ariaExpanded,
"aria-haspopup": ariaHaspopup,
}: Props = $props();
@@ -34,6 +36,7 @@ let {
data-testid="list-button"
class="listButton"
{onclick}
{popovertarget}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHaspopup}
>

View File

@@ -9,6 +9,7 @@ import {
User,
Users,
} from "@lucide/svelte";
import { MediaQuery } from "svelte/reactivity";
import Avatar from "#lib/components/Avatar.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import NotificationContent from "#lib/features/notifications/components/NotificationContent.svelte";
@@ -46,7 +47,18 @@ let {
}: Props = $props();
let activePanel = $state<PanelType>("closed");
let previousPanel: PanelType = "closed";
let previousPanel = $state<PanelType>("closed");
// xxx: no 600px hard coded many places, one centralized helper
// the panels are modal dialogs; leaving them open past the mobile breakpoint
// would keep the rest of the page inert while the tab bar is display: none
const isDesktop = new MediaQuery("width >= 600px");
$effect(() => {
if (isDesktop.current && activePanel !== "closed") {
closePanel();
}
});
const user = $derived(loggedInUser());

View File

@@ -54,11 +54,19 @@ function share() {
});
}
}
function showModal(dialog: HTMLDialogElement) {
dialog.showModal();
}
</script>
<div class={["panelOverlay", { noAnimation: skipAnimation }]}>
<div class={["menuOverlay", "scrollbar", { noAnimation: skipAnimation }]}>
<div data-testid="mobile-nav-panel" class="panelDialog" role="dialog">
<dialog
class={["menuOverlay", "scrollbar", { noAnimation: skipAnimation }]}
closedby="any"
onclose={onClose}
{@attach showModal}
>
<div data-testid="mobile-nav-panel" class="panelDialog">
<header class="menuHeader">
<div class="panelIconContainer">
<Menu size={18} />
@@ -132,30 +140,35 @@ function share() {
</ul>
</section>
<GhostTabBar tabCount={ghostTabCount} onTabPress={onGhostTabPress} />
</div>
</div>
</div>
</dialog>
<style>
.panelOverlay {
position: fixed;
inset: 0;
bottom: var(--layout-nav-height);
z-index: 18;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 200ms ease-out;
}
.menuOverlay {
position: fixed;
inset: 0;
z-index: 20;
inset: 0 0 var(--layout-nav-height) 0;
margin: 0;
border: none;
padding: 0;
width: 100%;
max-width: none;
height: calc(100dvh - var(--layout-nav-height));
max-height: none;
background-color: var(--color-bg);
color: inherit;
overflow-y: auto;
display: flex;
flex-direction: column;
animation: fade-in 200ms ease-out;
&[open] {
display: flex;
flex-direction: column;
animation: fade-in 200ms ease-out;
}
&::backdrop {
bottom: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
}
}
@keyframes fade-in {
@@ -167,7 +180,7 @@ function share() {
}
}
.noAnimation {
.noAnimation[open] {
animation: none;
}

View File

@@ -22,42 +22,39 @@ let {
onGhostTabPress,
skipAnimation,
}: Props = $props();
function showModal(dialog: HTMLDialogElement) {
dialog.showModal();
}
</script>
<div class={["panelOverlay", { noAnimation: skipAnimation }]}>
<div class={["panel", { noAnimation: skipAnimation }]}>
<div data-testid="mobile-nav-panel" class="panelDialog" role="dialog">
<header class="panelHeader">
<div class="panelIconContainer">{@render icon()}</div>
<h2 class="panelTitle">{title}</h2>
<button
type="button"
data-testid="panel-close-button"
class="panelCloseButton"
onclick={onClose}
>
<X size={18} />
</button>
</header>
<div class="panelContent scrollbar">
{@render children()}
</div>
<GhostTabBar tabCount={ghostTabCount} onTabPress={onGhostTabPress} />
<dialog
class={["panel", { noAnimation: skipAnimation }]}
closedby="any"
onclose={onClose}
{@attach showModal}
>
<div data-testid="mobile-nav-panel" class="panelDialog">
<header class="panelHeader">
<div class="panelIconContainer">{@render icon()}</div>
<h2 class="panelTitle">{title}</h2>
<button
type="button"
data-testid="panel-close-button"
class="panelCloseButton"
onclick={onClose}
>
<X size={18} />
</button>
</header>
<div class="panelContent scrollbar">
{@render children()}
</div>
<GhostTabBar tabCount={ghostTabCount} onTabPress={onGhostTabPress} />
</div>
</div>
</dialog>
<style>
.panelOverlay {
position: fixed;
inset: 0;
bottom: var(--layout-nav-height);
z-index: 18;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
@@ -69,17 +66,37 @@ let {
.panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 85%;
inset: auto 0 var(--layout-nav-height) 0;
margin: 0;
border: none;
padding: 0;
width: 100%;
max-width: none;
height: calc((100dvh - var(--layout-nav-height)) * 0.85);
max-height: none;
background-color: var(--color-bg);
color: inherit;
border-radius: var(--radius-box) var(--radius-box) 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding-block-end: env(safe-area-inset-bottom);
animation: slide-up 200ms ease-out;
&[open] {
display: flex;
flex-direction: column;
animation: slide-up 200ms ease-out;
}
&::backdrop {
bottom: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 200ms ease-out;
}
}
.noAnimation[open],
.noAnimation::backdrop {
animation: none;
}
@keyframes slide-up {

View File

@@ -49,6 +49,7 @@ interface NavCategoryItem {
</script>
<script lang="ts">
import { closePopoverOnScrollClip } from "@sendou/components";
import { page } from "$app/state";
import { dev } from "$app/env";
import { hasRole, loggedInUser } from "#lib/features/auth/user-state.ts";
@@ -91,14 +92,42 @@ interface NavCategoryItem {
}
let openCategory = $state<string | null>(null);
let openMenuElement = $state<HTMLElement | null>(null);
const returnTo = $derived(`${page.url.pathname}${page.url.search}`);
</script>
<svelte:window
onclick={() => {
if (openCategory !== null) openCategory = null;
}}
/>
const uid = $props.id();
closePopoverOnScrollClip({
isOpen: () => openCategory !== null,
element: () => openMenuElement,
close: () => openMenuElement?.hidePopover(),
});
function menuId(name: string) {
return `${uid}-nav-menu-${name}`;
}
function anchorName(name: string) {
return `--top-nav-anchor-${uid}-${name}`;
}
function onMenuToggle(event: Event, name: string) {
const isOpen = (event as ToggleEvent).newState === "open";
if (isOpen) {
openCategory = name;
openMenuElement = event.currentTarget as HTMLElement;
} else if (openCategory === name) {
openCategory = null;
openMenuElement = null;
}
}
function closeContainingPopover(event: Event) {
(event.currentTarget as HTMLElement)
.closest<HTMLElement>("[popover]")
?.hidePopover();
}
</script>
<nav class="container">
{#each NAV_CATEGORIES as category (category.name)}
@@ -106,39 +135,38 @@ interface NavCategoryItem {
<button
type="button"
class="menuButton"
popovertarget={menuId(category.name)}
aria-expanded={openCategory === category.name}
aria-haspopup="dialog"
onclick={(event) => {
event.stopPropagation();
openCategory =
openCategory === category.name ? null : category.name;
}}
style:anchor-name={anchorName(category.name)}
>
{categoryLabel(category.name)}
</button>
{#if openCategory === category.name}
<div class="menuPopover">
<div data-testid="menu-content" class="menuContent">
{#each visibleItems(category.items) as item (item.url)}
<a
href="/{item.url}"
class="menuItem"
onclick={() => {
openCategory = null;
}}
>
<Image
path={navIconUrl(item.icon ?? item.name)}
alt=""
size={20}
class="menuItemIcon"
/>
{pageLabel(item.name)}
</a>
{/each}
</div>
<div
id={menuId(category.name)}
popover="auto"
class="menuPopover"
style:position-anchor={anchorName(category.name)}
ontoggle={(event) => onMenuToggle(event, category.name)}
>
<div data-testid="menu-content" class="menuContent">
{#each visibleItems(category.items) as item (item.url)}
<a
href="/{item.url}"
class="menuItem"
onclick={closeContainingPopover}
>
<Image
path={navIconUrl(item.icon ?? item.name)}
alt=""
size={20}
class="menuItemIcon"
/>
{pageLabel(item.name)}
</a>
{/each}
</div>
{:else}
</div>
{#if openCategory !== category.name}
<div class="preview">
{#each visibleItems(category.items) as item (item.url)}
<a
@@ -164,35 +192,37 @@ interface NavCategoryItem {
<button
type="button"
class="menuButton"
popovertarget={menuId("dev")}
aria-expanded={openCategory === "dev"}
aria-haspopup="dialog"
onclick={(event) => {
event.stopPropagation();
openCategory = openCategory === "dev" ? null : "dev";
}}
style:anchor-name={anchorName("dev")}
>
Dev
</button>
{#if openCategory === "dev"}
<div class="menuPopover">
<div data-testid="menu-content" class="menuContent">
{#each DEV_IMPERSONATE_ITEMS as item (item.name)}
<form class="menuItemForm" method="post" action={item.action}>
<input type="hidden" name="returnTo" value={returnTo} />
<button type="submit" class="menuItem menuItemButton">
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
class="menuItemIcon"
/>
{item.name}
</button>
</form>
{/each}
</div>
<div
id={menuId("dev")}
popover="auto"
class="menuPopover"
style:position-anchor={anchorName("dev")}
ontoggle={(event) => onMenuToggle(event, "dev")}
>
<div data-testid="menu-content" class="menuContent">
{#each DEV_IMPERSONATE_ITEMS as item (item.name)}
<form class="menuItemForm" method="post" action={item.action}>
<input type="hidden" name="returnTo" value={returnTo} />
<button type="submit" class="menuItem menuItemButton">
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
class="menuItemIcon"
/>
{item.name}
</button>
</form>
{/each}
</div>
{:else}
</div>
{#if openCategory !== "dev"}
<div class="preview">
{#each DEV_IMPERSONATE_ITEMS as item (item.name)}
<form class="menuItemForm" method="post" action={item.action}>
@@ -249,10 +279,10 @@ interface NavCategoryItem {
}
.menuPopover {
position: absolute;
top: calc(100% + var(--s-1));
left: 0;
z-index: 21;
position: fixed;
position-area: block-end span-inline-end;
position-try-fallbacks: flip-inline;
margin: var(--s-1) 0 0;
max-width: min(20rem, calc(100vw - var(--s-4)));
padding: var(--s-2);
border: var(--border-style);
@@ -260,6 +290,7 @@ interface NavCategoryItem {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
background-color: var(--color-bg-high);
color: var(--color-text);
}
.menuContent {

View File

@@ -82,3 +82,12 @@ export interface NotificationRow {
href: string;
navIcon: string;
}
/** A breadcrumb trail entry a page's load sets for the header's site title. */
export interface Breadcrumb {
type: "IMAGE" | "TEXT";
imgPath?: string;
href: string;
text?: string;
identiconInput?: string;
}

View File

@@ -7,7 +7,7 @@ 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;
return page.data.user;
}
/** Whether the logged in user has the given global role. Always `false` when logged out. */

View File

@@ -24,9 +24,7 @@ const fields = $derived({ season, identifier: entry.identifier });
size="miniscule"
variant="outlined"
aria-label="Actions"
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
{...triggerProps}
>
{#snippet icon()}<MoreHorizontal />{/snippet}
</Button>

View File

@@ -5,6 +5,8 @@ import {
decompressFromBase64,
} from "#lib/utils/compression.ts";
// xxx: check location, do we even want "modules" or just packages
/**
* URL search param definitions: one `define()` per route or feature drives
* remote query args, client state, and href building. Ported from the React

View File

@@ -0,0 +1,27 @@
import { untrack } from "svelte";
/**
* Returns an object with the mounted state of the component
* that invokes this function.
*
* @see {@link https://runed.dev/docs/utilities/is-mounted}
*/
export class IsMounted {
#isMounted: boolean = $state(false);
constructor() {
$effect(() => {
untrack(() => {
this.#isMounted = true;
});
return () => {
this.#isMounted = false;
};
});
}
get current(): boolean {
return this.#isMounted;
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { type MenuTriggerProps, setMenuContext } from "./menu-context.ts";
import { closePopoverOnScrollClip } from "./popover-scroll-close.svelte.ts";
interface Props {
trigger: Snippet<[MenuTriggerProps]>;
@@ -16,34 +17,28 @@ let open = $state(false);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
function setOpen(next: boolean) {
if (open === next) return;
const uid = $props.id();
const popoverId = `${uid}-menu`;
const anchorName = `--menu-anchor-${uid}`;
closePopoverOnScrollClip({
isOpen: () => open,
element: () => popoverElement,
close: () => popoverElement?.hidePopover(),
});
function onPopoverToggle(event: Event) {
const next = (event as ToggleEvent).newState === "open";
if (next === open) 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>(
@@ -74,7 +69,7 @@ function focusItem(target: "first" | "last" | "next" | "previous") {
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
popoverElement?.hidePopover();
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
return;
}
@@ -101,55 +96,71 @@ function onPopoverKeydown(event: KeyboardEvent) {
setMenuContext({
close() {
setOpen(false);
popoverElement?.hidePopover();
},
});
const triggerProps: MenuTriggerProps = {
popovertarget: popoverId,
get "aria-expanded"() {
return open;
},
"aria-haspopup": "menu",
onclick: () => setOpen(!open),
};
</script>
<span class="triggerContainer" bind:this={triggerContainer}>
<!-- xxx: fix always first item selected -->
<span
class="triggerContainer"
bind:this={triggerContainer}
style:--menu-anchor={anchorName}
>
{@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 }]}
id={popoverId}
popover="auto"
class={["popover", "scrollbar", popoverClass, { scrolling, opensLeft }]}
style:position-anchor={anchorName}
ontoggle={onPopoverToggle}
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;
> :global(*) {
anchor-name: var(--menu-anchor);
}
}
.popover {
position: fixed;
margin: 0;
position-area: block-end span-inline-start;
position-try-fallbacks: flip-block;
margin: var(--s-2) 0;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
border: var(--border-style);
width: max-content;
max-width: calc(100vw - var(--s-4));
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-2);
color: var(--color-text);
}
.opensLeft {
position-area: block-end span-inline-end;
}
.scrolling {
max-height: 300px !important;
overflow-y: auto;
@@ -164,10 +175,4 @@ const triggerProps: MenuTriggerProps = {
outline: none;
}
}
.backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { ElementVisibility } from "./element-visibility.ts";
import { closePopoverOnScrollClip } from "./popover-scroll-close.svelte.ts";
export interface PopoverTriggerProps {
readonly popovertarget: string;
@@ -17,8 +17,6 @@ interface Props {
let { trigger, popoverClass, onOpenChange, isOpen, children }: Props = $props();
const VISIBLE_RATIO_THRESHOLD = 0.98;
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = isOpen !== undefined;
let uncontrolledOpen = $state(false);
@@ -39,33 +37,10 @@ $effect(() => {
}
});
const visibility = new ElementVisibility(() => {
if (!open || !popoverElement) return null;
return {
element: popoverElement,
marginTop: popoverBoundaryTop(popoverElement),
threshold: VISIBLE_RATIO_THRESHOLD,
};
});
// a popover too tall to ever fit fully (or one measured before it is shown)
// must not close itself; only a fully visible popover that scroll clips does
let wasFullyVisible = false;
$effect(() => {
if (!open) {
wasFullyVisible = false;
return;
}
const ratio = visibility.ratio;
if (ratio === null) return;
if (ratio >= VISIBLE_RATIO_THRESHOLD) {
wasFullyVisible = true;
} else if (wasFullyVisible) {
setOpen(false);
}
closePopoverOnScrollClip({
isOpen: () => open,
element: () => popoverElement,
close: () => setOpen(false),
});
function setOpen(next: boolean) {
@@ -91,13 +66,6 @@ const triggerProps: PopoverTriggerProps = {
"aria-haspopup": "dialog",
};
function popoverBoundaryTop(element: Element) {
return (
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--popover-boundary-top"),
) || 0
);
}
</script>
<span class="triggerContainer" style:--popover-anchor={anchorName}>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { closePopoverOnScrollClip } from "./popover-scroll-close.svelte.ts";
import { setSelectContext } from "./select-context.ts";
interface Props {
@@ -70,6 +71,12 @@ let triggerElement = $state<HTMLButtonElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
let searchInputElement = $state<HTMLInputElement | null>(null);
closePopoverOnScrollClip({
isOpen: () => open,
element: () => popoverElement,
close: () => setOpen(false),
});
const selectedText = $derived(
currentKey !== null ? items.get(currentKey)?.textValue : undefined,
);
@@ -106,46 +113,11 @@ function commitSelection(key: string | number | null) {
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`;
}
}
@@ -186,12 +158,7 @@ function scrollFocusedIntoView() {
}
function onTriggerKeydown(event: KeyboardEvent) {
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " "
) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
}
@@ -233,10 +200,22 @@ function onPopoverKeydown(event: KeyboardEvent) {
}
function onPopoverToggle(event: Event) {
const toggleEvent = event as ToggleEvent;
if (toggleEvent.newState === "closed" && open) {
open = false;
onOpenChange?.(false);
const next = (event as ToggleEvent).newState === "open";
if (next === open) return;
open = next;
onOpenChange?.(next);
if (next) {
focusedKey = currentKey ?? orderedKeys(false)[0] ?? null;
requestAnimationFrame(() => {
if (search) {
searchInputElement?.focus();
} else {
popoverElement?.focus();
}
scrollFocusedIntoView();
});
} else {
searchValue = "";
focusedKey = null;
}
@@ -247,13 +226,10 @@ const hasVisibleItems = $derived(items.size > 0);
const uid = $props.id();
const labelId = $derived(label ? `${uid}-select-label` : undefined);
const valueId = `${uid}-select-value`;
const popoverId = `${uid}-select-popover`;
const anchorName = `--select-anchor-${uid}`;
</script>
<svelte:window
onresize={() => open && positionPopover()}
onscroll={() => open && positionPopover()}
/>
<div class="select" data-testid={testId}>
{#if label}
<span class="label" id={labelId}>{label}</span>
@@ -268,7 +244,8 @@ const valueId = `${uid}-select-value`;
aria-label={ariaLabel}
aria-labelledby={labelId && !ariaLabel ? `${valueId} ${labelId}` : undefined}
data-required={isRequired || undefined}
onclick={() => setOpen(!open)}
popovertarget={popoverId}
style:anchor-name={anchorName}
onkeydown={onTriggerKeydown}
>
<span id={valueId} class="selectValue" data-placeholder={selectedText === undefined ? "true" : undefined}>
@@ -321,8 +298,10 @@ const valueId = `${uid}-select-value`;
<!-- svelte-ignore a11y_no_static_element_interactions -- keydown steers the listbox inside -->
<div
bind:this={popoverElement}
popover="manual"
id={popoverId}
popover="auto"
class={["popover", popoverClass]}
style:position-anchor={anchorName}
ontoggle={onPopoverToggle}
onkeydown={onPopoverKeydown}
tabindex="-1"
@@ -386,14 +365,6 @@ const valueId = `${uid}-select-value`;
</div>
</div>
{#if open}
<div
class="backdrop"
onclick={() => setOpen(false)}
aria-hidden="true"
></div>
{/if}
<style>
.select {
width: 100%;
@@ -463,7 +434,11 @@ const valueId = `${uid}-select-value`;
.popover {
position: fixed;
margin: 0;
position-area: block-end;
position-try-fallbacks: flip-block;
width: anchor-size(width);
max-height: 300px;
margin: var(--s-1) 0;
padding: var(--s-1);
border: var(--border-style);
border-radius: var(--radius-box);
@@ -563,10 +538,4 @@ const valueId = `${uid}-select-value`;
max-width: 14px;
margin-inline-end: var(--s-1);
}
.backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
</style>

View File

@@ -5,6 +5,7 @@ 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 { closePopoverOnScrollClip } from "./popover-scroll-close.svelte.ts";
export { default as Select } from "./Select.svelte";
export { default as SelectItem } from "./SelectItem.svelte";
export { default as SelectItemSection } from "./SelectItemSection.svelte";

View File

@@ -1,9 +1,9 @@
import { getContext, setContext } from "svelte";
export interface MenuTriggerProps {
readonly popovertarget: string;
readonly "aria-expanded": boolean;
readonly "aria-haspopup": "menu";
onclick: () => void;
}
export interface MenuContext {

View File

@@ -0,0 +1,55 @@
import { ElementVisibility } from "./element-visibility.ts";
const VISIBLE_RATIO_THRESHOLD = 0.98;
export interface PopoverScrollCloseOptions {
isOpen: () => boolean;
element: () => HTMLElement | null;
close: () => void;
}
/**
* Closes an open popover once scrolling clips it against the sticky header
* (`--popover-boundary-top`) or the bottom of the viewport.
*
* A popover too tall to ever fit fully (or one measured before it is shown)
* must not close itself; only a fully visible popover that scroll clips does.
* Must be called during component initialisation.
*/
export function closePopoverOnScrollClip(options: PopoverScrollCloseOptions) {
const visibility = new ElementVisibility(() => {
const element = options.element();
if (!options.isOpen() || !element) return null;
return {
element,
marginTop: popoverBoundaryTop(element),
threshold: VISIBLE_RATIO_THRESHOLD,
};
});
let wasFullyVisible = false;
$effect(() => {
if (!options.isOpen()) {
wasFullyVisible = false;
return;
}
const ratio = visibility.ratio;
if (ratio === null) return;
if (ratio >= VISIBLE_RATIO_THRESHOLD) {
wasFullyVisible = true;
} else if (wasFullyVisible) {
options.close();
}
});
}
function popoverBoundaryTop(element: Element) {
return (
Number.parseFloat(
getComputedStyle(element).getPropertyValue("--popover-boundary-top"),
) || 0
);
}