Skip to content

Commit

Permalink
feat(searchable-select): highlight matched search text (VIV-2045) (#1914
Browse files Browse the repository at this point in the history
)

Highlight matched search text
  • Loading branch information
RichardHelm authored Sep 30, 2024
1 parent 08869ba commit d3353b3
Show file tree
Hide file tree
Showing 10 changed files with 98 additions and 10 deletions.
13 changes: 9 additions & 4 deletions libs/components/src/lib/option/option.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,6 @@
pointer-events: none;
}

.text {
font: var(#{constants.$vvd-typography-base});
}

:host([aria-checked='true']) & {
#{focus-variables.$focus-stroke-color}: var(
#{constants.$vvd-color-neutral-500}
Expand All @@ -78,6 +74,15 @@
}
}

.text {
font: var(#{constants.$vvd-typography-base});
}

.match {
color: var(#{constants.$vvd-color-cta-600});
font: var(#{constants.$vvd-typography-base-bold});
}

slot[name='icon'] {
font-size: 20px;
line-height: 1;
Expand Down
23 changes: 23 additions & 0 deletions libs/components/src/lib/option/option.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,29 @@ describe('vwc-option', () => {
});
});

describe('_matchedRange', () => {
const getText = () => element.shadowRoot!.querySelector('.text')!;
const getMatch = () => element.shadowRoot!.querySelector('.match');

beforeEach(async () => {
element.text = 'Option text';
await elementUpdated(element);
});

it('should not mark any text as matched if not set', async () => {
expect(getText().textContent!.trim()).toBe('Option text');
expect(getMatch()).toBe(null);
});

it('should mark the provided range as matched', async () => {
element._matchedRange = { from: 1, to: 4 };
await elementUpdated(element);

expect(getText().textContent!.trim()).toBe('Option text');
expect(getMatch()!.textContent).toBe('pti');
});
});

describe('a11y', () => {
it('should pass html a11y test', async () => {
element = (await fixture(
Expand Down
17 changes: 16 additions & 1 deletion libs/components/src/lib/option/option.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,22 @@ export const ListboxOptionTemplate: (
>
<div class="${getClasses}">
${(x) => affixIconTemplate(x.icon, IconWrapper.Slot)}
${when((x) => x.text, html`<div class="text">${(x) => x.text}</div>`)}
${when(
(x) => x.text,
html<ListboxOption>`<div class="text">
${when(
(x) => x._matchedRange,
html<ListboxOption>`${(x) =>
x.text.slice(0, x._matchedRangeSafe.from)}<span class="match"
>${(x) =>
x.text.slice(
x._matchedRangeSafe.from,
x._matchedRangeSafe.to
)}</span
>`
)}${(x) => x.text.slice(x._matchedRangeSafe.to)}
</div>`
)}
${when(
(x) => x._displayCheckmark && x.selected,
html`<${iconTag} class="checkmark" name="check-line"></${iconTag}>`
Expand Down
14 changes: 14 additions & 0 deletions libs/components/src/lib/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ export class ListboxOption extends FoundationListboxOption {
* @internal
*/
@observable _displayCheckmark = false;

/**
* Range of text that should be highlighted as matching a search query.
* From is inclusive, to is exclusive.
* @internal
*/
@observable _matchedRange: { from: number; to: number } | null = null;

/**
* @internal
*/
get _matchedRangeSafe() {
return this._matchedRange ?? { from: 0, to: 0 };
}
}

export interface ListboxOption extends AffixIconWithTrailing {}
Expand Down
3 changes: 3 additions & 0 deletions libs/components/src/lib/option/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test('should show the component', async ({ page }: { page: Page }) => {
</vwc-option>
<vwc-option id="checkmark" text="Option" selected></vwc-option>
<vwc-option id="highlighted" text="Option"></vwc-option>
<vwc-option id="match" text="Option"></vwc-option>
</div>
`,
});
Expand All @@ -43,6 +44,8 @@ test('should show the component', async ({ page }: { page: Page }) => {
checkmark._displayCheckmark = true;
const highlighted = document.getElementById('highlighted') as ListboxOption;
highlighted._highlighted = true;
const match = document.getElementById('match') as ListboxOption;
match._matchedRange = { from: 1, to: 4 };
});

await page.waitForLoadState('networkidle');
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,24 @@ describe('vwc-searchable-select', () => {
expect(getVisibleOptions()).toEqual(['Apple', 'Banana']);
});

it('should not highlight a matched range when no text is entered', async function () {
focusInput();
await elementUpdated(element);

expect(getOption('Apple')._matchedRange).toEqual(null);
});

it('should highlight matched text of options', async function () {
focusInput();
await elementUpdated(element);

typeInput('a');
await elementUpdated(element);

expect(getOption('Apple')._matchedRange).toEqual({ from: 0, to: 1 });
expect(getOption('Banana')._matchedRange).toEqual({ from: 1, to: 2 });
});

it('should display an empty state if options are available', async function () {
await setUpFixture(`<${COMPONENT_TAG}></${COMPONENT_TAG}>`);
focusInput();
Expand Down
20 changes: 15 additions & 5 deletions libs/components/src/lib/searchable-select/searchable-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,11 +469,21 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
const newFilteredOptions = [];

for (const option of this._slottedOptions ?? []) {
const matches =
this.#suppressFilter ||
option.text.toLowerCase().includes(this._inputValue.toLowerCase());

option.hidden = !matches;
if (this.#suppressFilter || this._inputValue === '') {
option.hidden = false;
option._matchedRange = null;
} else {
const matchIndex = option.text
.toLowerCase()
.indexOf(this._inputValue.toLowerCase());
const matchedRange =
matchIndex === -1
? null
: { from: matchIndex, to: matchIndex + this._inputValue.length };

option.hidden = !matchedRange;
option._matchedRange = matchedRange;
}

if (!option.hidden) {
newFilteredOptions.push(option);
Expand Down

0 comments on commit d3353b3

Please sign in to comment.