UNPKG

vira

Version:

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

219 lines (208 loc) 8.04 kB
import { assertWrap } from '@augment-vir/assert'; import { css, defineElementEvent, html } from 'element-vir'; import { listenTo } from 'typed-event-target'; import { Check24Icon } from '../../icons/icon-svgs/24/check-24.icon.js'; import { viraFormCssVars } from '../../styles/form-styles.js'; import { noUserSelect } from '../../styles/index.js'; import { defineViraElement } from '../../util/define-vira-element.js'; import { ViraIcon } from '../vira-icon.element.js'; /** * An element for an individual menu item. * * @category PopUp * @category Elements */ export const ViraMenuItem = defineViraElement()({ tagName: 'vira-menu-item', state() { return { /** Removes event listeners registered during init. */ cleanupListeners: undefined, }; }, events: { /** * Fired when this menu item is activated by the user (a non-disabled click). Pop-up * containers (e.g. `ViraPopUpTrigger`) listen to this to close the pop-up on selection, * gated by `keepOpenAfterInteraction`. */ activate: defineElementEvent(), }, hostClasses: { 'vira-menu-item-selected': ({ inputs }) => !!inputs.selected || !!inputs.iconOverride, 'vira-menu-item-disabled': ({ inputs }) => !!inputs.disabled, 'vira-menu-item-enabled': ({ inputs }) => !inputs.disabled, 'vira-menu-item-default-icon': ({ inputs }) => !inputs.iconOverride, 'vira-menu-item-default-styles': ({ inputs }) => !inputs.disablePointerStyles, }, cssVars: { /** Horizontal gap between a menu item's icon and its label. */ 'vira-menu-item-icon-gap': '3px', }, styles: ({ hostClasses, cssVars }) => css ` :host { display: flex; flex-shrink: 0; ${noUserSelect}; box-sizing: border-box; max-width: 100%; gap: ${cssVars['vira-menu-item-icon-gap'].value}; overflow: hidden; padding: 8px 3px; padding-right: 16px; align-items: center; text-align: left; } ${hostClasses['vira-menu-item-disabled'].selector}${hostClasses['vira-menu-item-default-styles'].selector} { cursor: not-allowed; & .slot-wrapper, & ${ViraIcon} { opacity: 0.3; pointer-events: none; } } :host(:focus), :host(:active) { outline: none; } ${hostClasses['vira-menu-item-enabled'].selector}${hostClasses['vira-menu-item-default-styles'].selector} { cursor: pointer; &:host(:focus) { background-color: ${viraFormCssVars['vira-form-selection-hover-color'].value}; } &:host(:active) { background-color: ${viraFormCssVars['vira-form-selection-active-color'].value}; } } ${ViraIcon} { width: 24px; aspect-ratio: 1; align-items: center; justify-content: center; } ${hostClasses['vira-menu-item-default-icon'].selector} { ${ViraIcon} { visibility: hidden; } } ${hostClasses['vira-menu-item-selected'].selector} ${ViraIcon} { visibility: visible; } .slot-wrapper { display: flex; flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } `, init({ state, updateState, host, inputs, dispatch, events }) { host.setAttribute('role', 'menuitem'); host.setAttribute('tabindex', inputs.disabled ? '-1' : '0'); host.setAttribute('aria-selected', String(!!inputs.selected)); host.setAttribute('aria-disabled', String(!!inputs.disabled)); state.cleanupListeners?.(); const propagating = {}; function propagateMouseEvent(event) { if (propagating[event.type]) { return; } else if (inputs.disabled) { event.preventDefault(); event.stopPropagation(); return; } /** * Only forward `click`. Slotted content is activated by a single forwarded click, so * forwarding `mousedown` as well would double-trigger interactions: a `<select>`, for * example, opens its picker via `showPicker()` on both `mousedown` and `click`, and the * first call consumes the transient user activation so the second throws * `NotAllowedError`. `mousedown` still reaches this handler so disabled items can block * it above. */ if (event.type !== 'click') { return; } if (inputs.keepOpenAfterInteraction) { event.stopPropagation(); } const slotElement = assertWrap.instanceOf(host.shadowRoot.querySelector('slot'), HTMLSlotElement); slotElement .assignedElements({ flatten: true, }) .forEach((element) => { if (element instanceof HTMLElement && !event.composedPath().includes(element)) { event.preventDefault(); event.stopPropagation(); propagating[event.type] = true; const hasModifiers = event instanceof MouseEvent && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); if (hasModifiers) { /** * If modifier keys are present, dispatch a synthetic MouseEvent so that * the modifier keys are preserved. This is needed so that cmd+click * opens links in a new tab. */ element.dispatchEvent(new MouseEvent('click', event)); } else { /** * Use `.click()` instead of dispatching a synthetic MouseEvent so that * the resulting event carries user activation. This is required for * APIs like `showPicker()` on `<select>` elements. */ element.click(); } delete propagating[event.type]; } }); } const listenerRemovers = [ listenTo(host, 'click', propagateMouseEvent), listenTo(host, 'mousedown', propagateMouseEvent), listenTo(host, 'click', (event) => { if (propagating[event.type] || inputs.disabled || inputs.keepOpenAfterInteraction) { return; } dispatch(new events.activate(undefined)); }, { capture: true, }), listenTo(host, 'mouseenter', () => { if (!inputs.disabled) { host.focus(); } }), listenTo(host, 'mouseleave', () => { if (!inputs.disabled) { host.blur(); } }), ]; updateState({ cleanupListeners: () => { listenerRemovers.forEach((remover) => remover()); }, }); }, cleanup({ state, updateState }) { state.cleanupListeners?.(); updateState({ cleanupListeners: undefined, }); }, render({ inputs }) { return html ` <${ViraIcon.assign({ icon: inputs.iconOverride || Check24Icon, })}></${ViraIcon}> <div class="slot-wrapper"> <slot>&nbsp;</slot> </div> `; }, });