diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts
index f8a52a9b0..798f305d4 100644
--- a/apps/web/src/app.d.ts
+++ b/apps/web/src/app.d.ts
@@ -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 {}
}
diff --git a/apps/web/src/lib/components/HydrationTestIndicator.svelte b/apps/web/src/lib/components/HydrationTestIndicator.svelte
index b591ac461..304f55f33 100644
--- a/apps/web/src/lib/components/HydrationTestIndicator.svelte
+++ b/apps/web/src/lib/components/HydrationTestIndicator.svelte
@@ -29,12 +29,12 @@ function patchFetchOnce() {
-{#if hydrated}
+{#if mounted.current}
{
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()}
{/snippet}
{#if !compact}{`${m.common_actions_addNew()}…`}{/if}
diff --git a/apps/web/src/lib/components/layout/FriendMenu.svelte b/apps/web/src/lib/components/layout/FriendMenu.svelte
index df47cbe3b..09a71c5db 100644
--- a/apps/web/src/lib/components/layout/FriendMenu.svelte
+++ b/apps/web/src/lib/components/layout/FriendMenu.svelte
@@ -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}
diff --git a/apps/web/src/lib/components/layout/GhostTabBar.svelte b/apps/web/src/lib/components/layout/GhostTabBar.svelte
index 0d670e831..a8182e259 100644
--- a/apps/web/src/lib/components/layout/GhostTabBar.svelte
+++ b/apps/web/src/lib/components/layout/GhostTabBar.svelte
@@ -23,7 +23,7 @@ let { tabCount, onTabPress }: Props = $props();
diff --git a/packages/components/src/Popover.svelte b/packages/components/src/Popover.svelte
index fa31e9011..892eaa747 100644
--- a/packages/components/src/Popover.svelte
+++ b/packages/components/src/Popover.svelte
@@ -1,6 +1,6 @@
diff --git a/packages/components/src/Select.svelte b/packages/components/src/Select.svelte
index 826203b21..0c5261ba1 100644
--- a/packages/components/src/Select.svelte
+++ b/packages/components/src/Select.svelte
@@ -1,6 +1,7 @@
- open && positionPopover()}
- onscroll={() => open && positionPopover()}
-/>
-
{#if label}
{label}
@@ -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}
>
@@ -321,8 +298,10 @@ const valueId = `${uid}-select-value`;
-{#if open}
- setOpen(false)}
- aria-hidden="true"
- >
-{/if}
-
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index 81e504fc5..59b36b381 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -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";
diff --git a/packages/components/src/menu-context.ts b/packages/components/src/menu-context.ts
index 64a54f7cd..331bd22e5 100644
--- a/packages/components/src/menu-context.ts
+++ b/packages/components/src/menu-context.ts
@@ -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 {
diff --git a/packages/components/src/popover-scroll-close.svelte.ts b/packages/components/src/popover-scroll-close.svelte.ts
new file mode 100644
index 000000000..08d5bc2e0
--- /dev/null
+++ b/packages/components/src/popover-scroll-close.svelte.ts
@@ -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
+ );
+}