Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .storybook/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import { polyfill } from '@web/dev-server-polyfill';
export default /** @type {import('@web/dev-server').DevServerConfig} */ ({
...baseConfig,
open: '/',
plugins: [storybookPlugin(
plugins: [
storybookPlugin({ type: 'web-components' }),
polyfill({
scopedCustomElementRegistry: true,
}),{ type: 'web-components' }), ...baseConfig.plugins],
});
}),
...baseConfig.plugins
],
});
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ This element was meant to be used only for plugins in this organization. If it s
| `height` | | `number` | `72` | Height of each list item | |
| `filterable` | | `boolean` | `false` | Whether list items can be filtered on \`headline\` and \`supportingText\` | FilterListBase |
| `searchhelper` | | `string` | `'search'` | Placeholder for search input field | FilterListBase |
| `searchRegex` | | `RegExp` | `/.*/i` | | FilterListBase |
| `searchValue` | | `string` | | Current search filter value. Updates search regex when changed. | FilterListBase |

<details><summary>Private API</summary>

#### Fields

| Name | Privacy | Type | Default | Description | Inherited From |
| ------------- | --------- | ------------------------ | ------- | ----------- | -------------- |
| `searchRegex` | protected | `RegExp` | `/.*/i` | | FilterListBase |
| `searchInput` | protected | `TextField \| undefined` | | | FilterListBase |
| Name | Privacy | Type | Default | Description | Inherited From |
| -------------- | --------- | ------------------------ | ------- | ----------- | -------------- |
| `searchInput` | protected | `TextField \| undefined` | | | FilterListBase |
| `_searchValue` | protected | `string` | `''` | | FilterListBase |

#### Methods

Expand Down Expand Up @@ -107,15 +109,17 @@ This element was meant to be used only for plugins in this organization. If it s
| `selectedElements` | | `Element[]` | | | |
| `filterable` | | `boolean` | `false` | Whether list items can be filtered on \`headline\` and \`supportingText\` | FilterListBase |
| `searchhelper` | | `string` | `'search'` | Placeholder for search input field | FilterListBase |
| `searchRegex` | | `RegExp` | `/.*/i` | | FilterListBase |
| `searchValue` | | `string` | | Current search filter value. Updates search regex when changed. | FilterListBase |

<details><summary>Private API</summary>

#### Fields

| Name | Privacy | Type | Default | Description | Inherited From |
| ------------- | --------- | ------------------------ | ------- | ----------- | -------------- |
| `searchRegex` | protected | `RegExp` | `/.*/i` | | FilterListBase |
| `searchInput` | protected | `TextField \| undefined` | | | FilterListBase |
| Name | Privacy | Type | Default | Description | Inherited From |
| -------------- | --------- | ------------------------ | ------- | ----------- | -------------- |
| `searchInput` | protected | `TextField \| undefined` | | | FilterListBase |
| `_searchValue` | protected | `string` | `''` | | FilterListBase |

#### Methods

Expand Down Expand Up @@ -166,15 +170,17 @@ This element was meant to be used only for plugins in this organization. If it s
| -------------- | ------- | --------- | ---------- | ------------------------------------------------------------------------- | -------------- |
| `filterable` | | `boolean` | `false` | Whether list items can be filtered on \`headline\` and \`supportingText\` | |
| `searchhelper` | | `string` | `'search'` | Placeholder for search input field | |
| `searchRegex` | | `RegExp` | `/.*/i` | | |
| `searchValue` | | `string` | | Current search filter value. Updates search regex when changed. | |

<details><summary>Private API</summary>

#### Fields

| Name | Privacy | Type | Default | Description | Inherited From |
| ------------- | --------- | ------------------------ | ------- | ----------- | -------------- |
| `searchRegex` | protected | `RegExp` | `/.*/i` | | |
| `searchInput` | protected | `TextField \| undefined` | | | |
| Name | Privacy | Type | Default | Description | Inherited From |
| -------------- | --------- | ------------------------ | ------- | ----------- | -------------- |
| `searchInput` | protected | `TextField \| undefined` | | | |
| `_searchValue` | protected | `string` | `''` | | |

#### Methods

Expand Down
175 changes: 175 additions & 0 deletions base-list.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/* eslint-disable no-unused-expressions */

import { expect, fixture, html } from '@open-wc/testing';
import { customElement } from 'lit/decorators.js';

import { TextField } from '@scopedelement/material-web/textfield/internal/text-field';

import { FilterListBase } from './base-list.js';

@customElement('test-filterable-list')
class TestFilterableList extends FilterListBase {
render() {
return html`
${this.renderSearchField()}
<div class="content">Test content</div>
`;
}

public testOnFilter() {
this.onFilter();
}
}

describe('FilterListBase', () => {
let element: TestFilterableList;

beforeEach(async () => {
element = await fixture(
html`<test-filterable-list></test-filterable-list>`
);
});

describe('basic properties', () => {
it('has default filterable property as false', () => {
expect(element.filterable).to.be.false;
});

it('has default searchhelper property', () => {
expect(element.searchhelper).to.equal('search');
});

it('has default searchValue as empty string', () => {
expect(element.searchValue).to.equal('');
});

it('has default searchRegex that matches everything', () => {
const regex = element.searchRegex;
expect(regex.test('anything')).to.be.true;
expect(regex.test('')).to.be.true;
});
});

describe('searchValue property', () => {
it('getter returns the current search value', () => {
expect(element.searchValue).to.equal('');
element.searchValue = 'test';
expect(element.searchValue).to.equal('test');
});

it('setter updates internal state and regex', () => {
element.searchValue = 'apple';
expect(element.searchValue).to.equal('apple');

const regex = element.searchRegex;
expect(regex.test('apple')).to.be.true;
expect(regex.test('pineapple')).to.be.true;
expect(regex.test('banana')).to.be.false;
});
});

describe('search field rendering', () => {
it('does not render search field when filterable is false', () => {
element.filterable = false;
element.requestUpdate();

const searchField = element.shadowRoot?.querySelector(
'md-outlined-text-field'
);
expect(searchField).to.be.null;
});

it('renders search field when filterable is true', async () => {
element.filterable = true;
await element.updateComplete;

const searchField = element.shadowRoot?.querySelector(
'md-outlined-text-field'
);
expect(searchField).to.not.be.null;
});

it('search field has correct placeholder', async () => {
element.filterable = true;
element.searchhelper = 'Custom search placeholder';
await element.updateComplete;

const searchField = element.shadowRoot?.querySelector(
'md-outlined-text-field'
);
expect(searchField?.getAttribute('placeholder')).to.equal(
'Custom search placeholder'
);
});

it('search field reflects searchValue', async () => {
element.filterable = true;
element.searchValue = 'test value';
await element.updateComplete;

const searchField = element.shadowRoot?.querySelector(
'md-outlined-text-field'
) as TextField;
expect(searchField?.value).to.equal('test value');
});
});

describe('search regex functionality', () => {
it('creates case-insensitive regex', () => {
element.searchValue = 'Apple';
const regex = element.searchRegex;
expect(regex.test('apple')).to.be.true;
expect(regex.test('APPLE')).to.be.true;
});

it('handles multiple search terms', () => {
element.searchValue = 'red apple';
const regex = element.searchRegex;
expect(regex.test('red delicious apple')).to.be.true;
expect(regex.test('apple red')).to.be.true;
expect(regex.test('red banana')).to.be.false;
});

it('handles wildcard characters', () => {
element.searchValue = 'app*';
const regex = element.searchRegex;
expect(regex.test('apple')).to.be.true;
expect(regex.test('application')).to.be.true;
expect(regex.test('app')).to.be.true;
});

it('handles quoted strings', () => {
element.searchValue = '"exact phrase"';
const regex = element.searchRegex;
expect(regex.test('this is an exact phrase here')).to.be.true;
expect(regex.test('exact different phrase')).to.be.false;
});

it('escapes special regex characters', () => {
element.searchValue = 'test.value';
const regex = element.searchRegex;
expect(regex.test('test.value')).to.be.true;
expect(regex.test('testXvalue')).to.be.false;
});
});

describe('onFilter method', () => {
it('updates searchValue from search input', async () => {
element.filterable = true;
await element.updateComplete;

const searchField = element.shadowRoot?.querySelector(
'md-outlined-text-field'
) as TextField;
if (searchField) {
searchField.value = 'new search term';
element.testOnFilter();
expect(element.searchValue).to.equal('new search term');
}
});

it('handles missing search input gracefully', () => {
expect(() => element.testOnFilter()).to.not.throw;
});
});
});
26 changes: 22 additions & 4 deletions base-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,41 @@ export class FilterListBase extends ScopedElementsMixin(LitElement) {
searchhelper = 'search';

@state()
protected searchRegex: RegExp = /.*/i;
searchRegex: RegExp = /.*/i;

@query('md-outlined-text-field')
protected searchInput?: TextField;

@state()
protected _searchValue = '';

/** Current search filter value. Updates search regex when changed. */
@property({ type: String })
set searchValue(value: string) {
const oldVal = this._searchValue;
if (oldVal === value) return;
this._searchValue = value;
this.searchRegex = searchRegex(value);
}

get searchValue(): string {
return this._searchValue;
}

protected onFilter(): void {
this.searchRegex = searchRegex(this.searchInput?.value);
if (!this.searchInput) return;
this.searchValue = this.searchInput.value;
}

protected renderSearchField(): TemplateResult {
return this.filterable
? html`<md-outlined-text-field
.value=${this._searchValue}
placeholder="${this.searchhelper}"
@input="${debounce(() => this.onFilter())}"
>
<md-icon slot="leading-icon">search</md-icon></md-outlined-text-field
>`
<md-icon slot="leading-icon">search</md-icon>
</md-outlined-text-field>`
: html``;
}
}
Loading