|
import { createFocusTrap, 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 { tick } from 'svelte'; |
|
import { derived, writable } from 'svelte/store'; |
|
import { useModal } from '../../internal/actions/modal/action.js'; |
|
const { name } = createElHelpers('dialog'); |
|
const defaults = { |
|
preventScroll: true, |
|
closeOnEscape: true, |
|
closeOnOutsideClick: true, |
|
role: 'dialog', |
|
defaultOpen: false, |
|
portal: undefined, |
|
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, closeOnEscape, 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); |
|
handleFocus({ |
|
prop: closeFocus.get(), |
|
defaultEl: activeTrigger.get(), |
|
}); |
|
} |
|
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: styleToString({ |
|
display: $isVisible ? undefined : 'none', |
|
}), |
|
'aria-hidden': true, |
|
'data-state': $open ? 'open' : 'closed', |
|
}; |
|
}, |
|
action: (node) => { |
|
let unsubEscapeKeydown = noop; |
|
if (closeOnEscape.get()) { |
|
const escapeKeydown = useEscapeKeydown(node, { |
|
handler: () => { |
|
handleClose(); |
|
}, |
|
}); |
|
if (escapeKeydown && escapeKeydown.destroy) { |
|
unsubEscapeKeydown = escapeKeydown.destroy; |
|
} |
|
} |
|
return { |
|
destroy() { |
|
unsubEscapeKeydown(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
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: styleToString({ |
|
display: $isVisible ? undefined : 'none', |
|
}), |
|
}; |
|
}, |
|
action: (node) => { |
|
let activate = noop; |
|
let deactivate = noop; |
|
const destroy = executeCallbacks(effect([open], ([$open]) => { |
|
if (!$open) |
|
return; |
|
const focusTrap = createFocusTrap({ |
|
immediate: false, |
|
escapeDeactivates: true, |
|
clickOutsideDeactivates: true, |
|
returnFocusOnDeactivate: false, |
|
fallbackFocus: node, |
|
}); |
|
activate = focusTrap.activate; |
|
deactivate = focusTrap.deactivate; |
|
const ac = focusTrap.useFocusTrap(node); |
|
if (ac && ac.destroy) { |
|
return ac.destroy; |
|
} |
|
else { |
|
return focusTrap.deactivate; |
|
} |
|
}), effect([closeOnOutsideClick, open], ([$closeOnOutsideClick, $open]) => { |
|
return useModal(node, { |
|
open: $open, |
|
closeOnInteractOutside: $closeOnOutsideClick, |
|
onClose() { |
|
handleClose(); |
|
}, |
|
shouldCloseOnInteractOutside(e) { |
|
onOutsideClick.get()?.(e); |
|
if (e.defaultPrevented) |
|
return false; |
|
return true; |
|
}, |
|
}).destroy; |
|
}), effect([closeOnEscape], ([$closeOnEscape]) => { |
|
if (!$closeOnEscape) |
|
return noop; |
|
const escapeKeydown = useEscapeKeydown(node, { |
|
handler: () => { |
|
handleClose(); |
|
}, |
|
}); |
|
if (escapeKeydown && escapeKeydown.destroy) { |
|
return escapeKeydown.destroy; |
|
} |
|
return noop; |
|
}), effect([isVisible], ([$isVisible]) => { |
|
tick().then(() => { |
|
if (!$isVisible) { |
|
deactivate(); |
|
} |
|
else { |
|
activate(); |
|
} |
|
}); |
|
})); |
|
return { |
|
destroy: () => { |
|
unsubScroll(); |
|
destroy(); |
|
}, |
|
}; |
|
}, |
|
}); |
|
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; |
|
const portalAction = usePortal(node, portalDestination); |
|
if (portalAction && portalAction.destroy) { |
|
return portalAction.destroy; |
|
} |
|
else { |
|
return noop; |
|
} |
|
}); |
|
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 () => { |
|
|
|
|
|
if (!forceVisible.get()) { |
|
unsubScroll(); |
|
} |
|
}; |
|
}); |
|
return { |
|
ids, |
|
elements: { |
|
content, |
|
trigger, |
|
title, |
|
description, |
|
overlay, |
|
close, |
|
portalled, |
|
}, |
|
states: { |
|
open, |
|
}, |
|
options, |
|
}; |
|
} |
|
|