UNPKG

vira

Version:

A simple and highly versatile design system using element-vir.

391 lines (379 loc) 15 kB
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> `; }, });