Skip to content

Commit

Permalink
feat: Popover overlay (melt-ui#1025)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored and lolcabanon committed Apr 20, 2024
1 parent b9e8486 commit ebd638a
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/lucky-bats-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@melt-ui/svelte": minor
---

Popover: add optional `overlay` element builder for simplified modal behavior (part of #1018)
40 changes: 39 additions & 1 deletion src/docs/content/builders/popover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
```
Expand Down Expand Up @@ -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
<script lang="ts">
import { createPopover, melt } from '@melt-ui/svelte'
const {
elements: { content, trigger, overlay, close, arrow }
} = createPopover({
preventScroll: true
})
</script>
<button type="button" use:melt={$trigger}> Open </button>
<div use:melt={$overlay} />
<div use:melt={$content}>
<div use:melt={$arrow} />
<div>
<!-- ... -->
</div>
<button use:melt={$close}> Close </button>
</div>
```

The [Modal Popover](#modal-popover) example below demonstrates this behavior in action.

## Example Components

### Nested Popovers
Expand All @@ -75,6 +107,12 @@ const popover = createPopover({
<svelte:component this={previews.nested} />
</Preview>

### Modal Popover

<Preview code={snippets.modal}>
<svelte:component this={previews.modal} />
</Preview>

## API Reference

<APIReference {schemas} />
Expand Down
25 changes: 24 additions & 1 deletion src/docs/data/builders/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -78,13 +82,32 @@ 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'),
},
],
});

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.',
Expand Down Expand Up @@ -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',
Expand Down
95 changes: 95 additions & 0 deletions src/docs/previews/popover/modal/tailwind/index.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script lang="ts">
import { createPopover, melt } from '$lib/index.js';
import { fade } from 'svelte/transition';
import { Settings2, X } from '$icons/index.js';
const {
elements: { trigger, content, arrow, close, overlay },
states: { open },
} = createPopover({
forceVisible: true,
preventScroll: true,
});
</script>

<button
type="button"
class="trigger"
use:melt={$trigger}
aria-label="Update dimensions"
>
<Settings2 class="size-4" />
<span class="sr-only">Open Popover</span>
</button>

{#if $open}
<div use:melt={$overlay} class="fixed inset-0 z-40" />
<div
use:melt={$content}
transition:fade={{ duration: 100 }}
class="force-dark content"
>
<div use:melt={$arrow} />
<div class="flex flex-col gap-2.5">
<p>Dimensions</p>
<fieldset>
<label for="width">Width</label>
<input type="number" id="width" class="input" placeholder="Width" />
</fieldset>
<fieldset>
<label for="height">Height</label>
<input type="number" id="height" class="input" placeholder="Height" />
</fieldset>
<fieldset>
<label for="depth">Depth</label>
<input type="number" id="depth" class="input" placeholder="Depth" />
</fieldset>
<fieldset>
<label for="weight">Weight</label>
<input type="number" id="weight" class="input" placeholder="Weight" />
</fieldset>
</div>
<button class="close" use:melt={$close}>
<X class="size-4" />
</button>
</div>
{/if}

<style lang="postcss">
fieldset {
@apply flex items-center gap-5;
}
label {
@apply w-[75px] text-sm text-neutral-700;
}
p {
@apply mb-2 font-medium text-neutral-900;
}
.input {
@apply flex h-8 w-full rounded-md border border-magnum-800 bg-transparent px-2.5 text-sm;
@apply ring-offset-magnum-300 focus-visible:ring;
@apply focus-visible:ring-magnum-400 focus-visible:ring-offset-1;
@apply flex-1 items-center justify-center;
@apply px-2.5 text-sm leading-none text-magnum-700;
}
.trigger {
@apply inline-flex h-9 w-9 items-center justify-center rounded-full bg-white p-0;
@apply text-sm font-medium text-magnum-900 transition-colors hover:bg-white/90;
@apply focus-visible:ring focus-visible:ring-magnum-400 focus-visible:ring-offset-2;
}
.close {
@apply absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full;
@apply text-magnum-900 transition-colors hover:bg-magnum-500/10;
@apply focus-visible:ring focus-visible:ring-magnum-400 focus-visible:ring-offset-2;
@apply bg-white p-0 text-sm font-medium;
}
.content {
@apply z-40 w-60 rounded-[4px] bg-white p-5 shadow-sm;
}
</style>
70 changes: 64 additions & 6 deletions src/lib/builders/popover/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -48,7 +53,7 @@ const defaults = {
onOutsideClick: undefined,
} satisfies Defaults<CreatePopoverProps>;

type PopoverParts = 'trigger' | 'content' | 'arrow' | 'close';
type PopoverParts = 'trigger' | 'content' | 'arrow' | 'close' | 'overlay';
const { name } = createElHelpers<PopoverParts>('popover');

export const popoverIdParts = ['trigger', 'content'] as const;
Expand Down Expand Up @@ -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;
Expand All @@ -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) => ({
Expand Down Expand Up @@ -296,10 +349,15 @@ export function createPopover(args?: CreatePopoverProps) {
content,
arrow,
close,
overlay,
},
states: {
open,
},
options,
};
}

function stateAttr(open: boolean) {
return open ? 'open' : 'closed';
}

0 comments on commit ebd638a

Please sign in to comment.