Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/lib/holocene/menu/menu-button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
count = 0,
disabled = false,
hasIndicator = false,
id = null,
label = null,
id = undefined,
label = undefined,
variant = 'secondary',
size = 'md',
onclick,
Expand Down Expand Up @@ -78,7 +78,7 @@

const focusFirstMenuItem = () => {
const focusable: (HTMLInputElement | HTMLLIElement)[] = Array.from(
$menuElement.querySelectorAll(MENU_ITEM_SELECTORS),
$menuElement?.querySelectorAll(MENU_ITEM_SELECTORS) ?? [],
);

if (focusable && focusable[0]) {
Expand All @@ -100,6 +100,7 @@
{variant}
class={merge(className)}
{size}
active={$open}
disableTracking={true}
{...rest}
>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/holocene/menu/menu-container.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
export type MenuContext = {
open: Writable<boolean>;
keepOpen: Writable<boolean>;
menuElement: Writable<HTMLUListElement>;
menuElement: Writable<HTMLUListElement | null>;
};
</script>

Expand Down Expand Up @@ -34,7 +34,7 @@
}: Props = $props();

const keepOpen = writable(false);
const menuElement: Writable<HTMLUListElement> = writable(null);
const menuElement: Writable<HTMLUListElement | null> = writable(null);

const closeMenu = () => {
if ($open) {
Expand Down
110 changes: 83 additions & 27 deletions src/lib/holocene/menu/menu.svelte
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
<script lang="ts" module>
import { cva } from 'class-variance-authority';

const sharedMenuStyles = [
'surface-primary',
'min-w-fit',
'list-none',
'overflow-auto',
'border',
'border-subtle',
'text-primary',
'shadow',
'w-full',
];

const menuStyles = cva(
[
'surface-primary',
...sharedMenuStyles,
'absolute',
'z-20',
'mt-1',
'min-w-fit',
'list-none',
'overflow-auto',
'border',
'border-subtle',
'text-primary',
'shadow',
'w-full',
'transition-all',
'duration-100',
'ease-out',
Expand Down Expand Up @@ -42,6 +46,8 @@
import { getContext } from 'svelte';
import { type ClassNameValue, twMerge as merge } from 'tailwind-merge';

import Portal from '$lib/holocene/portal/portal.svelte';
import type { PortalPosition } from '$lib/holocene/portal/types';
import { getFocusableElements } from '$lib/utilities/focus-trap';

import { MENU_CONTEXT, type MenuContext } from './menu-container.svelte';
Expand All @@ -51,9 +57,13 @@
id: string;
keepOpen?: boolean;
position?: 'left' | 'right' | 'top-left' | 'top-right';
menuElement?: HTMLUListElement;
menuElement?: HTMLUListElement | null;
maxHeight?: string;
class?: ClassNameValue;
usePortal?: boolean;
scrollContainer?: string;
flipOnCollision?: boolean;
hideWhenAnchorHidden?: boolean;
}

let {
Expand All @@ -63,11 +73,16 @@
position = 'left',
menuElement = $bindable(null),
maxHeight = 'max-h-[20rem]',
usePortal = false,
scrollContainer,
flipOnCollision = undefined,
hideWhenAnchorHidden = undefined,
children,
...rest
}: Props = $props();

let height = $state(0);
let anchorElement = $state<HTMLElement | null>(null);

const {
keepOpen: keepOpenCtx,
Expand All @@ -83,6 +98,21 @@
$menuElementCtx = menuElement;
});

$effect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named function please

if (usePortal && id) {
anchorElement = document.querySelector(
`[aria-controls="${id}"]`,
) as HTMLElement | null;
}
});

const portalPosition: PortalPosition | undefined = $derived.by(() => {
if (!usePortal) return undefined;
if (position.includes('top')) return position;

return position === 'left' ? 'bottom-left' : 'bottom-right';
});

const menuItems = $derived(
menuElement ? getFocusableElements(menuElement) : [],
);
Expand All @@ -101,20 +131,46 @@
);
</script>

<ul
role="menu"
class={merge(styles, maxHeight, className)}
aria-labelledby={id}
tabindex={-1}
style={position === 'top-right' || position === 'top-left'
? `top: -${height + 16}px;`
: ''}
{id}
bind:this={menuElement}
bind:clientHeight={height}
onfocusout={handleFocusOut}
onclick={handleClick}
{...rest}
>
{@render children?.()}
</ul>
{#if usePortal && anchorElement}
<Portal
anchor={anchorElement}
open={$open}
position={portalPosition}
{flipOnCollision}
{hideWhenAnchorHidden}
{scrollContainer}
>
<ul
role="menu"
class={merge(sharedMenuStyles, maxHeight, className)}
aria-labelledby={id}
tabindex={-1}
{id}
bind:this={menuElement}
bind:clientHeight={height}
onfocusout={handleFocusOut}
onclick={handleClick}
{...rest}
>
{@render children?.()}
</ul>
</Portal>
{:else}
<ul
role="menu"
class={merge(styles, maxHeight, className)}
aria-labelledby={id}
tabindex={-1}
style={position === 'top-right' || position === 'top-left'
? `top: -${height + 16}px;`
: ''}
{id}
bind:this={menuElement}
bind:clientHeight={height}
onfocusout={handleFocusOut}
onclick={handleClick}
{...rest}
>
{@render children?.()}
</ul>
{/if}
24 changes: 24 additions & 0 deletions src/lib/holocene/portal/portal-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function portal(
node: HTMLElement,
target: HTMLElement | string = document.body,
) {
const targetEl =
typeof target === 'string' ? document.querySelector(target) : target;

if (!targetEl) {
console.warn('Portal target not found');
return {
destroy() {},
};
}

targetEl.appendChild(node);

return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
},
};
}
Loading
Loading