vira
Version:
A simple and highly versatile design system using element-vir.
252 lines (239 loc) • 9.29 kB
JavaScript
import { check } from '@augment-vir/assert';
import { filterMap, randomString } from '@augment-vir/common';
import { classMap, css, defineElementEvent, html, ifDefined, listen, nothing, testId, } from 'element-vir';
import { ChevronUp16Icon } from '../icons/index.js';
import { viraFormCssVars } from '../styles/form-styles.js';
import { noUserSelect, viraAnimationDurations } from '../styles/index.js';
import { defineViraElement } from '../util/define-vira-element.js';
import { renderMenuItemEntries } from '../util/pop-up-helpers.js';
import { ViraMenu, ViraMenuPopUpDirection } from './pop-up/vira-menu.element.js';
import { HorizontalAnchor, ViraPopUpTrigger, } from './pop-up/vira-pop-up-trigger.element.js';
import { ViraIcon } from './vira-icon.element.js';
/**
* A dropdown element that uses pop-up menus.
*
* @category Dropdown
* @category Elements
* @see https://electrovir.github.io/vira/book/elements/dropdown/vira-dropdown
*/
export const ViraDropdown = defineViraElement()({
tagName: 'vira-dropdown',
testIds: [
'leadingIcon',
'prefixText',
'trigger',
],
styles: css `
:host {
display: inline-flex;
vertical-align: middle;
width: 256px;
position: relative;
max-width: 100%;
}
${ViraPopUpTrigger} {
width: 100%;
}
.selection-display {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger-icon {
width: 24px;
aspect-ratio: 1;
align-self: flex-start;
will-change: transform;
transform: rotate(180deg);
transition: transform
${viraAnimationDurations['vira-interaction-animation-duration'].value} linear;
}
.trigger-icon-wrapper {
flex-grow: 1;
display: flex;
justify-content: flex-end;
}
.open {
& .trigger-icon {
transform: rotate(0);
}
&:not(.open-upwards).dropdown-trigger {
border-bottom-left-radius: 0;
}
&.open-upwards.dropdown-trigger {
border-top-left-radius: 0;
}
}
.dropdown-trigger {
${noUserSelect};
border: 1px solid ${viraFormCssVars['vira-form-border-color'].value};
height: 100%;
width: 100%;
box-sizing: border-box;
display: flex;
gap: 8px;
text-align: left;
align-items: center;
padding: 3px;
padding-left: 10px;
border-radius: ${viraFormCssVars['vira-form-radius'].value};
background-color: ${viraFormCssVars['vira-form-background-color'].value};
color: ${viraFormCssVars['vira-form-foreground-color'].value};
}
.using-placeholder {
opacity: 0.4;
}
label {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 2px;
width: 100%;
max-width: 100%;
& .dropdown-label {
font-weight: ${viraFormCssVars['vira-form-label-font-weight'].value};
text-align: left;
flex-shrink: 0;
flex-wrap: wrap;
}
}
`,
events: {
/**
* @deprecated Use `selectedValuesChange` instead. `selectedChange` is broken for
* multi-select (it doesn't emit all the currently selected values) but is temporarily
* left for backwards compatibility purposes. It will be removed entirely soon.
*/
selectedChange: defineElementEvent(),
/** Emits all currently selected values. */
selectedValuesChange: defineElementEvent(),
openChange: defineElementEvent(),
},
state() {
return {
/** `undefined` means the pop up is not currently showing. */
showPopUpResult: undefined,
/**
* Used to couple the label and trigger together. This is not applied if no label is
* provided.
*/
randomId: randomString(32),
};
},
render({ state, inputs, dispatch, events, updateState, testIds }) {
const selectedOptions = filterMap(inputs.selected, (selectedValue) => inputs.options.find((option) => option.value === selectedValue), check.isTruthy);
const leadingIconTemplate = inputs.icon
? html `
<${ViraIcon.assign({
icon: inputs.icon,
})}
${testId(testIds.leadingIcon)}
></${ViraIcon}>
`
: nothing;
const shouldUsePlaceholder = !selectedOptions.length;
const prefixTemplate = inputs.selectionPrefix && !shouldUsePlaceholder
? html `
<span class="selected-label-prefix" ${testId(testIds.prefixText)}>
${inputs.selectionPrefix}
</span>
`
: nothing;
const selectionDisplay = shouldUsePlaceholder
? inputs.placeholder || ''
: inputs.isMultiSelect && selectedOptions.length > 1
? `${selectedOptions.length} Selected`
: selectedOptions[0]?.label || '';
const menuTemplate = html `
<${ViraMenu.assign({
direction: state.showPopUpResult?.popDown
? ViraMenuPopUpDirection.Downwards
: ViraMenuPopUpDirection.Upwards,
})}
slot=${ViraPopUpTrigger.slotNames['vira-pop-up-trigger-pop-up']}
>
${renderMenuItemEntries(inputs.options.map((option) => {
return {
content: option.label,
onClick() {
const newSelectedValues = inputs.isMultiSelect
? selectedOptions.includes(option)
? filterMap(selectedOptions, (selectedOption) => selectedOption.value, (value, selectedOption) => selectedOption !== option)
: [
...selectedOptions.map((selectedOption) => selectedOption.value),
option.value,
]
: [option.value];
// eslint-disable-next-line @typescript-eslint/no-deprecated
dispatch(new events.selectedChange([option.value]));
dispatch(new events.selectedValuesChange(newSelectedValues));
},
disabled: option.disabled,
selected: selectedOptions.includes(option),
};
}))}
</${ViraMenu}>
`;
const triggerTemplate = html `
<${ViraPopUpTrigger.assign({
...inputs,
keepOpenAfterInteraction: inputs.isMultiSelect,
focusOnClose: true,
popUpOffset: {
vertical: -1,
right: 24,
},
horizontalAnchor: inputs.horizontalAnchor || HorizontalAnchor.Both,
})}
${listen(ViraPopUpTrigger.events.openChange, (event) => {
if (!!state.showPopUpResult !== !!event.detail) {
dispatch(new events.openChange(event.detail));
}
updateState({
showPopUpResult: event.detail,
});
})}
>
<div
class="dropdown-trigger ${classMap({
open: !!state.showPopUpResult,
'open-upwards': !state.showPopUpResult?.popDown,
})}"
slot=${ViraPopUpTrigger.slotNames['vira-pop-up-trigger-trigger']}
id=${ifDefined(inputs.label ? state.randomId : undefined)}
aria-label=${ifDefined(inputs.label || undefined)}
${testId(testIds.trigger)}
>
${leadingIconTemplate}
<span
class="selection-display ${classMap({
'using-placeholder': shouldUsePlaceholder,
})}"
title=${ifDefined(shouldUsePlaceholder ? undefined : selectionDisplay)}
>
${prefixTemplate} ${selectionDisplay}
</span>
<span class="trigger-icon-wrapper">
<${ViraIcon.assign({
icon: ChevronUp16Icon,
})}
class="trigger-icon"
></${ViraIcon}>
</span>
</div>
${state.showPopUpResult ? menuTemplate : nothing}
</${ViraPopUpTrigger}>
`;
if (inputs.label) {
return html `
<label for=${state.randomId}>
<span class="dropdown-label">${inputs.label}</span>
${triggerTemplate}
</label>
`;
}
else {
return triggerTemplate;
}
},
});