vira
Version:
A simple and highly versatile design system using element-vir.
307 lines (289 loc) • 10.5 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 { viraAnimationDurations } from '../styles/durations.js';
import { viraFormCssVars } from '../styles/form-styles.js';
import { noNativeFormStyles, noNativeSpacing } from '../styles/native-styles.js';
import { viraShadows } from '../styles/shadows.js';
import { noUserSelect } from '../styles/user-select.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 globalEventsToCloseDrawerOn = [
'pagehide',
'pageshow',
'popstate',
];
/** Minimum downward drag distance (in pixels) required to close the drawer. */
const dragCloseThreshold = 30;
/**
* A drawer element that slides up from the bottom of the page using the built-in `dialog` element.
*
* @category Elements
*/
export const ViraDrawer = defineViraElement()({
tagName: 'vira-drawer',
events: {
drawerClose: defineElementEvent(),
},
state() {
return {
dialogElement: undefined,
contentElement: undefined,
previousOpenValue: undefined,
/** Cleans up all listeners that have been attached. */
cleanupListeners: undefined,
isDragging: false,
dragStartY: 0,
dragCurrentY: 0,
};
},
cleanup({ state }) {
state.cleanupListeners?.();
},
hostClasses: {
'vira-drawer-dragging': ({ state }) => state.isDragging,
'vira-drawer-no-content-padding': ({ inputs }) => !!inputs.noContentPadding,
},
slotNames: ['vira-drawer-drawer-title'],
cssVars: {
'vira-drawer-backdrop-filter': 'blur(3px)',
'vira-drawer-max-height': '80dvh',
},
styles: ({ cssVars, hostClasses }) => css `
:host {
display: contents;
}
${hostClasses['vira-drawer-dragging'].selector} {
${noUserSelect};
}
h1 {
${noNativeSpacing};
}
dialog {
${colorCss(viraTheme.colors[themeDefaultKey])}
border: none;
padding: 0;
overflow: hidden;
position: fixed;
inset: auto 0 0 0;
margin: 0;
width: 100%;
max-width: 100%;
max-height: ${cssVars['vira-drawer-max-height'].value};
border-radius: 16px 16px 0 0;
${viraShadows.modal}
transition: transform ${viraAnimationDurations['vira-pretty-animation-duration']
.value} ease;
&[open] {
display: flex;
flex-direction: column;
}
&::backdrop {
background: ${viraFormCssVars['vira-form-modal-backdrop-color'].value};
backdrop-filter: ${cssVars['vira-drawer-backdrop-filter'].value};
}
& .drawer-content-wrapper {
overflow: hidden;
display: flex;
flex-direction: column;
& .drag-handle-wrapper {
display: flex;
justify-content: center;
padding: 8px 0 0;
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
& .drag-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background-color: ${viraFormCssVars['vira-form-secondary-body-foreground']
.value};
opacity: 0.5;
}
}
& .header {
padding: 8px 24px 16px;
display: flex;
gap: 16px;
align-items: flex-start;
& .header-text-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
align-self: center;
flex-grow: 1;
overflow: hidden;
& h1 {
font-size: 20px;
}
}
& 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: 0 24px 24px;
overflow: auto;
overscroll-behavior: contain;
}
}
}
${hostClasses['vira-drawer-no-content-padding'].selector} {
& dialog .drawer-content-wrapper .body {
padding: 0;
}
}
`,
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 = globalEventsToCloseDrawerOn.map((eventName) => listenToGlobal(eventName, () => {
dispatch(new events.drawerClose());
}));
updateState({
cleanupListeners: () => {
removers.forEach((remover) => remover());
},
});
}
}
function close() {
if (inputs.open) {
state.cleanupListeners?.();
dispatch(new events.drawerClose());
}
}
if (state.dialogElement) {
if (state.isDragging) {
const dragOffset = Math.max(0, state.dragCurrentY - state.dragStartY);
state.dialogElement.style.transform = `translateY(${String(dragOffset)}px)`;
state.dialogElement.style.transition = 'none';
}
else {
state.dialogElement.style.transform = '';
state.dialogElement.style.transition = '';
}
}
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)) {
close();
}
})}
>
<div
class="drawer-content-wrapper"
${onDomCreated((element) => {
updateState({
contentElement: assertWrap.instanceOf(element, HTMLDivElement),
});
})}
>
<div
class="drag-handle-wrapper"
${listen('dblclick', () => {
close();
})}
${listen('pointerdown', (event) => {
updateState({
isDragging: true,
dragStartY: event.clientY,
dragCurrentY: event.clientY,
});
function handlePointerMove(moveEvent) {
updateState({
dragCurrentY: moveEvent.clientY,
});
}
function handlePointerUp(upEvent) {
const dragDistance = upEvent.clientY - state.dragStartY;
updateState({
isDragging: false,
dragStartY: 0,
dragCurrentY: 0,
});
if (dragDistance > dragCloseThreshold) {
close();
}
listenerRemovers.forEach((remover) => remover());
}
const listenerRemovers = [
listenToGlobal('pointermove', handlePointerMove),
listenToGlobal('pointerup', handlePointerUp),
];
})}
>
<div class="drag-handle"></div>
</div>
<div class="header">
<div class="header-text-wrapper">
<h1>
<slot name=${slotNames['vira-drawer-drawer-title']}>
${inputs.drawerTitle}
</slot>
</h1>
</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>
`;
},
});