>(),
+ },
+ styles: css`
+ :host {
+ display: flex;
+ flex-direction: column;
+
+ pointer-events: auto;
+ width: 100%;
+ max-height: 100%;
+ overflow-y: auto;
+ z-index: 99;
+ border-radius: ${viraBorders['vira-form-input-radius'].value};
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ background-color: ${viraFormCssVars['vira-form-background-color'].value};
+ border: 1px solid ${viraFormCssVars['vira-form-border-color'].value};
+ color: ${viraFormCssVars['vira-form-foreground-color'].value};
+ ${viraShadows.menuShadow}
+ }
+
+ .dropdown-item {
+ background-color: white;
+ outline: none;
+ }
+
+ ${navSelector.css.selected('.dropdown-item:not(.disabled)')} {
+ background-color: ${viraFormCssVars['vira-form-selection-hover-background-color']
+ .value};
+ outline: none;
+ }
+
+ ${ViraDropdownItem} {
+ pointer-events: none;
+ }
+
+ .dropdown-item.disabled {
+ ${viraDisabledStyles};
+ pointer-events: auto;
+ }
+ `,
+ renderCallback({inputs, dispatch, events}) {
+ const optionTemplates = inputs.options.map((option) => {
+ const innerTemplate =
+ option.template ||
+ html`
+ <${ViraDropdownItem.assign({
+ label: option.label,
+ selected: inputs.selectedOptions.includes(option),
+ })}>${ViraDropdownItem}>
+ `;
+
+ return html`
+ {
+ /**
+ * Prevent this mousedown event from propagating to the window, which would
+ * then trigger the dropdown to close.
+ */
+ event.stopPropagation();
+ })}
+ ${listen('mouseup', (event) => {
+ /**
+ * Prevent this event from propagating to the window, which would then
+ * trigger the dropdown to close.
+ */
+ event.stopPropagation();
+
+ if (!option.disabled) {
+ dispatch(new events.selectionChange(option));
+ }
+ })}
+ >
+ ${innerTemplate}
+
+ `;
+ });
+
+ return html`
+ ${optionTemplates}
+ `;
+ },
+});
diff --git a/packages/vira/src/elements/dropdown/vira-dropdown.element.test.ts b/packages/vira/src/elements/dropdown/vira-dropdown.element.test.ts
new file mode 100644
index 00000000..3cfe5e00
--- /dev/null
+++ b/packages/vira/src/elements/dropdown/vira-dropdown.element.test.ts
@@ -0,0 +1,174 @@
+import {queryThroughShadow, waitForAnimationFrame} from '@augment-vir/browser';
+import {clickElement, extractText} from '@augment-vir/browser-testing';
+import {mapObjectValues, randomString, waitUntilTruthy} from '@augment-vir/common';
+import {assert, fixture, waitUntil} from '@open-wc/testing';
+import {html, listen, testIdBy} from 'element-vir';
+import {assertDefined, assertInstanceOf} from 'run-time-assertions';
+import {Element24Icon} from '../../icons/index';
+import {mockOptions} from './dropdown.mock';
+import {viraDropdownOptionsTestIds} from './vira-dropdown-options.element';
+import {ViraDropdown, viraDropdownTestIds} from './vira-dropdown.element';
+
+async function setupDropdownTest(inputs?: Partial<(typeof ViraDropdown)['inputsType']>) {
+ const events: {
+ [EventKey in keyof typeof ViraDropdown.events]: InstanceType<
+ (typeof ViraDropdown.events)[EventKey]
+ >['detail'][];
+ } = mapObjectValues(ViraDropdown.events, () => []);
+ const instance = await fixture(html`
+ <${ViraDropdown.assign({
+ options: mockOptions,
+ selected: [],
+ ...inputs,
+ })}
+ ${listen(ViraDropdown.events.openChange, (event) => {
+ events.openChange.push(event.detail);
+ })}
+ ${listen(ViraDropdown.events.selectedChange, (event) => {
+ events.selectedChange.push(event.detail);
+ })}
+ >${ViraDropdown}>
+ `);
+
+ assertInstanceOf(instance, ViraDropdown);
+
+ const triggerElement = instance.shadowRoot.querySelector(testIdBy(viraDropdownTestIds.trigger));
+ assertInstanceOf(triggerElement, HTMLElement);
+
+ assert.isNull(instance.shadowRoot.querySelector(testIdBy(viraDropdownTestIds.options)));
+ assert.isEmpty(events.openChange);
+ assert.isEmpty(events.selectedChange);
+
+ return {
+ events,
+ instance,
+ triggerElement,
+ queryByTestId: mapObjectValues(viraDropdownTestIds, (testIdKey, testId) => {
+ return () => {
+ return instance.shadowRoot.querySelector(testIdBy(testId));
+ };
+ }),
+ async toggle() {
+ const optionsExisted: boolean = !!instance.shadowRoot.querySelector(
+ testIdBy(viraDropdownTestIds.options),
+ );
+
+ await clickElement(triggerElement);
+
+ await waitUntilTruthy(
+ async () => {
+ const optionsExistNow = !!instance.shadowRoot.querySelector(
+ testIdBy(viraDropdownTestIds.options),
+ );
+
+ return optionsExisted !== optionsExistNow;
+ },
+ 'the options never popped up',
+ {timeout: {milliseconds: 1000}},
+ );
+ },
+ };
+}
+
+describe(ViraDropdown.tagName, () => {
+ it('opens on a click', async () => {
+ const {toggle, events} = await setupDropdownTest();
+
+ await toggle();
+ assert.deepStrictEqual(events.openChange, [true]);
+ });
+
+ it('closes on a click', async () => {
+ const {toggle, events, queryByTestId} = await setupDropdownTest();
+
+ await toggle();
+ assert.deepStrictEqual(events.openChange, [true]);
+ await toggle();
+ assert.deepStrictEqual(events.openChange, [
+ true,
+ false,
+ ]);
+ await waitUntil(() => {
+ return !queryByTestId.options();
+ });
+ });
+
+ it('selects an option on click', async () => {
+ const {instance, toggle, events, queryByTestId} = await setupDropdownTest();
+
+ await toggle();
+ const options = queryThroughShadow({
+ element: instance,
+ query: testIdBy(viraDropdownOptionsTestIds.option),
+ all: true,
+ });
+
+ assert.lengthOf(options, mockOptions.length);
+ assertDefined(options[1]);
+ await clickElement(options[1]);
+
+ await waitUntil(() => {
+ return !queryByTestId.options();
+ });
+ assert.deepStrictEqual(events.openChange, [
+ true,
+ false,
+ ]);
+ assert.deepStrictEqual(events.selectedChange, [
+ [1],
+ ]);
+ });
+
+ it('does not render prefix if nothing is selected', async () => {
+ const {queryByTestId} = await setupDropdownTest({
+ selectionPrefix: randomString(),
+ });
+ await waitForAnimationFrame(5);
+ assert.isNull(queryByTestId.prefix());
+ });
+
+ it('renders a prefix', async () => {
+ const prefix = randomString();
+ const {queryByTestId} = await setupDropdownTest({
+ selectionPrefix: prefix,
+ selected: [1],
+ });
+ const prefixElement = await waitUntilTruthy(
+ () => {
+ return queryByTestId.prefix();
+ },
+ 'prefix element never showed up',
+ {timeout: {milliseconds: 1000}},
+ );
+
+ assert.strictEqual(extractText(prefixElement), prefix);
+ });
+
+ it('renders an icon', async () => {
+ const {queryByTestId} = await setupDropdownTest({
+ icon: Element24Icon,
+ });
+ await waitUntilTruthy(
+ () => {
+ return queryByTestId.icon();
+ },
+ 'icon element never showed up',
+ {timeout: {milliseconds: 1000}},
+ );
+ });
+
+ it('does not render an icon if not assigned', async () => {
+ const {queryByTestId} = await setupDropdownTest();
+ await waitForAnimationFrame(5);
+ assert.isNull(queryByTestId.icon());
+ });
+
+ it('renders a placeholder', async () => {
+ const placeholder = randomString();
+ const {triggerElement} = await setupDropdownTest({
+ placeholder,
+ });
+
+ assert.strictEqual(extractText(triggerElement), placeholder);
+ });
+});
diff --git a/packages/vira/src/elements/dropdown/vira-dropdown.element.ts b/packages/vira/src/elements/dropdown/vira-dropdown.element.ts
new file mode 100644
index 00000000..819aafab
--- /dev/null
+++ b/packages/vira/src/elements/dropdown/vira-dropdown.element.ts
@@ -0,0 +1,396 @@
+import {PartialAndUndefined} from '@augment-vir/common';
+import {NavController} from 'device-navigation';
+import {
+ classMap,
+ css,
+ defineElementEvent,
+ html,
+ ifDefined,
+ listen,
+ perInstance,
+ renderIf,
+ testId,
+} from 'element-vir';
+import {assertInstanceOf} from 'run-time-assertions';
+import {ViraIconSvg} from '../../icons/icon-svg';
+import {ChevronUp24Icon} from '../../icons/index';
+import {
+ noNativeFormStyles,
+ noUserSelect,
+ viraAnimationDurations,
+ viraDisabledStyles,
+} from '../../styles';
+import {viraBorders} from '../../styles/border';
+import {createFocusStyles, viraFocusCssVars} from '../../styles/focus';
+import {viraFormCssVars} from '../../styles/form-themes';
+import {viraShadows} from '../../styles/shadows';
+import {
+ HidePopUpEvent,
+ NavSelectEvent,
+ PopUpManager,
+ ShowPopUpResult,
+} from '../../util/pop-up-manager';
+import {defineViraElement} from '../define-vira-element';
+import {ViraIcon} from '../vira-icon.element';
+import {
+ assertUniqueIdProps,
+ createNewSelection,
+ filterToSelectedOptions,
+ triggerPopUpState,
+} from './dropdown-helpers';
+import {ViraDropdownOption} from './vira-dropdown-item.element';
+import {ViraDropdownOptions} from './vira-dropdown-options.element';
+
+export const viraDropdownTestIds = {
+ trigger: 'dropdown-trigger',
+ icon: 'dropdown-icon',
+ prefix: 'dropdown-prefix',
+ options: 'dropdown-options',
+};
+
+export const ViraDropdown = defineViraElement<
+ {
+ options: ReadonlyArray>;
+ /** The selected id from the given options. */
+ selected: ReadonlyArray;
+ } & PartialAndUndefined<{
+ /** Text to show if nothing is selected. */
+ placeholder: string;
+ /**
+ * If false, this will behave like a single select dropdown, otherwise you can select
+ * multiple.
+ */
+ isMultiSelect: boolean;
+ icon: ViraIconSvg;
+ selectionPrefix: string;
+ isDisabled: boolean;
+ /** For debugging purposes only. Very bad for actual production code use. */
+ z_debug_forceOpenState: boolean;
+ }>
+>()({
+ tagName: 'vira-dropdown',
+ hostClasses: {
+ 'vira-dropdown-disabled': ({inputs}) => !!inputs.isDisabled,
+ },
+ styles: ({hostClasses}) => css`
+ :host {
+ display: inline-flex;
+ vertical-align: middle;
+ width: 256px;
+ ${viraFocusCssVars['vira-focus-outline-color'].name}: ${viraFormCssVars[
+ 'vira-form-focus-color'
+ ].value};
+ position: relative;
+ max-width: 100%;
+ }
+
+ .dropdown-wrapper {
+ ${noNativeFormStyles};
+ max-width: 100%;
+ align-self: stretch;
+ flex-grow: 1;
+ position: relative;
+ border-radius: ${viraBorders['vira-form-input-radius'].value};
+ transition: border-radius
+ ${viraAnimationDurations['vira-interaction-animation-duration'].value};
+ outline: none;
+ }
+
+ ${createFocusStyles({
+ selector: '.dropdown-wrapper:focus',
+ elementBorderSize: 1,
+ })}
+
+ .selection-display {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .trigger-icon {
+ transform: rotate(0);
+ transition: ${viraAnimationDurations['vira-interaction-animation-duration'].value}
+ linear transform;
+ align-self: flex-start;
+ }
+
+ .trigger-icon-wrapper {
+ flex-grow: 1;
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .dropdown-wrapper.open .trigger-icon {
+ transform: rotate(180deg);
+ }
+
+ .dropdown-wrapper.open:not(.open-upwards) {
+ border-bottom-left-radius: 0;
+ }
+
+ .open-upwards.dropdown-wrapper.open {
+ border-top-left-radius: 0;
+ }
+
+ .dropdown-trigger {
+ border: 1px solid ${viraFormCssVars['vira-form-border-color'].value};
+ height: 100%;
+ width: 100%;
+ transition: inherit;
+ box-sizing: border-box;
+ display: flex;
+ gap: 8px;
+ text-align: left;
+ align-items: center;
+ padding: 3px;
+ padding-left: 10px;
+ ${noUserSelect};
+ border-radius: inherit;
+ background-color: ${viraFormCssVars['vira-form-background-color'].value};
+ color: ${viraFormCssVars['vira-form-foreground-color'].value};
+ }
+
+ .open-upwards ${ViraDropdownOptions} {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ ${viraShadows.menuShadowReversed}
+ }
+
+ ${hostClasses['vira-dropdown-disabled'].selector} {
+ ${viraDisabledStyles}
+ pointer-events: auto;
+ }
+
+ ${hostClasses['vira-dropdown-disabled'].selector} .dropdown-wrapper {
+ pointer-events: none;
+ }
+
+ .pop-up-positioner {
+ position: absolute;
+ pointer-events: none;
+ display: flex;
+ flex-direction: column;
+
+ /* highest possible z-index */
+ z-index: 2147483647;
+ /* space for the caret icon */
+ right: 28px;
+ /* minus the border width */
+ top: calc(100% - 1px);
+ }
+
+ .using-placeholder {
+ opacity: 0.4;
+ }
+
+ .open-upwards .pop-up-positioner {
+ flex-direction: column-reverse;
+ /* minus the border width */
+ bottom: calc(100% - 1px);
+ }
+ `,
+ events: {
+ selectedChange: defineElementEvent(),
+ openChange: defineElementEvent(),
+ },
+ stateInitStatic: {
+ /** `undefined` means the pop up is not currently showing. */
+ showPopUpResult: undefined as ShowPopUpResult | undefined,
+ popUpManager: perInstance(() => new PopUpManager()),
+ navController: undefined as NavController | undefined,
+ },
+ cleanupCallback({state, updateState}) {
+ updateState({showPopUpResult: undefined});
+ state.popUpManager.destroy();
+ },
+ initCallback({state, updateState, host, inputs, dispatch, events}) {
+ state.popUpManager.listen(HidePopUpEvent, () => {
+ updateState({showPopUpResult: undefined});
+ if (!inputs.isDisabled) {
+ const dropdownWrapper = host.shadowRoot.querySelector('.dropdown-wrapper');
+
+ assertInstanceOf(
+ dropdownWrapper,
+ HTMLButtonElement,
+ 'failed to find dropdown wrapper child',
+ );
+
+ dropdownWrapper.focus();
+ }
+ });
+ state.popUpManager.listen(NavSelectEvent, (event) => {
+ const optionIndex = event.detail.x;
+ const option = inputs.options[optionIndex];
+ if (!option) {
+ throw new Error(`Found no dropdown option at index '${optionIndex}'`);
+ }
+ /** Only close upon option selection if the dropdown is not multi select. */
+ if (!inputs.isMultiSelect) {
+ triggerPopUpState(
+ {emitEvent: true, open: false},
+ {
+ dispatch: (openState) => {
+ dispatch(new events.openChange(openState));
+ },
+ host,
+ popUpManager: state.popUpManager,
+ updateState,
+ },
+ );
+ }
+
+ dispatch(
+ new events.selectedChange(
+ createNewSelection(option.id, inputs.selected, !!inputs.isMultiSelect),
+ ),
+ );
+ });
+ updateState({navController: new NavController(host)});
+ },
+ renderCallback({dispatch, events, state, inputs, updateState, host}) {
+ assertUniqueIdProps(inputs.options);
+
+ function triggerPopUp(param: Parameters[0]) {
+ triggerPopUpState(param, {
+ dispatch: (openState) => {
+ dispatch(new events.openChange(openState));
+ },
+ host,
+ popUpManager: state.popUpManager,
+ updateState,
+ });
+ }
+
+ if (inputs.isDisabled) {
+ triggerPopUp({open: false, emitEvent: false});
+ } else if (inputs.z_debug_forceOpenState != undefined) {
+ if (!inputs.z_debug_forceOpenState && state.showPopUpResult) {
+ triggerPopUp({emitEvent: false, open: false});
+ } else if (inputs.z_debug_forceOpenState && !state.showPopUpResult) {
+ triggerPopUp({emitEvent: false, open: true});
+ }
+ }
+
+ const selectedOptions: ReadonlyArray> =
+ filterToSelectedOptions(inputs);
+
+ const leadingIconTemplate = inputs.icon
+ ? html`
+ <${ViraIcon.assign({
+ icon: inputs.icon,
+ })}
+ ${testId(viraDropdownTestIds.icon)}
+ >${ViraIcon}>
+ `
+ : '';
+
+ const positionerStyles = state.showPopUpResult
+ ? state.showPopUpResult.popDown
+ ? /** Dropdown going down position. */
+ css`
+ bottom: -${state.showPopUpResult.positions.diff.bottom}px;
+ `
+ : /** Dropdown going up position. */
+ css`
+ top: -${state.showPopUpResult.positions.diff.top}px;
+ `
+ : undefined;
+
+ function respondToClick() {
+ triggerPopUp({emitEvent: true, open: !state.showPopUpResult});
+ }
+
+ const shouldUsePlaceholder: boolean = !selectedOptions.length;
+
+ const prefixTemplate =
+ inputs.selectionPrefix && !shouldUsePlaceholder
+ ? html`
+
+ ${inputs.selectionPrefix}
+
+ `
+ : '';
+
+ const selectionDisplay: string = shouldUsePlaceholder
+ ? inputs.placeholder || ''
+ : selectedOptions.map((item) => item.label).join(', ');
+
+ return html`
+
+ `;
+ },
+});
diff --git a/packages/vira/src/elements/index.ts b/packages/vira/src/elements/index.ts
index d9aa9d0b..f255ee43 100644
--- a/packages/vira/src/elements/index.ts
+++ b/packages/vira/src/elements/index.ts
@@ -1,6 +1,9 @@
/** This file is automatically updated by update-index-exports.ts */
export * from './define-vira-element';
+export * from './dropdown/vira-dropdown-item.element';
+export * from './dropdown/vira-dropdown-options.element';
+export * from './dropdown/vira-dropdown.element';
export * from './vira-button.element';
export * from './vira-collapsible-wrapper.element';
export * from './vira-icon.element';
diff --git a/packages/vira/src/styles/form-themes.ts b/packages/vira/src/styles/form-themes.ts
new file mode 100644
index 00000000..0226568f
--- /dev/null
+++ b/packages/vira/src/styles/form-themes.ts
@@ -0,0 +1,12 @@
+import {defineCssVars} from 'lit-css-vars';
+import {viraFocusCssVars} from './focus';
+
+export const viraFormCssVars = defineCssVars({
+ 'vira-form-border-color': '#cccccc',
+ 'vira-form-background-color': 'white',
+ 'vira-form-foreground-color': 'black',
+ 'vira-form-focus-color': viraFocusCssVars['vira-focus-outline-color'].value,
+
+ 'vira-form-selection-hover-background-color': '#d2eaff',
+ 'vira-form-selection-hover-foreground-color': 'black',
+});
diff --git a/packages/vira/src/styles/index.ts b/packages/vira/src/styles/index.ts
index ea4c6d65..aca9240c 100644
--- a/packages/vira/src/styles/index.ts
+++ b/packages/vira/src/styles/index.ts
@@ -5,6 +5,7 @@ export * from './color';
export * from './disabled';
export * from './durations';
export * from './focus';
+export * from './form-themes';
export * from './native-styles';
export * from './scrollbar';
export * from './shadows';
diff --git a/packages/vira/src/util/pop-up-manager.ts b/packages/vira/src/util/pop-up-manager.ts
index 64221183..ed1954af 100644
--- a/packages/vira/src/util/pop-up-manager.ts
+++ b/packages/vira/src/util/pop-up-manager.ts
@@ -87,6 +87,7 @@ export class PopUpManager {
supportNavigation: true,
};
private cleanupCallbacks: (() => void)[] = [];
+ private lastRootElement: HTMLElement | undefined;
constructor(options?: Partial | undefined) {
this.options = {...this.options, ...options};
@@ -103,7 +104,14 @@ export class PopUpManager {
}),
listenToGlobal(
'mousedown',
- () => {
+ (event) => {
+ if (
+ this.lastRootElement &&
+ event.composedPath().includes(this.lastRootElement)
+ ) {
+ /** Ignore clicks that came from the pop up host itself. */
+ return;
+ }
this.removePopUp();
},
{passive: true},
@@ -185,6 +193,7 @@ export class PopUpManager {
rootElement: HTMLElement,
options?: Partial | undefined,
): ShowPopUpResult {
+ this.lastRootElement = rootElement;
const currentOptions = {...this.options, ...options};
const container = findOverflowParent(rootElement);
assertInstanceOf(container, HTMLElement);