vira
Version:
A simple and highly versatile design system using element-vir.
282 lines (271 loc) • 10.6 kB
JavaScript
import { assert } from '@augment-vir/assert';
import { NavController } from 'device-navigation';
import { classMap, css, defineElementEvent, html, listen, renderIf } from 'element-vir';
import { createFocusStyles } from '../../styles/focus.js';
import { noNativeFormStyles, noUserSelect, viraDisabledStyles } from '../../styles/index.js';
import { HidePopUpEvent, NavSelectEvent, PopUpManager, } from '../../util/pop-up-manager.js';
import { defineViraElement } from '../define-vira-element.js';
import { triggerPopUpState } from './pop-up-helpers.js';
/**
* Anchor options for pop-ups.
*
* @category Internal
*/
export var HorizontalAnchor;
(function (HorizontalAnchor) {
/**
* The left side of the pop-up will be anchored to the left side of the trigger, allowing the
* pop-up to grow on the right side of the trigger.
*/
HorizontalAnchor["Left"] = "left";
/**
* The Right side of the pop-up will be anchored to the right side of the trigger, allowing the
* pop-up to grow on the left side of the trigger.
*/
HorizontalAnchor["Right"] = "right";
/**
* Restrict the pop-up on both sides.
*
* This is the default anchor for {@link ViraPopUpTrigger}.
*/
HorizontalAnchor["Both"] = "both";
})(HorizontalAnchor || (HorizontalAnchor = {}));
/**
* An element with slots for a pop-up trigger and pop-up contents.
*
* @category PopUp
* @category Elements
* @see https://electrovir.github.io/element-vir/vira/book/elements/vira-pop-up-trigger
*/
export const ViraPopUpTrigger = defineViraElement()({
tagName: 'vira-pop-up-trigger',
state({ host }) {
return {
/** `undefined` means the pop up is not currently showing. */
showPopUpResult: undefined,
popUpManager: new PopUpManager(new NavController(host, { activateOnMouseUp: true })),
};
},
slotNames: [
'trigger',
'popUp',
],
hostClasses: {
'vira-pop-up-trigger-disabled': ({ inputs }) => !!inputs.isDisabled,
},
styles: ({ hostClasses }) => css `
:host {
display: inline-flex;
box-sizing: border-box;
vertical-align: middle;
position: relative;
max-width: 100%;
}
.dropdown-wrapper {
${noNativeFormStyles};
cursor: pointer;
max-width: 100%;
position: relative;
flex-grow: 1;
box-sizing: border-box;
${createFocusStyles({
elementBorderSize: 1,
})}
}
.dropdown-trigger {
box-sizing: border-box;
${noUserSelect};
}
${hostClasses['vira-pop-up-trigger-disabled'].selector} {
${viraDisabledStyles}
pointer-events: auto;
}
${hostClasses['vira-pop-up-trigger-disabled'].selector} .dropdown-wrapper {
pointer-events: none;
}
.pop-up-positioner {
position: absolute;
pointer-events: none;
display: flex;
box-sizing: border-box;
flex-direction: column;
align-items: flex-start;
/* highest possible z-index */
z-index: 2147483647;
& > * {
pointer-events: auto;
max-width: 100%;
}
&.right-aligned {
align-items: flex-end;
}
}
.open-upwards .pop-up-positioner {
flex-direction: column-reverse;
}
`,
events: {
navSelect: defineElementEvent(),
/**
* - `undefined` indicates that the pop-up just closed.
* - {@link ShowPopUpResult} indicates that the pop-up just opened.
*/
openChange: defineElementEvent(),
init: defineElementEvent(),
},
cleanup({ state, updateState }) {
updateState({ showPopUpResult: undefined });
state.popUpManager.destroy();
},
init({ state, updateState, host, inputs, dispatch, events }) {
/** Refocus the trigger and set the result to `undefined` when the pop up closes. */
state.popUpManager.listen(HidePopUpEvent, () => {
updateState({ showPopUpResult: undefined });
dispatch(new events.openChange(undefined));
if (!inputs.isDisabled) {
const dropdownWrapper = host.shadowRoot.querySelector('.dropdown-wrapper');
assert.instanceOf(dropdownWrapper, HTMLButtonElement, 'failed to find dropdown wrapper child');
dropdownWrapper.focus();
}
});
state.popUpManager.listen(NavSelectEvent, (event) => {
if (!inputs.keepOpenAfterInteraction) {
triggerPopUpState({
open: false,
callback(showPopUpResult) {
updateState({
showPopUpResult,
});
},
host,
popUpManager: state.popUpManager,
});
}
dispatch(new events.navSelect(event.detail));
});
dispatch(new events.init({
navController: state.popUpManager.navController,
popUpManager: state.popUpManager,
}));
},
render({ dispatch, events, state, inputs, updateState, host, slotNames }) {
function triggerPopUp({ emitEvent, open }, event) {
if (state.showPopUpResult && inputs.keepOpenAfterInteraction && event) {
const dropdownTrigger = host.shadowRoot.querySelector('.dropdown-trigger');
if (dropdownTrigger && !event.composedPath().includes(dropdownTrigger)) {
/**
* Prevent closing the pop-up when `keepOpenAfterInteraction` is turned on and
* the pop-up was interacted with.
*/
return;
}
}
triggerPopUpState({
open,
callback(showPopUpResult) {
updateState({ showPopUpResult });
if (emitEvent) {
dispatch(new events.openChange(showPopUpResult));
}
},
host,
popUpManager: state.popUpManager,
});
}
if (inputs.isDisabled) {
triggerPopUp({ open: false, emitEvent: false }, undefined);
}
else if (inputs.z_debug_forceOpenState != undefined) {
if (!inputs.z_debug_forceOpenState && state.showPopUpResult) {
triggerPopUp({ emitEvent: false, open: false }, undefined);
}
else if (inputs.z_debug_forceOpenState && !state.showPopUpResult) {
triggerPopUp({ emitEvent: false, open: true }, undefined);
}
}
const leftCss = inputs.horizontalAnchor === HorizontalAnchor.Right && state.showPopUpResult
? css `
left: -${state.showPopUpResult.positions.diff.left}px;
`
: css `
left: ${inputs.popUpOffset?.left || 0}px;
`;
const rightCss = state.showPopUpResult && inputs.horizontalAnchor === HorizontalAnchor.Left
? css `
right: -${state.showPopUpResult.positions.diff.right}px;
`
: css `
right: ${inputs.popUpOffset?.right || 0}px;
`;
const horizontalPositionStyle = css `
${leftCss}
${rightCss}
`;
/**
* These styles do _not_ account for window resizing while the menu is open. I decided this
* was not a major enough problem to tackle. If it becomes major enough in the future,
* you'll need to hook into a window _or_ container resize listener inside `PopUpManager`
* and emit a new `ShowPopUpResult` instance when it changes.
*/
const positionerStyles = state.showPopUpResult
? state.showPopUpResult.popDown
? /** Dropdown going down position. */
css `
bottom: -${state.showPopUpResult.positions.diff.bottom}px;
top: calc(100% + ${inputs.popUpOffset?.vertical || 0}px);
${horizontalPositionStyle}
`
: /** Dropdown going up position. */
css `
top: -${state.showPopUpResult.positions.diff.top}px;
bottom: calc(100% + ${inputs.popUpOffset?.vertical || 0}px);
${horizontalPositionStyle}
`
: undefined;
function respondToClick(event) {
triggerPopUp({ emitEvent: true, open: !state.showPopUpResult }, event);
}
return html `
<button
?disabled=${!!inputs.isDisabled}
class="dropdown-wrapper ${classMap({
open: !!state.showPopUpResult,
'open-upwards': !state.showPopUpResult?.popDown,
})}"
role="listbox"
aria-expanded=${!!state.showPopUpResult}
${listen('keydown', (event) => {
if (!state.showPopUpResult && event.code.startsWith('Arrow')) {
triggerPopUp({ emitEvent: true, open: true }, event);
}
})}
${listen('click', (event) => {
/** Detail is 0 if it was a keyboard key (like Enter) that triggered this click. */
if (event.detail === 0) {
respondToClick(event);
}
})}
${listen('mousedown', (event) => {
/** Ignore any clicks that aren't the main button. */
if (event.button === 0) {
respondToClick(event);
}
})}
>
<div class="dropdown-trigger">
<slot name=${slotNames.trigger}></slot>
</div>
<div
class="pop-up-positioner ${classMap({
'right-aligned': inputs.horizontalAnchor === HorizontalAnchor.Right,
})}"
style=${positionerStyles}
>
${renderIf(!!state.showPopUpResult, html `
<slot name=${slotNames.popUp}></slot>
`)}
</div>
</button>
`;
},
});