vira
Version:
A simple and highly versatile design system using element-vir.
391 lines (379 loc) • 15 kB
JavaScript
import { assert, assertWrap } from '@augment-vir/assert';
import { walkActiveElement } from '@augment-vir/web';
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 { defineViraElement } from '../../util/define-vira-element.js';
import { triggerPopUpState } from '../../util/pop-up-helpers.js';
import { HidePopUpEvent, isInputLikeElement, NavSelectEvent, PopUpManager, } from '../../util/pop-up-manager.js';
import { ViraMenuItem } from './vira-menu-item.element.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. */
HorizontalAnchor["Both"] = "both";
/**
* Automatically choose left or right based on available space, defaulting to anchoring on the
* left side.
*
* This is the default anchor for {@link ViraPopUpTrigger}.
*/
HorizontalAnchor["Auto"] = "auto";
})(HorizontalAnchor || (HorizontalAnchor = {}));
/**
* An element with slots for a pop-up trigger and pop-up contents.
*
* @category PopUp
* @category Elements
* @see https://electrovir.github.io/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: [
'vira-pop-up-trigger-trigger',
'vira-pop-up-trigger-pop-up',
],
hostClasses: {
'vira-pop-up-trigger-disabled': ({ inputs }) => !!inputs.isDisabled,
'vira-pop-up-trigger-inside-focus': ({ inputs }) => !!inputs.useInsideFocus,
'vira-pop-up-trigger-outside-focus': ({ inputs }) => !inputs.useInsideFocus,
},
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;
}
${hostClasses['vira-pop-up-trigger-inside-focus'].selector} .dropdown-wrapper {
${createFocusStyles({
renderInside: true,
})}
}
${hostClasses['vira-pop-up-trigger-outside-focus'].selector} .dropdown-wrapper {
${createFocusStyles()}
}
.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.focusOnClose && !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);
}
}
/**
* Resolve the effective horizontal anchor. For Auto, use the popRight calculation from
* showPopUpResult to determine whether to anchor left or right.
*/
const effectiveHorizontalAnchor = inputs.horizontalAnchor === HorizontalAnchor.Auto ||
inputs.horizontalAnchor === undefined
? state.showPopUpResult?.popRight
? HorizontalAnchor.Left
: HorizontalAnchor.Right
: inputs.horizontalAnchor;
const leftCss = effectiveHorizontalAnchor === HorizontalAnchor.Right && state.showPopUpResult
? inputs.ignoreMaxWidth
? css `
left: unset;
`
: css `
left: -${state.showPopUpResult.positions.diff.left}px;
`
: css `
left: ${inputs.popUpOffset?.left || 0}px;
`;
const rightCss = state.showPopUpResult && effectiveHorizontalAnchor === HorizontalAnchor.Left
? inputs.ignoreMaxWidth
? css `
right: unset;
`
: 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. */
inputs.ignoreMaxHeight
? css `
bottom: unset;
top: calc(100% + ${inputs.popUpOffset?.vertical || 0}px);
${horizontalPositionStyle}
`
: css `
bottom: -${state.showPopUpResult.positions.diff.bottom}px;
top: calc(100% + ${inputs.popUpOffset?.vertical || 0}px);
${horizontalPositionStyle}
`
: /** Dropdown going up position. */
inputs.ignoreMaxHeight
? css `
top: unset;
bottom: calc(100% + ${inputs.popUpOffset?.vertical || 0}px);
${horizontalPositionStyle}
`
: 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) {
let isTextActiveElement = false;
walkActiveElement(({ element }) => {
if (isInputLikeElement(element)) {
isTextActiveElement = true;
return true;
}
else {
return false;
}
});
if (isTextActiveElement) {
return;
}
respondToClick(event);
}
else if (event.button === 0 && state.showPopUpResult) {
const dropdownTrigger = host.shadowRoot.querySelector('.dropdown-trigger');
/**
* Close the menu when a mouse click originates from the pop-up area (e.g. a
* menu item was clicked). The trigger's mousedown already handles
* open/close toggling for the trigger itself.
*/
if (dropdownTrigger && !event.composedPath().includes(dropdownTrigger)) {
triggerPopUp({
emitEvent: true,
open: false,
}, event);
}
}
})}
${listen('mousedown', (event) => {
/** Ignore any clicks that aren't the main button. */
if (event.button !== 0) {
return;
}
const dropdownTrigger = assertWrap.instanceOf(host.shadowRoot.querySelector('.dropdown-trigger'), HTMLElement);
/**
* Only respond if the mousedown originated from the trigger slot, not from
* within the pop-up menu items, to avoid prematurely closing the menu.
*/
if (event.composedPath().includes(dropdownTrigger)) {
respondToClick(event);
}
})}
${listen(ViraMenuItem.events.activate, (event) => {
if (state.showPopUpResult) {
triggerPopUp({
emitEvent: true,
open: false,
}, event);
}
})}
>
<div class="dropdown-trigger">
<slot name=${slotNames['vira-pop-up-trigger-trigger']}></slot>
</div>
<div
class="pop-up-positioner ${classMap({
'right-aligned': effectiveHorizontalAnchor === HorizontalAnchor.Right,
})}"
style=${positionerStyles}
>
${renderIf(!!state.showPopUpResult, html `
<slot name=${slotNames['vira-pop-up-trigger-pop-up']}></slot>
`)}
</div>
</button>
`;
},
});