import { browserDetection } from '@lion/ui/core.js';
import { Unparseable } from '@lion/ui/form-core.js';
import { LionListbox } from '@lion/ui/listbox.js';
import { LocalizeMixin } from '@lion/ui/localize-no-side-effects.js';
import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js';
import { css, html } from 'lit';
import { makeMatchingTextBold, unmakeMatchingTextBold } from './utils/makeMatchingTextBold.js';
import { MatchesOption } from './validators.js';
import { CustomChoiceGroupMixin } from '../../form-core/src/choice-group/CustomChoiceGroupMixin.js';
const matchA11ySpanReverseFns = new WeakMap();
// TODO: make ListboxOverlayMixin that is shared between SelectRich and Combobox
// TODO: extract option matching based on 'typed character cache' and share that logic
// on Listbox or ListNavigationWithActiveDescendantMixin
/**
* @typedef {import('@lion/ui/listbox.js').LionOption} LionOption
* @typedef {import('@lion/ui/listbox.js').LionOptions} LionOptions
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
* @typedef {import('@lion/ui/types/core.js').SlotsMap} SlotsMap
* @typedef {import('@lion/ui/types/form-core.js').ChoiceInputHost} ChoiceInputHost
* @typedef {import('@lion/ui/types/form-core.js').FormControlHost} FormControlHost
* @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay
* @typedef {LionOption & { onFilterUnmatch?:function; onFilterMatch?:function }} OptionWithFilterFn
*/
/**
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
* FormControl
*/
export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMixin(LionListbox))) {
/** @type {any} */
static get properties() {
return {
autocomplete: { type: String, reflect: true },
matchMode: {
type: String,
attribute: 'match-mode',
},
showAllOnEmpty: {
type: Boolean,
attribute: 'show-all-on-empty',
},
requireOptionMatch: {
type: Boolean,
},
allowCustomChoice: {
type: Boolean,
attribute: 'allow-custom-choice',
},
__shouldAutocompleteNextUpdate: Boolean,
};
}
static get styles() {
return [
...super.styles,
css`
.input-group__input {
display: flex;
flex: 1;
}
.input-group__container {
display: flex;
border-bottom: 1px solid;
}
* > ::slotted([slot='input']) {
outline: none;
flex: 1;
box-sizing: border-box;
border: none;
width: 100%;
/* border-bottom: 1px solid; */
}
* > ::slotted([role='listbox']) {
max-height: 200px;
display: block;
overflow: auto;
z-index: 1;
background: white;
}
`,
];
}
static get localizeNamespaces() {
return [
{
'lion-combobox': /** @param {string} locale */ locale => {
switch (locale) {
case 'bg-BG':
case 'bg':
return import('@lion/ui/combobox-translations/bg.js');
case 'cs-CZ':
case 'cs':
return import('@lion/ui/combobox-translations/cs.js');
case 'de-AT':
case 'de-DE':
case 'de':
return import('@lion/ui/combobox-translations/de.js');
case 'en-AU':
case 'en-GB':
case 'en-PH':
case 'en-US':
case 'en':
return import('@lion/ui/combobox-translations/en.js');
case 'es-ES':
case 'es':
return import('@lion/ui/combobox-translations/es.js');
case 'fr-FR':
case 'fr-BE':
case 'fr':
return import('@lion/ui/combobox-translations/fr.js');
case 'hu-HU':
case 'hu':
return import('@lion/ui/combobox-translations/hu.js');
case 'it-IT':
case 'it':
return import('@lion/ui/combobox-translations/it.js');
case 'nl-BE':
case 'nl-NL':
case 'nl':
return import('@lion/ui/combobox-translations/nl.js');
case 'pl-PL':
case 'pl':
return import('@lion/ui/combobox-translations/pl.js');
case 'ro-RO':
case 'ro':
return import('@lion/ui/combobox-translations/ro.js');
case 'ru-RU':
case 'ru':
return import('@lion/ui/combobox-translations/ru.js');
case 'sk-SK':
case 'sk':
return import('@lion/ui/combobox-translations/sk.js');
case 'uk-UA':
case 'uk':
return import('@lion/ui/combobox-translations/uk.js');
case 'zh-CN':
case 'zh':
return import('@lion/ui/combobox-translations/zh.js');
default:
return import('@lion/ui/combobox-translations/en.js');
}
},
},
...super.localizeNamespaces,
];
}
/**
* @override ChoiceGroupMixin
*/
// @ts-ignore
get modelValue() {
const choiceGroupModelValue = super.modelValue;
if (choiceGroupModelValue !== '') {
return choiceGroupModelValue;
}
// Since the FormatMixin can't be applied to a [FormGroup](https://github.com/ing-bank/lion/blob/master/packages/ui/components/form-core/src/form-group/FormGroupMixin.js)
// atm, we treat it in a way analogue to InteractionStateMixin (basically same apis, w/o Mixin applied).
// Hence, modelValue has no reactivity by default and we need to call parser manually here...
return this.parser(this.value);
}
// Duplicating from ChoiceGroupMixin, because you cannot independently inherit/override getter + setter.
// If you override one, gotta override the other, they go in pairs.
/**
* @override ChoiceGroupMixin
*/
set modelValue(value) {
super.modelValue = value;
}
/**
* We define the value getter/setter below as also defined in LionField (via FormatMixin).
* Since FormatMixin is meant for Formgroups/ChoiceGroup it's not applied on Combobox;
* Combobox is somewhat of a hybrid between a ChoiceGroup and LionField, therefore we copy over
* some of the LionField members to align with its interface.
*
* The view value. Will be delegated to `._inputNode.value`
*/
get value() {
return this._inputNode?.value || this.__value || '';
}
/** @param {string} value */
set value(value) {
// if not yet connected to dom can't change the value
if (this._inputNode) {
this._inputNode.value = value;
/** @type {string | undefined} */
this.__value = undefined;
} else {
this.__value = value;
}
}
reset() {
super.reset();
if (!this.multipleChoice) {
// @ts-ignore _initialModelValue comes from ListboxMixin
this.value = this._initialModelValue;
}
this._resetListboxOptions();
}
/**
* @protected
*/
_resetListboxOptions() {
this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, idx) => {
this._unhighlightMatchedOption(option);
if (!this.showAllOnEmpty || !this.opened) {
// eslint-disable-next-line no-param-reassign
option.style.display = 'none';
} else {
// eslint-disable-next-line no-param-reassign
option.style.display = '';
option.setAttribute('aria-posinset', `${idx + 1}`);
option.setAttribute('aria-setsize', `${this.formElements.length}`);
option.removeAttribute('aria-hidden');
}
});
}
/**
* @enhance FormControlMixin - add slot[name=selection-display]
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() {
return html`
`;
}
/**
* @enhance FormControlMixin - add overlay
* @protected
*/
_groupTwoTemplate() {
return html` ${super._groupTwoTemplate()} ${this._overlayListboxTemplate()}`;
}
/**
* @type {SlotsMap}
*/
get slots() {
return {
...super.slots,
/**
* The interactive element that can receive focus
*/
input: () => {
if (this._ariaVersion === '1.1') {
/**
* According to the 1.1 specs, the input should be either wrapped in an element with
* [role=combobox], or element with [role=combobox] should have [aria-owns=input-id].
* For best cross browser compatibility, we choose the first option.
*/
const combobox = document.createElement('div');
const textbox = document.createElement('input');
// Reset textbox styles so that it 'merges' with parent [role=combobox]
// that is styled by Subclassers
textbox.style.cssText = `
border: none;
outline: none;
width: 100%;
height: 100%;
font: inherit;
background: inherit;
color: inherit;
border-radius: inherit;
box-sizing: border-box;
padding: 0;`;
combobox.appendChild(textbox);
return combobox;
}
// ._ariaVersion === '1.0'
/**
* For browsers not supporting aria 1.1 spec, we implement the 1.0 spec.
* That means we have one (input) element that has [role=combobox]
*/
return document.createElement('input');
},
/**
* As opposed to our parent (LionListbox), the end user doesn't interact with the
* element that has [role=listbox] (in a combobox, it has no tabindex), but with
* the text box () element.
*/
listbox: super.slots.input,
};
}
/**
* Wrapper with combobox role for the text input that the end user controls the listbox with.
* @type {HTMLElement}
* @protected
*/
get _comboboxNode() {
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
}
/**
* @type {SelectionDisplay | null}
* @protected
*/
get _selectionDisplayNode() {
return this.querySelector('[slot="selection-display"]');
}
/**
* @configure FormControlMixin
* Will tell FormControlMixin that a11y wrt labels / descriptions / feedback
* should be applied here.
* @protected
*/
get _inputNode() {
if (this._ariaVersion === '1.1' && this._comboboxNode) {
return /** @type {HTMLInputElement} */ (
this._comboboxNode.querySelector('input') || this._comboboxNode
);
}
return /** @type {HTMLInputElement} */ (this._comboboxNode);
}
/**
* @configure OverlayMixin
* @protected
*/
get _overlayContentNode() {
return this._listboxNode;
}
/**
* @configure OverlayMixin
* @protected
*/
get _overlayReferenceNode() {
return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('.input-group__container');
}
/**
* @configure OverlayMixin
* @protected
*/
get _overlayInvokerNode() {
return this._inputNode;
}
/**
* @configure ListboxMixin
* @protected
*/
get _listboxNode() {
return /** @type {LionOptions} */ (
(this._overlayCtrl && this._overlayCtrl.contentNode) ||
Array.from(this.children).find(child => child.slot === 'listbox')
);
}
/**
* @configure ListboxMixin
* @protected
*/
get _activeDescendantOwnerNode() {
return this._inputNode;
}
/**
* @returns {boolean}
*/
get requireOptionMatch() {
return !this.allowCustomChoice;
}
/**
* @param {boolean} value
*/
set requireOptionMatch(value) {
this.allowCustomChoice = !value;
}
constructor() {
super();
/**
* When "list", will filter listbox suggestions based on textbox value.
* When "both", an inline completion string will be added to the textbox as well.
* @type {'none'|'list'|'inline'|'both'}
*/
this.autocomplete = 'both';
/**
* When typing in the textbox, will by default be set on 'begin',
* only matching the beginning part in suggestion list.
* => 'a' will match 'apple' from ['apple', 'pear', 'citrus'].
* When set to 'all', will match middle of the word as well
* => 'a' will match 'apple' and 'pear'
* @type {'begin'|'all'}
*/
this.matchMode = 'all';
/**
* When true, the listbox is open and textbox goes from a value to empty, all options are shown.
* By default, the listbox closes on empty, similar to wai-aria example and