diff --git a/.changeset/lucky-bats-jam.md b/.changeset/lucky-bats-jam.md new file mode 100644 index 0000000000..e7aa3f96f5 --- /dev/null +++ b/.changeset/lucky-bats-jam.md @@ -0,0 +1,5 @@ +--- +"@melt-ui/svelte": minor +--- + +Popover: add optional `overlay` element builder for simplified modal behavior (part of #1018) diff --git a/src/docs/content/builders/popover.md b/src/docs/content/builders/popover.md index c41079c17f..3ca8cc667c 100644 --- a/src/docs/content/builders/popover.md +++ b/src/docs/content/builders/popover.md @@ -28,7 +28,9 @@ above to create your popover. To specify that the popover should be open by default, set the `defaultOpen` prop to `true`. ```ts {2} -const { trigger, content, open, arrow, close } = createPopover({ +const { + /** ... */ +} = createPopover({ defaultOpen: true }) ``` @@ -67,6 +69,36 @@ const popover = createPopover({ }) ``` +### Modal Behavior + +To give the popover modal behavior, where body scrolling is disabled and an overlay is rendered +behind the popover to prevent interaction with the rest of the page, use the `preventScroll` prop +and `overlay` builder element. + +```svelte + + + +
+
+
+
+ +
+ +
+``` + +The [Modal Popover](#modal-popover) example below demonstrates this behavior in action. + ## Example Components ### Nested Popovers @@ -75,6 +107,12 @@ const popover = createPopover({ +### Modal Popover + + + + + ## API Reference diff --git a/src/docs/data/builders/popover.ts b/src/docs/data/builders/popover.ts index e8f0ff4380..9d978a7424 100644 --- a/src/docs/data/builders/popover.ts +++ b/src/docs/data/builders/popover.ts @@ -41,6 +41,10 @@ const builder = builderSchema(BUILDER_NAME, { name: 'content', description: 'The builder store used to create the popover content.', }, + { + name: 'overlay', + description: 'The builder store used to create the popover overlay.', + }, { name: 'close', description: 'The builder store used to create the popover close button.', @@ -78,6 +82,10 @@ const trigger = elementSchema('trigger', { const content = elementSchema('content', { description: 'The popover content.', dataAttributes: [ + { + name: 'data-state', + value: ATTRS.OPEN_CLOSED, + }, { name: 'data-melt-popover-content', value: ATTRS.MELT('content'), @@ -85,6 +93,21 @@ const content = elementSchema('content', { ], }); +const overlay = elementSchema('overlay', { + description: + 'The optional popover overlay element, which can be used to give the popover modal-like behavior where the user cannot interact with the rest of the page while the popover is open.', + dataAttributes: [ + { + name: 'data-state', + value: ATTRS.OPEN_CLOSED, + }, + { + name: 'data-melt-popover-overlay', + value: ATTRS.MELT('overlay'), + }, + ], +}); + const arrow = elementSchema('arrow', { title: 'arrow', description: 'The optional arrow element.', @@ -137,7 +160,7 @@ const keyboard: KeyboardSchema = [ }, ]; -const schemas = [builder, trigger, content, close, arrow]; +const schemas = [builder, trigger, content, overlay, close, arrow]; const features = [ 'Full keyboard navigation', diff --git a/src/docs/previews/popover/modal/tailwind/index.svelte b/src/docs/previews/popover/modal/tailwind/index.svelte new file mode 100644 index 0000000000..a2c1bd8dfb --- /dev/null +++ b/src/docs/previews/popover/modal/tailwind/index.svelte @@ -0,0 +1,95 @@ + + + + +{#if $open} +
+
+
+
+

Dimensions

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+{/if} + + diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts index 7c1553a193..e26a47d8f3 100644 --- a/src/lib/builders/popover/create.ts +++ b/src/lib/builders/popover/create.ts @@ -21,7 +21,12 @@ import { portalAttr, } from '$lib/internal/helpers/index.js'; -import { usePopper, type InteractOutsideEvent } from '$lib/internal/actions/index.js'; +import { + useEscapeKeydown, + usePopper, + usePortal, + type InteractOutsideEvent, +} from '$lib/internal/actions/index.js'; import { safeOnMount } from '$lib/internal/helpers/lifecycle.js'; import type { Defaults, MeltActionReturn } from '$lib/internal/types.js'; import { tick } from 'svelte'; @@ -48,7 +53,7 @@ const defaults = { onOutsideClick: undefined, } satisfies Defaults; -type PopoverParts = 'trigger' | 'content' | 'arrow' | 'close'; +type PopoverParts = 'trigger' | 'content' | 'arrow' | 'close' | 'overlay'; const { name } = createElHelpers('popover'); export const popoverIdParts = ['trigger', 'content'] as const; @@ -196,13 +201,13 @@ export function createPopover(args?: CreatePopoverProps) { } const trigger = makeElement(name('trigger'), { - stores: [open, ids.content, ids.trigger], - returned: ([$open, $contentId, $triggerId]) => { + stores: [isVisible, ids.content, ids.trigger], + returned: ([$isVisible, $contentId, $triggerId]) => { return { role: 'button', 'aria-haspopup': 'dialog', - 'aria-expanded': $open, - 'data-state': $open ? 'open' : 'closed', + 'aria-expanded': $isVisible ? 'true' : 'false', + 'data-state': stateAttr($isVisible), 'aria-controls': $contentId, id: $triggerId, } as const; @@ -225,6 +230,54 @@ export function createPopover(args?: CreatePopoverProps) { }, }); + const overlay = makeElement(name('overlay'), { + stores: [isVisible], + returned: ([$isVisible]) => { + return { + hidden: $isVisible ? undefined : true, + tabindex: -1, + style: styleToString({ + display: $isVisible ? undefined : 'none', + }), + 'aria-hidden': 'true', + 'data-state': stateAttr($isVisible), + } as const; + }, + action: (node: HTMLElement) => { + let unsubEscapeKeydown = noop; + + if (closeOnEscape.get()) { + const escapeKeydown = useEscapeKeydown(node, { + handler: () => { + handleClose(); + }, + }); + if (escapeKeydown && escapeKeydown.destroy) { + unsubEscapeKeydown = escapeKeydown.destroy; + } + } + + const unsubPortal = effect([portal], ([$portal]) => { + if ($portal === null) return noop; + const portalDestination = getPortalDestination(node, $portal); + if (portalDestination === null) return noop; + const portalAction = usePortal(node, portalDestination); + if (portalAction && portalAction.destroy) { + return portalAction.destroy; + } else { + return noop; + } + }); + + return { + destroy() { + unsubEscapeKeydown(); + unsubPortal(); + }, + }; + }, + }); + const arrow = makeElement(name('arrow'), { stores: arrowSize, returned: ($arrowSize) => ({ @@ -296,6 +349,7 @@ export function createPopover(args?: CreatePopoverProps) { content, arrow, close, + overlay, }, states: { open, @@ -303,3 +357,7 @@ export function createPopover(args?: CreatePopoverProps) { options, }; } + +function stateAttr(open: boolean) { + return open ? 'open' : 'closed'; +}