@melt-ui/svelte
Version:

228 lines (227 loc) • 8.72 kB
JavaScript
import { addMeltEventListener, makeElement, createElHelpers, derivedVisible, effect, executeCallbacks, getPortalDestination, handleFocus, isBrowser, isElement, kbd, noop, omit, overridable, removeScroll, styleToString, toWritableStores, portalAttr, generateIds, withGet, } from '../../internal/helpers/index.js';
import { usePopper, usePortal } from '../../internal/actions/index.js';
import { writable } from 'svelte/store';
import { tick } from 'svelte';
const defaults = {
positioning: {
placement: 'bottom',
},
arrowSize: 8,
defaultOpen: false,
disableFocusTrap: false,
escapeBehavior: 'close',
preventScroll: false,
onOpenChange: undefined,
closeOnOutsideClick: true,
portal: 'body',
forceVisible: false,
openFocus: undefined,
closeFocus: undefined,
onOutsideClick: undefined,
preventTextSelectionOverflow: true,
};
const { name } = createElHelpers('popover');
export const popoverIdParts = ['trigger', 'content'];
export function createPopover(args) {
const withDefaults = { ...defaults, ...args };
const options = toWritableStores(omit(withDefaults, 'open', 'ids'));
const { positioning, arrowSize, disableFocusTrap, preventScroll, escapeBehavior, closeOnOutsideClick, portal, forceVisible, openFocus, closeFocus, onOutsideClick, preventTextSelectionOverflow, } = options;
const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
const open = overridable(openWritable, withDefaults?.onOpenChange);
const activeTrigger = withGet.writable(null);
const ids = toWritableStores({ ...generateIds(popoverIdParts), ...withDefaults.ids });
function handleClose() {
open.set(false);
}
const isVisible = derivedVisible({ open, activeTrigger, forceVisible });
const content = makeElement(name('content'), {
stores: [isVisible, open, activeTrigger, portal, ids.content],
returned: ([$isVisible, $open, $activeTrigger, $portal, $contentId]) => {
return {
hidden: $isVisible && isBrowser ? undefined : true,
tabindex: -1,
style: $isVisible ? undefined : styleToString({ display: 'none' }),
id: $contentId,
'data-state': $open && $activeTrigger ? 'open' : 'closed',
'data-portal': portalAttr($portal),
};
},
action: (node) => {
let unsubPopper = noop;
const unsubDerived = effect([isVisible, activeTrigger, positioning, disableFocusTrap, closeOnOutsideClick, portal], ([$isVisible, $activeTrigger, $positioning, $disableFocusTrap, $closeOnOutsideClick, $portal,]) => {
unsubPopper();
if (!$isVisible || !$activeTrigger)
return;
tick().then(() => {
unsubPopper();
unsubPopper = usePopper(node, {
anchorElement: $activeTrigger,
open,
options: {
floating: $positioning,
focusTrap: $disableFocusTrap ? null : undefined,
modal: {
shouldCloseOnInteractOutside: shouldCloseOnInteractOutside,
onClose: handleClose,
closeOnInteractOutside: $closeOnOutsideClick,
},
escapeKeydown: { behaviorType: escapeBehavior },
portal: getPortalDestination(node, $portal),
preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
},
}).destroy;
});
});
return {
destroy() {
unsubDerived();
unsubPopper();
},
};
},
});
async function toggleOpen() {
open.update((prev) => !prev);
}
function shouldCloseOnInteractOutside(e) {
onOutsideClick.get()?.(e);
if (e.defaultPrevented)
return false;
const target = e.target;
const triggerEl = document.getElementById(ids.trigger.get());
if (triggerEl && isElement(target)) {
if (target === triggerEl || triggerEl.contains(target))
return false;
}
return true;
}
const trigger = makeElement(name('trigger'), {
stores: [isVisible, ids.content, ids.trigger],
returned: ([$isVisible, $contentId, $triggerId]) => {
return {
role: 'button',
'aria-haspopup': 'dialog',
'aria-expanded': $isVisible ? 'true' : 'false',
'data-state': stateAttr($isVisible),
'aria-controls': $contentId,
id: $triggerId,
};
},
action: (node) => {
activeTrigger.set(node);
const unsub = executeCallbacks(addMeltEventListener(node, 'click', toggleOpen), addMeltEventListener(node, 'keydown', (e) => {
if (e.key !== kbd.ENTER && e.key !== kbd.SPACE)
return;
e.preventDefault();
toggleOpen();
}));
return {
destroy() {
activeTrigger.set(null);
unsub();
},
};
},
});
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),
};
},
action: (node) => {
let unsubDerived = noop;
let unsubPortal = noop;
unsubDerived = effect([portal], ([$portal]) => {
unsubPortal();
if ($portal === null)
return;
const portalDestination = getPortalDestination(node, $portal);
if (portalDestination === null)
return;
unsubPortal = usePortal(node, portalDestination).destroy;
});
return {
destroy() {
unsubDerived();
unsubPortal();
},
};
},
});
const arrow = makeElement(name('arrow'), {
stores: arrowSize,
returned: ($arrowSize) => ({
'data-arrow': true,
style: styleToString({
position: 'absolute',
width: `var(--arrow-size, ${$arrowSize}px)`,
height: `var(--arrow-size, ${$arrowSize}px)`,
}),
}),
});
const close = makeElement(name('close'), {
returned: () => ({
type: 'button',
}),
action: (node) => {
const unsub = executeCallbacks(addMeltEventListener(node, 'click', (e) => {
if (e.defaultPrevented)
return;
handleClose();
}), addMeltEventListener(node, 'keydown', (e) => {
if (e.defaultPrevented)
return;
if (e.key !== kbd.ENTER && e.key !== kbd.SPACE)
return;
e.preventDefault();
toggleOpen();
}));
return {
destroy: unsub,
};
},
});
effect([open, activeTrigger, preventScroll], ([$open, $activeTrigger, $preventScroll]) => {
if (!isBrowser || !$open)
return;
const unsubs = [];
if ($preventScroll) {
unsubs.push(removeScroll());
}
handleFocus({ prop: openFocus.get(), defaultEl: $activeTrigger });
return () => {
unsubs.forEach((unsub) => unsub());
};
});
effect(open, ($open) => {
if (!isBrowser || $open)
return;
const triggerEl = document.getElementById(ids.trigger.get());
handleFocus({ prop: closeFocus.get(), defaultEl: triggerEl });
}, { skipFirstRun: true });
return {
ids,
elements: {
trigger,
content,
arrow,
close,
overlay,
},
states: {
open,
},
options,
};
}
function stateAttr(open) {
return open ? 'open' : 'closed';
}