@melt-ui/svelte
Version:

226 lines (225 loc) • 8.19 kB
JavaScript
import { useFocusTrap, useEscapeKeydown, usePortal } from '../../internal/actions/index.js';
import { addMeltEventListener, makeElement, createElHelpers, effect, executeCallbacks, generateIds, getPortalDestination, handleFocus, isBrowser, isHTMLElement, kbd, noop, omit, overridable, removeScroll, styleToString, toWritableStores, portalAttr, } from '../../internal/helpers/index.js';
import { withGet } from '../../internal/helpers/withGet.js';
import { derived, writable } from 'svelte/store';
import { useModal } from '../../internal/actions/modal/action.js';
const { name } = createElHelpers('dialog');
const defaults = {
preventScroll: true,
escapeBehavior: 'close',
closeOnOutsideClick: true,
role: 'dialog',
defaultOpen: false,
portal: 'body',
forceVisible: false,
openFocus: undefined,
closeFocus: undefined,
onOutsideClick: undefined,
};
export const dialogIdParts = ['content', 'title', 'description'];
export function createDialog(props) {
const withDefaults = { ...defaults, ...props };
const options = toWritableStores(omit(withDefaults, 'ids'));
const { preventScroll, escapeBehavior, closeOnOutsideClick, role, portal, forceVisible, openFocus, closeFocus, onOutsideClick, } = options;
const activeTrigger = withGet.writable(null);
const ids = toWritableStores({
...generateIds(dialogIdParts),
...withDefaults.ids,
});
const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
const open = overridable(openWritable, withDefaults?.onOpenChange);
const isVisible = derived([open, forceVisible], ([$open, $forceVisible]) => {
return $open || $forceVisible;
});
let unsubScroll = noop;
function handleOpen(e) {
const el = e.currentTarget;
const triggerEl = e.currentTarget;
if (!isHTMLElement(el) || !isHTMLElement(triggerEl))
return;
open.set(true);
activeTrigger.set(triggerEl);
}
function handleClose() {
open.set(false);
}
const trigger = makeElement(name('trigger'), {
stores: [open],
returned: ([$open]) => {
return {
'aria-haspopup': 'dialog',
'aria-expanded': $open,
type: 'button',
};
},
action: (node) => {
const unsub = executeCallbacks(addMeltEventListener(node, 'click', (e) => {
handleOpen(e);
}), addMeltEventListener(node, 'keydown', (e) => {
if (e.key !== kbd.ENTER && e.key !== kbd.SPACE)
return;
e.preventDefault();
handleOpen(e);
}));
return {
destroy: unsub,
};
},
});
const overlay = makeElement(name('overlay'), {
stores: [isVisible, open],
returned: ([$isVisible, $open]) => {
return {
hidden: $isVisible ? undefined : true,
tabindex: -1,
style: $isVisible ? undefined : styleToString({ display: 'none' }),
'aria-hidden': true,
'data-state': $open ? 'open' : 'closed',
};
},
});
const content = makeElement(name('content'), {
stores: [isVisible, ids.content, ids.description, ids.title, open],
returned: ([$isVisible, $contentId, $descriptionId, $titleId, $open]) => {
return {
id: $contentId,
role: role.get(),
'aria-describedby': $descriptionId,
'aria-labelledby': $titleId,
'aria-modal': $isVisible ? 'true' : undefined,
'data-state': $open ? 'open' : 'closed',
tabindex: -1,
hidden: $isVisible ? undefined : true,
style: $isVisible ? undefined : styleToString({ display: 'none' }),
};
},
action: (node) => {
let unsubEscape = noop;
let unsubModal = noop;
let unsubFocusTrap = noop;
const unsubDerived = effect([isVisible, closeOnOutsideClick], ([$isVisible, $closeOnOutsideClick]) => {
unsubModal();
unsubEscape();
unsubFocusTrap();
if (!$isVisible)
return;
unsubModal = useModal(node, {
closeOnInteractOutside: $closeOnOutsideClick,
onClose: handleClose,
shouldCloseOnInteractOutside(e) {
onOutsideClick.get()?.(e);
if (e.defaultPrevented)
return false;
return true;
},
}).destroy;
unsubEscape = useEscapeKeydown(node, {
handler: handleClose,
behaviorType: escapeBehavior,
}).destroy;
unsubFocusTrap = useFocusTrap(node, { fallbackFocus: node }).destroy;
});
return {
destroy: () => {
unsubScroll();
unsubDerived();
unsubModal();
unsubEscape();
unsubFocusTrap();
},
};
},
});
const portalled = makeElement(name('portalled'), {
stores: portal,
returned: ($portal) => ({
'data-portal': portalAttr($portal),
}),
action: (node) => {
const unsubPortal = effect([portal], ([$portal]) => {
if ($portal === null)
return noop;
const portalDestination = getPortalDestination(node, $portal);
if (portalDestination === null)
return noop;
return usePortal(node, portalDestination).destroy;
});
return {
destroy() {
unsubPortal();
},
};
},
});
const title = makeElement(name('title'), {
stores: [ids.title],
returned: ([$titleId]) => ({
id: $titleId,
}),
});
const description = makeElement(name('description'), {
stores: [ids.description],
returned: ([$descriptionId]) => ({
id: $descriptionId,
}),
});
const close = makeElement(name('close'), {
returned: () => ({
type: 'button',
}),
action: (node) => {
const unsub = executeCallbacks(addMeltEventListener(node, 'click', () => {
handleClose();
}), addMeltEventListener(node, 'keydown', (e) => {
if (e.key !== kbd.SPACE && e.key !== kbd.ENTER)
return;
e.preventDefault();
handleClose();
}));
return {
destroy: unsub,
};
},
});
effect([open, preventScroll], ([$open, $preventScroll]) => {
if (!isBrowser)
return;
if ($preventScroll && $open)
unsubScroll = removeScroll();
if ($open) {
const contentEl = document.getElementById(ids.content.get());
handleFocus({ prop: openFocus.get(), defaultEl: contentEl });
}
return () => {
// we only want to remove the scroll lock if the dialog is not forced visible
// otherwise the scroll removal is handled in the `destroy` of the `content` builder
if (!forceVisible.get()) {
unsubScroll();
}
};
});
effect(open, ($open) => {
if (!isBrowser || $open)
return;
handleFocus({
prop: closeFocus.get(),
defaultEl: activeTrigger.get(),
});
}, { skipFirstRun: true });
return {
ids,
elements: {
content,
trigger,
title,
description,
overlay,
close,
portalled,
},
states: {
open,
},
options,
};
}