fix(combobox): make the first occurrence of a string highlighted, instead of the last

This commit is contained in:
gvangeest 2023-02-13 16:42:47 +01:00 committed by gerjanvangeest
parent 974e9ea4ce
commit 1c18057cee
7 changed files with 382 additions and 81 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[combobox] make the first occurrence of a string highlighted, instead of the last.

View file

@ -14,7 +14,7 @@ availability of the popup.
```js script ```js script
import { LitElement, html, repeat } from '@mdjs/mdjs-preview'; 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 { LionCombobox } from '@lion/ui/combobox.js';
import '@lion/ui/define/lion-combobox.js'; import '@lion/ui/define/lion-combobox.js';
import '@lion/ui/define/lion-option.js'; import '@lion/ui/define/lion-option.js';
@ -328,20 +328,45 @@ customElements.define('demo-server-side', DemoServerSide);
export const serverSideCompletion = () => html`<demo-server-side></demo-server-side>`; export const serverSideCompletion = () => html`<demo-server-side></demo-server-side>`;
``` ```
## 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 ```js preview-story
class ComplexObjectCombobox extends LionCombobox { class ComplexObjectCombobox extends LionCombobox {
/** /**
* Override how the input text is filled * @overridable
* @param {LionOption} option * @param {LionOption & {__originalInnerHTML?:string}} option
* @returns {string} * @param {string} matchingString
* @protected
*/ */
// eslint-disable-next-line class-methods-use-this _onFilterMatch(option, matchingString) {
_getTextboxValueFromOption(option) { Array.from(option.children).forEach(child => {
return option.label; 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)}`); console.log(`event.target.modelValue: ${JSON.stringify(event.target.modelValue)}`);
}; };
const listboxDataObject = listboxData.map(e => {
return { label: e, name: e };
});
export const complexObjectChoiceValue = () => html` <complex-object-combobox export const complexObjectChoiceValue = () => html` <complex-object-combobox
name="combo" name="combo"
label="Display only the label once selected" label="Display only the label once selected"
@model-value-changed="${onModelValueChanged}" @model-value-changed="${onModelValueChanged}"
> >
${lazyRender( ${lazyRender(
listboxDataObject.map( listboxComplexData.map(
entry => entry =>
html` html`
<lion-option .choiceValue="${entry}" .label="${entry.label}">${entry.label}</lion-option> <lion-option .choiceValue="${entry.label}">
<div data-key>${entry.label}</div>
<small>${entry.description}</small>
</lion-option>
`, `,
), ),
)} )}

View file

@ -63,3 +63,69 @@ export const listboxData = [
'Yam', 'Yam',
'Zucchini', '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' },
];

View file

@ -1,62 +1,11 @@
import { html, css } from 'lit';
import { browserDetection } from '@lion/ui/core.js'; import { browserDetection } from '@lion/ui/core.js';
import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js';
import { LionListbox } from '@lion/ui/listbox.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(); 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: make ListboxOverlayMixin that is shared between SelectRich and Combobox
// TODO: extract option matching based on 'typed character cache' and share that logic // TODO: extract option matching based on 'typed character cache' and share that logic
// on Listbox or ListNavigationWithActiveDescendantMixin // on Listbox or ListNavigationWithActiveDescendantMixin
@ -304,7 +253,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @type {'begin'|'all'} * @type {'begin'|'all'}
*/ */
this.matchMode = 'all'; this.matchMode = 'all';
/** /**
* When true, the listbox is open and textbox goes from a value to empty, all options are shown. * 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 <datalist> * By default, the listbox closes on empty, similar to wai-aria example and <datalist>
@ -642,14 +590,27 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @param {string} matchingString * @param {string} matchingString
* @protected * @protected
*/ */
// eslint-disable-next-line class-methods-use-this
_onFilterMatch(option, matchingString) { _onFilterMatch(option, matchingString) {
makeMatchingTextBold(option, matchingString, option); this._highlightMatchedOption(option, matchingString);
// Alternatively, an extension can add an animation here
option.style.display = '';
}
/**
* @overridable
* @param {Element} option
* @param {string} matchingString
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_highlightMatchedOption(option, matchingString) {
makeMatchingTextBold(option, matchingString);
// For Safari, we need to add a label to the element // For Safari, we need to add a label to the element
if (option.textContent) { if (option.textContent) {
const a11ySpan = document.createElement('span'); const a11ySpan = document.createElement('span');
a11ySpan.setAttribute('aria-label', option.textContent); a11ySpan.setAttribute('aria-label', option.textContent.replace(/\s+/g, ' '));
Array.from(option.childNodes).forEach(childNode => { Array.from(option.childNodes).forEach(childNode => {
a11ySpan.appendChild(childNode); a11ySpan.appendChild(childNode);
}); });
@ -664,8 +625,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
} }
}); });
} }
// Alternatively, an extension can add an animation here
option.style.display = '';
} }
/** /**
@ -677,14 +636,24 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
*/ */
// eslint-disable-next-line no-unused-vars, class-methods-use-this // eslint-disable-next-line no-unused-vars, class-methods-use-this
_onFilterUnmatch(option, curValue, prevValue) { _onFilterUnmatch(option, curValue, prevValue) {
if (matchReverseFns.has(option)) { this._unhighlightMatchedOption(option);
matchReverseFns.get(option)();
} // Alternatively, an extension can add an animation here
option.style.display = 'none';
}
/**
* @overridable
* @param {Element} option
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_unhighlightMatchedOption(option) {
unmakeMatchingTextBold(option);
if (matchA11ySpanReverseFns.has(option)) { if (matchA11ySpanReverseFns.has(option)) {
matchA11ySpanReverseFns.get(option)(); matchA11ySpanReverseFns.get(option)();
} }
// Alternatively, an extension can add an animation here
option.style.display = 'none';
} }
/* eslint-enable no-param-reassign */ /* eslint-enable no-param-reassign */

View file

@ -0,0 +1,68 @@
const matchReverseFns = new WeakMap();
/**
* @param {Node} root
* @param {string} matchingString
*/
export function makeMatchingTextBold(root, matchingString) {
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(root, () => {
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);
}
});
}
/**
* @param {Node} root
*/
export function unmakeMatchingTextBold(root) {
if (matchReverseFns.has(root)) {
matchReverseFns.get(root)();
}
Array.from(root.childNodes).forEach(childNode => {
if (childNode.nodeName === '#text') {
if (matchReverseFns.has(childNode)) {
matchReverseFns.get(childNode)();
}
} else {
unmakeMatchingTextBold(childNode);
}
});
}

View file

@ -13,6 +13,7 @@ import { LionCombobox } from '@lion/ui/combobox.js';
* @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay * @typedef {import('../types/SelectionDisplay.js').SelectionDisplay} SelectionDisplay
* @typedef {import('../../listbox/types/ListboxMixinTypes.js').ListboxHost} ListboxHost * @typedef {import('../../listbox/types/ListboxMixinTypes.js').ListboxHost} ListboxHost
* @typedef {import('../../form-core/types/FormControlMixinTypes.js').FormControlHost} FormControlHost * @typedef {import('../../form-core/types/FormControlMixinTypes.js').FormControlHost} FormControlHost
* @typedef {import('@lion/ui/listbox.js').LionOption} LionOption
*/ */
/** /**
@ -1632,7 +1633,7 @@ describe('lion-combobox', () => {
}); });
}); });
it('highlights matching options', async () => { it('highlights first occcurence of matching option', async () => {
const el = /** @type {LionCombobox} */ ( const el = /** @type {LionCombobox} */ (
await fixture(html` await fixture(html`
<lion-combobox name="foo" match-mode="all"> <lion-combobox name="foo" match-mode="all">
@ -1645,6 +1646,16 @@ describe('lion-combobox', () => {
); );
const options = el.formElements; const options = el.formElements;
mimicUserTyping(/** @type {LionCombobox} */ (el), 'c');
await el.updateComplete;
expect(options[0]).lightDom.to.equal(`<span aria-label="Artichoke">Arti<b>c</b>hoke</span>`);
expect(options[1]).lightDom.to.equal(`<span aria-label="Chard"><b>C</b>hard</span>`);
expect(options[2]).lightDom.to.equal(`<span aria-label="Chicory"><b>C</b>hicory</span>`);
expect(options[3]).lightDom.to.equal(
`<span aria-label="Victoria Plum">Vi<b>c</b>toria Plum</span>`,
);
mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch'); mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
await el.updateComplete; await el.updateComplete;
@ -1662,6 +1673,48 @@ describe('lion-combobox', () => {
expect(options[3]).lightDom.to.equal(`Victoria Plum`); expect(options[3]).lightDom.to.equal(`Victoria Plum`);
}); });
it('highlights matching complex options', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" match-mode="all">
<lion-option .choiceValue="${'Artichoke'}">
<div>Artichoke</div>
<small>Cardoon</small>
</lion-option>
<lion-option .choiceValue="${'Chard'}">
<div>Chard</div>
<small>Beet</small>
</lion-option>
<lion-option .choiceValue="${'Chicory'}">
<div>Chicory</div>
<small>Chicory</small>
</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">
<div>Victoria Plum</div>
<small>Prunus domestica</small>
</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
await el.updateComplete;
expect(options[0]).lightDom.to.equal(
`<span aria-label=" Artichoke Cardoon "><div>Arti<b>ch</b>oke</div><small>Cardoon</small>`,
);
expect(options[1]).lightDom.to.equal(
`<span aria-label=" Chard Beet "><div><b>Ch</b>ard</div><small>Beet</small>`,
);
expect(options[2]).lightDom.to.equal(
`<span aria-label=" Chicory Chicory "><div><b>Ch</b>icory</div><small><b>Ch</b>icory</small>`,
);
expect(options[3]).lightDom.to.equal(
`<div>Victoria Plum</div><small>Prunus domestica</small>`,
);
});
it('synchronizes textbox when autocomplete is "inline" or "both"', async () => { it('synchronizes textbox when autocomplete is "inline" or "both"', async () => {
const el = /** @type {LionCombobox} */ ( const el = /** @type {LionCombobox} */ (
await fixture(html` await fixture(html`
@ -1999,6 +2052,98 @@ describe('lion-combobox', () => {
await performChecks(); await performChecks();
}); });
}); });
it('allows to override "_onFilterMatch" and "_onFilterUmatch"', async () => {
class X extends LionCombobox {
/**
* @overridable
* @param {LionOption & {__originalInnerHTML?:string}} option
* @param {string} matchingString
* @protected
*/
_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
*/
// eslint-disable-next-line no-unused-vars
_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';
}
}
const tagName = defineCE(X);
const tag = unsafeStatic(tagName);
const el = /** @type {LionCombobox} */ (
await fixture(html`
<${tag} name="foo" autocomplete="both">
<lion-option .choiceValue="${'Artichoke'}">
<div data-key>Artichoke</div>
<small>Cardoon</small>
</lion-option>
<lion-option .choiceValue="${'Chard'}">
<div data-key>Chard</div>
<small>Beet</small>
</lion-option>
<lion-option .choiceValue="${'Chicory'}">
<div data-key>Chicory</div>
<small>Chicory</small>
</lion-option>
<lion-option .choiceValue="${'Victoria Plum'}">
<div data-key>Victoria Plum</div>
<small>Prunus domestica</small>
</lion-option>
</${tag}>
`)
);
const options = el.formElements;
mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
await el.updateComplete;
expect(options[0]).lightDom.to.equal(
`<div data-key><span aria-label="Artichoke">Arti<b>ch</b>oke</span></div><small>Cardoon</small>`,
);
expect(options[1]).lightDom.to.equal(
`<div data-key><span aria-label="Chard"><b>Ch</b>ard</div></span><small>Beet</small>`,
);
expect(options[2]).lightDom.to.equal(
`<div data-key><span aria-label="Chicory"><b>Ch</b>icory</span></div><small>Chicory</small>`,
);
expect(options[3]).lightDom.to.equal(
`<div data-key>Victoria Plum</div><small>Prunus domestica</small>`,
);
mimicUserTyping(/** @type {LionCombobox} */ (el), 'D');
await el.updateComplete;
expect(options[0]).lightDom.to.equal(`<div data-key>Artichoke</div><small>Cardoon</small>`);
expect(options[1]).lightDom.to.equal(
`<div data-key><span aria-label="Chard">Char<b>d</b></div></span><small>Beet</small>`,
);
expect(options[2]).lightDom.to.equal(`<div data-key>Chicory</div><small>Chicory</small>`);
expect(options[3]).lightDom.to.equal(
`<div data-key>Victoria Plum</div><small>Prunus domestica</small>`,
);
});
}); });
describe('Active index behavior', () => { describe('Active index behavior', () => {
@ -2278,6 +2423,26 @@ describe('lion-combobox', () => {
expect(labelledElement).to.not.be.null; expect(labelledElement).to.not.be.null;
expect(labelledElement.innerText).to.equal('Artichoke'); expect(labelledElement.innerText).to.equal('Artichoke');
}); });
it('adds aria-label to highlighted complex options', async () => {
const el = /** @type {LionCombobox} */ (
await fixture(html`
<lion-combobox name="foo" match-mode="all">
<lion-option .choiceValue="${'Artichoke'}">
<div>Artichoke</div>
<small>Cardoon</small>
</lion-option>
</lion-combobox>
`)
);
const options = el.formElements;
mimicUserTyping(/** @type {LionCombobox} */ (el), 'choke');
await el.updateComplete;
const labelledElement = options[0].querySelector('span[aria-label=" Artichoke Cardoon "]');
expect(labelledElement).to.not.be.null;
expect(labelledElement.innerText).to.equal('Artichoke\nCardoon');
});
}); });
}); });

View file

@ -1 +1,5 @@
export { LionCombobox } from '../components/combobox/src/LionCombobox.js'; export { LionCombobox } from '../components/combobox/src/LionCombobox.js';
export {
makeMatchingTextBold,
unmakeMatchingTextBold,
} from '../components/combobox/src/utils/makeMatchingTextBold.js';