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 + ); +}