Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
13 changes: 12 additions & 1 deletion src/lib/ui/core/Popover/Popover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import { cn } from '$ui/utils/index.js'
import { flyAndScaleOutTransition } from '$ui/utils/transitions.js'

import { useCloseOnOutsideClick } from './closeOutside.svelte.js'

type TProps = {
class?: string
children?: PopoverTriggerProps['child']
Expand All @@ -30,6 +32,7 @@
openDelay?: number
closeDelay?: number
openOnHover?: boolean
closeOnOutsideClick?: boolean
}

let {
Expand All @@ -39,6 +42,7 @@
noStyles = false,
matchTriggerWidth = false,
openOnHover = false,
closeOnOutsideClick = false,
isOpened = $bindable(false),

openDelay = 0,
Expand All @@ -54,17 +58,24 @@
}: TProps = $props()

const preventFocus = (e: Event) => e.preventDefault()

const { triggerEl, contentEl } = useCloseOnOutsideClick({
enabled: closeOnOutsideClick,
getIsOpened: () => isOpened,
close: () => (isOpened = false),
})
</script>

<Popover.Root {...rootProps} bind:open={isOpened}>
<Popover.Trigger child={children} {openDelay} {closeDelay} {openOnHover} />
<Popover.Trigger bind:ref={triggerEl.$} child={children} {openDelay} {closeDelay} {openOnHover} />

<Popover.Portal disabled={!portalTo} to={portalTo}>
<Popover.Content
sideOffset={8}
onCloseAutoFocus={preventFocus}
onOpenAutoFocus={preventFocus}
{...contentProps}
bind:ref={contentEl.$}
{align}
{side}
forceMount
Expand Down
51 changes: 51 additions & 0 deletions src/lib/ui/core/Popover/closeOutside.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { on } from 'svelte/events'

type TCloseOutsideProps = {
getIsOpened: () => boolean
close: () => void
enabled?: boolean
}

export const useCloseOnOutsideClick = ({
getIsOpened,
close,
enabled = true,
}: TCloseOutsideProps) => {
let triggerEl = $state<HTMLElement | null>(null)
let contentEl = $state<HTMLElement | null>(null)

const isOpened = $derived(getIsOpened())

if (enabled) {
$effect(() => {
if (!isOpened) return

return on(window, 'pointerdown', (e) => {
if (contentEl && e.composedPath().includes(contentEl)) return
if (triggerEl && e.composedPath().includes(triggerEl)) return

close()
})
})
}

return {
triggerEl: {
get $() {
return triggerEl
},
set $(el: HTMLElement | null) {
triggerEl = el
},
},

contentEl: {
get $() {
return contentEl
},
set $(el: HTMLElement | null) {
contentEl = el
},
},
}
}
22 changes: 8 additions & 14 deletions src/lib/ui/core/Tooltip/Tooltip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { useMelt } from '$ui/utils/melt-ui.js'
import { flyAndScaleOutTransition } from '$ui/utils/transitions.js'

import { useCloseOnOutsideClick } from '../Popover/closeOutside.svelte.js'

type FloatingConfig = NonNullable<CreateTooltipProps['positioning']>

type TooltipType = 'plain' | 'arrow'
Expand Down Expand Up @@ -63,19 +65,11 @@

useMelt(triggerRef, trigger)

let contentEl = $state<HTMLElement>()

if (closeOnOutsideClick) {
$effect(() => {
if (!$open) return

return on(window, 'pointerdown', (e) => {
if (contentEl && e.composedPath().includes(contentEl)) return

open.set(false)
})
})
}
const { contentEl } = useCloseOnOutsideClick({
enabled: closeOnOutsideClick,
getIsOpened: () => $open,
close: () => open.set(false),
})

$effect(() => {
open.set(isOpened)
Expand All @@ -87,7 +81,7 @@
{#if $open}
<div
{...$content}
bind:this={contentEl}
bind:this={contentEl.$}
use:content
out:flyAndScaleOutTransition
class={cn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,19 @@
{/snippet}

{#snippet content()}
<section class="max-w-64">
This popover opens on hover and its content is aligned with the start of the trigger.
</section>
<section class="max-w-64">This popover can stay open on another button click</section>
{/snippet}
</Popover>

<Button variant="fill">Some Button</Button>

<Popover openOnHover side="bottom" align="end">
<Popover openOnHover side="bottom" align="end" closeOnOutsideClick>
{#snippet children({ props })}
<Button variant="border" {...props}>Hover Popover</Button>
{/snippet}

{#snippet content()}
<section class="max-w-64">
This popover also opens on hover, with content aligned to the end of the trigger.
</section>
<section class="max-w-64">This popover should close on click outside of its content</section>
{/snippet}
</Popover>
</main>
6 changes: 3 additions & 3 deletions src/stories/Design System - Core UI/Popover/index.stories.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/svelte'

import component from './index.svelte'
import PopoverWithButtonComponent from './PopoverWithButton.svelte'
import HoverWithButtonComponent from './HoverWithButton.svelte'
import PositionedPopover from './PositionedPopover.svelte'

const meta = {
Expand All @@ -22,8 +22,8 @@ export const PopoverWithPositionConfig: StoryObj<typeof PositionedPopover> = {
}),
}

export const PopoverWithButton: StoryObj<typeof PopoverWithButtonComponent> = {
export const HoverWithButton: StoryObj<typeof HoverWithButtonComponent> = {
render: () => ({
Component: PopoverWithButtonComponent,
Component: HoverWithButtonComponent,
}),
}