mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
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:
12
apps/web/src/app.d.ts
vendored
12
apps/web/src/app.d.ts
vendored
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
27
apps/web/src/lib/utils/is-mounted.svelte.ts
Normal file
27
apps/web/src/lib/utils/is-mounted.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
55
packages/components/src/popover-scroll-close.svelte.ts
Normal file
55
packages/components/src/popover-scroll-close.svelte.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user