vira
Version:
A simple and highly versatile design system using element-vir.
243 lines (229 loc) • 8.1 kB
JavaScript
import { assertWrap } from '@augment-vir/assert';
import { colorCss } from '@electrovir/color';
import { css, defineElementEvent, html, listen, nothing, onDomCreated } from 'element-vir';
import { themeDefaultKey } from 'theme-vir/dist/color-theme/color-theme.js';
import { listenToGlobal } from 'typed-event-target';
import { X24Icon } from '../icons/icon-svgs/24/x-24.icon.js';
import { viraFormCssVars } from '../styles/form-styles.js';
import { noNativeFormStyles, noNativeSpacing } from '../styles/native-styles.js';
import { viraShadows } from '../styles/shadows.js';
import { viraTheme } from '../styles/vira-color-theme.js';
import { defineViraElement } from '../util/define-vira-element.js';
import { ViraIcon } from './vira-icon.element.js';
const globalEventsToCloseModalOn = [
'pagehide',
'pageshow',
'popstate',
];
/**
* A modal element that uses the built-in `dialog` element.
*
* @category Elements
* @see https://electrovir.github.io/vira/book/elements/vira-modal
*/
export const ViraModal = defineViraElement()({
tagName: 'vira-modal',
events: {
modalClose: defineElementEvent(),
},
state() {
return {
dialogElement: undefined,
contentElement: undefined,
previousOpenValue: undefined,
/** Remove listeners. */
cleanupListeners: undefined,
};
},
cleanup({ state }) {
state.cleanupListeners?.();
},
hostClasses: {
'vira-modal-phone-size': ({ inputs }) => !!inputs.isMobileSize,
'vira-modal-no-content-padding': ({ inputs }) => !!inputs.noContentPadding,
},
slotNames: ['vira-modal-modal-title'],
cssVars: {
'vira-modal-backdrop-filter': 'blur(3px)',
},
styles: ({ hostClasses, cssVars }) => css `
:host {
display: contents;
min-width: 280px;
border-radius: 16px;
}
h1 {
${noNativeSpacing};
}
dialog {
${colorCss(viraTheme.colors[themeDefaultKey])}
border: none;
flex-direction: column;
border-radius: inherit;
padding: 0;
overflow: hidden;
min-width: inherit;
min-height: inherit;
max-width: calc(100dvw - 100px);
max-height: calc(100dvh - 100px);
${viraShadows.modal}
&[open] {
display: flex;
}
&::backdrop {
background: ${viraFormCssVars['vira-form-modal-backdrop-color'].value};
backdrop-filter: ${cssVars['vira-modal-backdrop-filter'].value};
}
& .modal-content-wrapper {
overflow: hidden;
display: flex;
flex-direction: column;
& .header {
padding: 16px 24px;
display: flex;
gap: 16px;
align-items: flex-start;
& .header-text-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
align-self: center;
margin-right: auto;
overflow: hidden;
& h1 {
font-size: 24px;
}
& sub {
font-size: 16px;
color: ${viraFormCssVars['vira-form-secondary-body-foreground'].value};
}
}
& button.close {
${noNativeFormStyles};
flex-shrink: 0;
cursor: pointer;
padding: 4px;
border-radius: ${viraFormCssVars['vira-form-radius'].value};
&:hover {
background-color: ${viraFormCssVars['vira-form-selection-hover-color']
.value};
}
& ${ViraIcon} {
display: flex;
}
}
}
& .body {
padding: 16px 24px;
overflow: auto;
overscroll-behavior: contain;
}
}
}
${hostClasses['vira-modal-no-content-padding'].selector} {
& dialog .modal-content-wrapper .body {
padding: 0;
}
}
${hostClasses['vira-modal-phone-size'].selector} {
& dialog {
width: 100dvw;
max-width: 100dvw;
}
}
`,
render({ inputs, state, updateState, events, dispatch, slotNames }) {
if (state.dialogElement && inputs.open !== state.dialogElement.open) {
if (inputs.open) {
state.dialogElement.showModal();
}
else {
state.dialogElement.close();
}
}
if (state.previousOpenValue !== inputs.open) {
state.cleanupListeners?.();
updateState({
previousOpenValue: inputs.open,
});
if (inputs.open) {
const removers = globalEventsToCloseModalOn.map((eventName) => listenToGlobal(eventName, () => {
dispatch(new events.modalClose());
}));
updateState({
cleanupListeners: () => {
removers.forEach((remover) => remover());
},
});
}
}
function close() {
if (inputs.open) {
state.cleanupListeners?.();
dispatch(new events.modalClose());
}
}
return html `
<dialog
${onDomCreated((element) => {
updateState({
dialogElement: assertWrap.instanceOf(element, HTMLDialogElement),
});
})}
${listen('close', () => {
close();
})}
${listen('mousedown', (event) => {
if (state.contentElement &&
!event.composedPath().includes(state.contentElement) &&
!inputs.blockLightDismissal) {
/** Background click. */
close();
}
})}
>
<div
class="modal-content-wrapper"
${onDomCreated((element) => {
updateState({
contentElement: assertWrap.instanceOf(element, HTMLDivElement),
});
})}
>
<div class="header">
<div class="header-text-wrapper">
<h1>
<slot name=${slotNames['vira-modal-modal-title']}>
${inputs.modalTitle}
</slot>
</h1>
${inputs.modalSubtitle
? html `
<sub>${inputs.modalSubtitle}</sub>
`
: nothing}
</div>
<button
class="close"
aria-label="Close"
${listen('click', () => {
state.dialogElement?.close();
})}
>
<${ViraIcon.assign({
icon: X24Icon,
})}></${ViraIcon}>
</button>
</div>
${inputs.open
? html `
<div class="body">
<slot></slot>
</div>
`
: nothing}
</div>
</dialog>
`;
},
});