diff --git a/.changeset/fast-taxis-kneel.md b/.changeset/fast-taxis-kneel.md
new file mode 100644
index 000000000..be652d700
--- /dev/null
+++ b/.changeset/fast-taxis-kneel.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': patch
+---
+
+[combobox] make the first occurrence of a string highlighted, instead of the last.
diff --git a/docs/components/combobox/use-cases.md b/docs/components/combobox/use-cases.md
index 870eb507f..9dbc6a548 100644
--- a/docs/components/combobox/use-cases.md
+++ b/docs/components/combobox/use-cases.md
@@ -14,7 +14,7 @@ availability of the popup.
```js script
import { LitElement, html, repeat } from '@mdjs/mdjs-preview';
-import { listboxData } from '../listbox/src/listboxData.js';
+import { listboxData, listboxComplexData } from '../listbox/src/listboxData.js';
import { LionCombobox } from '@lion/ui/combobox.js';
import '@lion/ui/define/lion-combobox.js';
import '@lion/ui/define/lion-option.js';
@@ -328,20 +328,45 @@ customElements.define('demo-server-side', DemoServerSide);
export const serverSideCompletion = () => html``;
```
-## Set complex object in choiceValue
+## Complex options
-A common use case is to set a complex object in the `choiceValue` property. By default, `LionCombobox` will display in the text input the `modelValue`. By overriding the `_getTextboxValueFromOption` method, you can choose which value will be used for the input value.
+For performance reasons a complex object in the choiceValue property is unwanted. But it is possible to create more complex options.
+
+To highlight the correct elements of the option, each element should be tagged with a `data-key` attribute. Which will be used in the `_onFilterMatch` and `_onFilterUnmatch` functions.
```js preview-story
class ComplexObjectCombobox extends LionCombobox {
/**
- * Override how the input text is filled
- * @param {LionOption} option
- * @returns {string}
+ * @overridable
+ * @param {LionOption & {__originalInnerHTML?:string}} option
+ * @param {string} matchingString
+ * @protected
*/
- // eslint-disable-next-line class-methods-use-this
- _getTextboxValueFromOption(option) {
- return option.label;
+ _onFilterMatch(option, matchingString) {
+ Array.from(option.children).forEach(child => {
+ if (child.hasAttribute('data-key')) {
+ this._highlightMatchedOption(child, matchingString);
+ }
+ });
+ // Alternatively, an extension can add an animation here
+ option.style.display = '';
+ }
+
+ /**
+ * @overridable
+ * @param {LionOption & {__originalInnerHTML?:string}} option
+ * @param {string} [curValue]
+ * @param {string} [prevValue]
+ * @protected
+ */
+ _onFilterUnmatch(option, curValue, prevValue) {
+ Array.from(option.children).forEach(child => {
+ if (child.hasAttribute('data-key')) {
+ this._unhighlightMatchedOption(child);
+ }
+ });
+ // Alternatively, an extension can add an animation here
+ option.style.display = 'none';
}
}
@@ -351,20 +376,19 @@ const onModelValueChanged = event => {
console.log(`event.target.modelValue: ${JSON.stringify(event.target.modelValue)}`);
};
-const listboxDataObject = listboxData.map(e => {
- return { label: e, name: e };
-});
-
export const complexObjectChoiceValue = () => html`
${lazyRender(
- listboxDataObject.map(
+ listboxComplexData.map(
entry =>
html`
- ${entry.label}
+
+ ${entry.label}
+ ${entry.description}
+
`,
),
)}
diff --git a/docs/components/listbox/src/listboxData.js b/docs/components/listbox/src/listboxData.js
index 60f89ff59..af3d42b10 100644
--- a/docs/components/listbox/src/listboxData.js
+++ b/docs/components/listbox/src/listboxData.js
@@ -63,3 +63,69 @@ export const listboxData = [
'Yam',
'Zucchini',
];
+
+export const listboxComplexData = [
+ { label: 'Apple', description: 'Rosaceae' },
+ { label: 'Artichoke', description: 'Cardoon' },
+ { label: 'Asparagus', description: 'Liliopsida' },
+ { label: 'Banana', description: 'Bananas' },
+ { label: 'Beets', description: 'Beet' },
+ { label: 'Bell pepper', description: 'Solanaceae' },
+ { label: 'Broccoli', description: 'Wild cabbage' },
+ { label: 'Brussels sprout', description: 'Wild cabbage' },
+ { label: 'Cabbage', description: 'Wild cabbage' },
+ { label: 'Carrot', description: 'Apiales' },
+ { label: 'Cauliflower', description: 'Wild cabbage' },
+ { label: 'Celery', description: 'Apium' },
+ { label: 'Chard', description: 'Beet' },
+ { label: 'Chicory', description: 'Chicory' },
+ { label: 'Corn', description: 'Corn' },
+ { label: 'Cucumber', description: 'Cucumis' },
+ { label: 'Daikon', description: 'Radishes' },
+ { label: 'Date', description: 'Arecaceae' },
+ { label: 'Edamame', description: 'Wild bean' },
+ { label: 'Eggplant', description: 'Nightshade' },
+ { label: 'Elderberry', description: 'Moschatel' },
+ { label: 'Fennel', description: 'Fennels' },
+ { label: 'Fig', description: 'Fig trees' },
+ { label: 'Garlic', description: 'Allium' },
+ { label: 'Grape', description: 'Vitis vinifera' },
+ { label: 'Honeydew melon', description: 'Citrullus' },
+ { label: 'Iceberg lettuce', description: 'Lactuca' },
+ { label: 'Jerusalem artichoke', description: 'Sunflowers' },
+ { label: 'Kale', description: 'Brassicaceae' },
+ { label: 'Kiwi', description: 'Ratites' },
+ { label: 'Leek', description: 'Onion' },
+ { label: 'Lemon', description: 'Citrus' },
+ { label: 'Mango', description: 'Mangifera' },
+ { label: 'Mangosteen', description: 'Saptrees' },
+ { label: 'Melon', description: 'Citrullus' },
+ { label: 'Mushroom', description: 'Eumycota' },
+ { label: 'Nectarine', description: 'Citrus' },
+ { label: 'Okra', description: 'Mallows' },
+ { label: 'Olive', description: 'Olives' },
+ { label: 'Onion', description: 'Onion' },
+ { label: 'Orange', description: 'Citrus' },
+ { label: 'Parship', description: 'Umbellifers' },
+ { label: 'Pea', description: 'Peas' },
+ { label: 'Pear', description: 'Malinae' },
+ { label: 'Pineapple', description: 'Pineapples' },
+ { label: 'Potato', description: 'Nightshade' },
+ { label: 'Pumpkin', description: 'Cucurbitaceae' },
+ { label: 'Quince', description: 'Cydonia' },
+ { label: 'Radish', description: 'Radishes' },
+ { label: 'Rhubarb', description: 'Rhubarb' },
+ { label: 'Shallot', description: 'Onion' },
+ { label: 'Spinach', description: 'Spinacia' },
+ { label: 'Squash', description: 'Cucurbiteae' },
+ { label: 'Strawberry', description: 'Fragaria' },
+ { label: 'Sweet potato', description: 'Ipomoea' },
+ { label: 'Tomato', description: 'Nightshade' },
+ { label: 'Turnip', description: 'Field mustard' },
+ { label: 'Ugli fruit', description: 'Citrus reticulata' },
+ { label: 'Victoria plum', description: 'Prunus domestica' },
+ { label: 'Watercress', description: 'Nasturtium' },
+ { label: 'Watermelon', description: 'Citrullus' },
+ { label: 'Yam', description: 'Dioscorea' },
+ { label: 'Zucchini', description: 'Cucurbitaceae' },
+];
diff --git a/packages/ui/components/combobox/src/LionCombobox.js b/packages/ui/components/combobox/src/LionCombobox.js
index 8f8f436fa..6d7af9267 100644
--- a/packages/ui/components/combobox/src/LionCombobox.js
+++ b/packages/ui/components/combobox/src/LionCombobox.js
@@ -1,62 +1,11 @@
-import { html, css } from 'lit';
import { browserDetection } from '@lion/ui/core.js';
-import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js';
import { LionListbox } from '@lion/ui/listbox.js';
+import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js';
+import { css, html } from 'lit';
+import { makeMatchingTextBold, unmakeMatchingTextBold } from './utils/makeMatchingTextBold.js';
-const matchReverseFns = new WeakMap();
const matchA11ySpanReverseFns = new WeakMap();
-/**
- * @param {Node} root
- * @param {string} matchingString
- * @param {Node} option
- */
-function makeMatchingTextBold(root, matchingString, option) {
- Array.from(root.childNodes).forEach(childNode => {
- if (childNode.nodeName === '#text') {
- // check for match based on nodeValue
-
- const re = new RegExp(`^(.*)(${matchingString})(.*)$`, 'i');
- // @ts-ignore
- const match = childNode.nodeValue.match(re);
-
- if (match) {
- // 1. textContent before match
-
- const textBefore = document.createTextNode(match[1]);
- root.appendChild(textBefore);
-
- // 2. matched part
- const boldElement = document.createElement('b');
- // eslint-disable-next-line prefer-destructuring
- boldElement.textContent = match[2];
- root.appendChild(boldElement);
-
- // 3. textContent after match
-
- const textAfter = document.createTextNode(match[3]);
- root.appendChild(textAfter);
- root.removeChild(childNode);
-
- matchReverseFns.set(option, () => {
- root.appendChild(childNode);
- if (root.contains(textBefore) && textBefore.parentNode !== null) {
- textBefore.parentNode.removeChild(textBefore);
- }
- if (root.contains(boldElement) && boldElement.parentNode !== null) {
- boldElement.parentNode.removeChild(boldElement);
- }
- if (root.contains(textAfter) && textAfter.parentNode !== null) {
- textAfter.parentNode.removeChild(textAfter);
- }
- });
- }
- } else {
- makeMatchingTextBold(childNode, matchingString, option);
- }
- });
-}
-
// 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
@@ -304,7 +253,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @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