Skip to content

Commit

Permalink
feat(tree): enable custom filter of query (#1172)
Browse files Browse the repository at this point in the history
* feat(tree, tree-select, combo-box): allow filtering latest value of items

* docs(combo-box): improve regexp usage

* docs(tree-select): add missing filter API reference

* test(tree-select): add custom filter unit test

* docs(combo-box): use collection composer in filter examples for latest value

* docs(tree-select): remove non-existing filter attribute in JSDoc

* refactor(elements): remove unnecessary type option in property decorator with no attribute

* docs(tree-select): add missing queryDebounceRate API ref

* feat(tree): expose filter as property only

* test(tree): new unit test for custom filter

* docs(tree): add filter section

* docs(tree-select): workaround overflow table issue in API reference

* refactor(tree-select): fix tree manager prop visibility

* docs(tree): use v6 API in the docs

* feat: filter with initial value

* Revert "feat(tree, tree-select, combo-box): allow filtering latest value of items"

This reverts commit 3b3013a.

* docs(tree-select): improve paragraph format

* refactor(combo-box,tree-select, tree): remove unnecessary lastIndex usage

* test(combo-box): add custom filter test case
  • Loading branch information
wattachai-lseg committed Jun 10, 2024
1 parent 5473aad commit 6e7f048
Show file tree
Hide file tree
Showing 22 changed files with 873 additions and 181 deletions.
32 changes: 20 additions & 12 deletions documents/src/pages/elements/combo-box.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,25 +218,29 @@ comboBox.data = [
{ label: 'Brazil', value: 'br' },
{ label: 'Argentina', value: 'ar' }
];
const customFilter = (comboBox) => {
const createCustomFilter = (comboBox) => {
let query = '';
let queryRegExp;
const getRegularExpressionOfQuery = () => {
if (comboBox.query !== query || !queryRegExp) {
query = comboBox.query || '';
// Non-word characters are escaped to prevent ReDoS attack.
// This serves as a demo only.
// For production, use a proven implementation instead.
queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i');
}
return queryRegExp;
};
return (item) => {
const label = item.label;
const value = item.value;
const regex = getRegularExpressionOfQuery();
const result = query === item.value || regex.test(item.label);
regex.lastIndex = 0; // do not forget to reset last index
const result = regex.test(value) || regex.test(label);
return result;
};
};

comboBox.filter = customFilter(comboBox);
comboBox.filter = createCustomFilter(comboBox);
```
```css
.wrapper {
Expand All @@ -255,7 +259,7 @@ comboBox.filter = customFilter(comboBox);
const comboBox = document.querySelector('ef-combo-box');

// Make a scoped re-usable filter for performance
const customFilter = (comboBox) => {
const createCustomFilter = (comboBox) => {
let query = ''; // reference query string for validating queryRegExp cache state
let queryRegExp; // cache RegExp

Expand All @@ -265,25 +269,28 @@ const customFilter = (comboBox) => {
const getRegularExpressionOfQuery = () => {
if (comboBox.query !== query || !queryRegExp) {
query = comboBox.query || '';
// Non-word characters are escaped to prevent ReDoS attack.
// This serves as a demo only.
// For production, use a proven implementation instead.
queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i');
}
return queryRegExp;
};

// return scoped custom filter
return (item) => {
const label = item.label;
const value = item.value;
const regex = getRegularExpressionOfQuery();
const result = query === item.value || regex.test(item.label);
regex.lastIndex = 0; // do not forget to reset last index
const result = regex.test(value) || regex.test(label);
return result;
};
};

comboBox.filter = customFilter(comboBox);
comboBox.filter = createCustomFilter(comboBox);
```


@> Regardless of filter configuration Combo Box always treats `type: 'header'` items as group headers, which persist as long as at least one item within the group is visible.
@> Regardless of filter configuration, Combo Box always treats `type: 'header'` items as group headers, which persist as long as at least one item within the group is visible.

## Asynchronous filtering

Expand Down Expand Up @@ -345,6 +352,9 @@ comboBox.filter = null;

// A function to make request. In real life scenario it may wrap fetch
const request = (query, value) => {
// Non-word characters are escaped to prevent ReDoS attack.
// This serves as a demo only.
// For production, use a proven implementation instead.
const regex = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i');

// Always keep a promise to let Combo Box know that the data is loading
Expand All @@ -360,13 +370,11 @@ const request = (query, value) => {
selected: true,
hidden: query ? !regex.test(item.label) : false
}));
regex.lastIndex = 0;
continue;
}

if (query && regex.test(item.label)) {
filterData.push(item);
regex.lastIndex = 0;
}
}
}
Expand Down
6 changes: 2 additions & 4 deletions documents/src/pages/elements/tree-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,10 @@ setTimeout(() => { el.opened = true; }, 1000);
*> If the number of selected items is likely to be large, pills may not be a good choice for display or performance.

## Filtering
Tree select has built in text filtering and selection editing.

By clicking the `Selected` button, Tree Select allows the items to be filtered by selected state, and that subset to be operated on in isolation from the main item list.

For custom filtering, Tree Select provides an identical interface as Combo Box. You provide a predicate function that tests an item. Please consult the [Combo Box docs](./elements/combo-box) for details on how to construct a compatible filter.
Tree Select has built in text filtering and selection editing. By clicking the `Selected` button, Tree Select allows the items to be filtered by selected state, and that subset to be operated on in isolation from the main item list.

For custom filtering, Tree Select provides an identical interface as Combo Box. You provide a predicate function testing each item. Please consult the [Combo Box docs](./elements/combo-box#filtering) for details on how to construct a compatible filter.

## Limiting Selected Items
Tree Select offers a convenient way to limit the number of selected items using `max` property. If users attempt to select more items than the specified limit, "Done" button will be automatically disabled.
Expand Down
115 changes: 115 additions & 0 deletions documents/src/pages/elements/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,122 @@ tree.addEventListener('value-changed', (event) => {
});
```

## Filtering

Filtering happens when `query` property or attribute is not empty. By Default, the filter is applied on the data `label` property. Developers may wish to do their own filtering by implementing the `filter` property. A typical example is to apply filter on multiple data properties e.g. `label` and `value`.

::
```javascript
::import-elements::
const tree = document.querySelector('ef-tree');
tree.data = [
{ label: 'EMEA', value: 'emea', expanded: true, items: [
{ label: 'France', value: 'fr' },
{ label: 'Russian Federation', value: 'ru' },
{ label: 'Spain', value: 'es' },
{ label: 'United Kingdom', value: 'gb' }
]},
{ label: 'APAC', value: 'apac', expanded: true, items: [
{ label: 'China', value: 'ch' },
{ label: 'Australia', value: 'au' },
{ label: 'India', value: 'in' },
{ label: 'Thailand', value: 'th' }
]},
{ label: 'AMERS', value: 'amers', expanded: true, items: [
{ label: 'Canada', value: 'ca' },
{ label: 'United States', value: 'us' },
{ label: 'Brazil', value: 'br' },
{ label: 'Argentina', value: 'ar' }
]}
];
const createCustomFilter = (tree) => {
let query = '';
let queryRegExp;
const getRegularExpressionOfQuery = () => {
if (tree.query !== query || !queryRegExp) {
query = tree.query || '';
// Non-word characters are escaped to prevent ReDoS attack.
// This serves as a demo only.
// For production, use a proven implementation instead.
queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i');
}
return queryRegExp;
};
return (item) => {
const label = item.label;
const value = item.value;
const regex = getRegularExpressionOfQuery();
const result = regex.test(value) || regex.test(label);
return result;
};
};
tree.filter = createCustomFilter(tree);

const input = document.getElementById('query');
input.addEventListener('value-changed', e => {
tree.query = e.detail.value;
});
```
```css
.wrapper {
padding: 5px;
width: 300px;
height: 430px;
}

#query {
width: 200px;
}
```
```html
<div class="wrapper">
<label for="query">Filter</label>
<ef-text-field id="query" placeholder="keyword to filter Tree's items"></ef-text-field>
<br>
<ef-tree></ef-tree>
</div>
```
::

```javascript
const tree = document.querySelector('ef-tree');

// Make a scoped re-usable filter for performance
const createCustomFilter = (tree) => {
let query = ''; // reference query string for validating queryRegExp cache state
let queryRegExp; // cache RegExp

// Get current RegExp, or renew if out of date
// this is fetched on demand by filter/renderer
// only created once per query
const getRegularExpressionOfQuery = () => {
if (tree.query !== query || !queryRegExp) {
query = tree.query || '';
// Non-word characters are escaped to prevent ReDoS attack.
// This serves as a demo only.
// For production, use a proven implementation instead.
queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i');
}
return queryRegExp;
};

// return scoped custom filter
return (item) => {
const label = item.label;
const value = item.value;
const regex = getRegularExpressionOfQuery();
const result = regex.test(value) || regex.test(label);
return result;
};
};

tree.filter = createCustomFilter(tree);
```

@> Regardless of filter configuration, Tree always shows parent items as long as at least one of their child is visible.

## Accessibility

::a11y-intro::

`ef-tree` is assigned `role="tree"` and can include properties such as `aria-multiselectable`, `aria-label`, or `aria-labelledby`. It receives focus once at host and it is navigable through items using `Up` and `Down` arrow keys and expandable or collapsable using `Left` and `Right`. Each item is assigned `role="treeitem"` and can include properties such as `aria-selected` or `aria-checked` in `multiple` mode.
Expand Down
3 changes: 0 additions & 3 deletions packages/elements/src/combo-box/__demo__/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@
const regex = getRegularExpressionOfQuery();
// test on label or value
const result = query === item.value || regex.test(item.label);
regex.lastIndex = 0; // do not forget to reset last index
return result;
};
};
Expand Down Expand Up @@ -297,13 +296,11 @@
hidden: query ? !regex.test(item.label) : false
})
);
regex.lastIndex = 0;
continue;
}

if (query && regex.test(item.label)) {
filterData.push(item);
regex.lastIndex = 0;
}
}
}
Expand Down
60 changes: 60 additions & 0 deletions packages/elements/src/combo-box/__snapshots__/Filter.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,69 @@
</ef-list-item>
</ef-list>
</ef-overlay>
```

```html
<div part="input-wrapper">
<input
aria-activedescendant="AX"
aria-autocomplete="list"
aria-expanded="true"
aria-haspopup="listbox"
aria-owns="internal-list"
autocomplete="off"
part="input"
role="combobox"
type="text"
>
<div
id="toggle-button"
part="button button-toggle"
>
<ef-icon
icon="down"
part="icon icon-toggle"
>
</ef-icon>
</div>
</div>
<ef-overlay-viewport>
</ef-overlay-viewport>
<ef-overlay
first-resize-done=""
no-autofocus=""
no-focus-management=""
no-overlap=""
opened=""
part="list"
tabindex="-1"
with-shadow=""
>
<ef-list
aria-multiselectable="false"
id="internal-list"
role="listbox"
tabindex=""
>
<ef-list-item
aria-selected="false"
role="presentation"
type="header"
>
</ef-list-item>
<ef-list-item
aria-selected="false"
highlighted=""
id="AX"
role="option"
>
</ef-list-item>
</ef-list>
</ef-overlay>
```

#### `Should be able to use custom filter function`

```html
<div part="input-wrapper">
<input
Expand Down
34 changes: 34 additions & 0 deletions packages/elements/src/combo-box/__test__/combo-box.filter.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import escapeStringRegexp from 'escape-string-regexp';

import '@refinitiv-ui/elements/combo-box';

import '@refinitiv-ui/elemental-theme/light/ef-combo-box';
Expand Down Expand Up @@ -31,5 +33,37 @@ describe('combo-box/Filter', function () {
expect(el.query).to.equal(textInput, 'Query should be the same as input text: "Aland Islands"');
expect(el).shadowDom.to.equalSnapshot(snapshotIgnore);
});

it('Should be able to use custom filter function', async function () {
const el = await fixture('<ef-combo-box opened></ef-combo-box>');
el.data = getData();
await elementUpdated(el);

const createCustomFilter = (comboBox) => {
let query = '';
let queryRegExp;
// Items could be filtered with case-insensitive partial match of both labels & values.
const getRegularExpressionOfQuery = () => {
if (comboBox.query !== query || !queryRegExp) {
query = comboBox.query || '';
queryRegExp = new RegExp(escapeStringRegexp(query), 'i');
}
return queryRegExp;
};
return (item) => {
const value = item.value;
const label = item.label;
const regex = getRegularExpressionOfQuery();
const result = regex.test(value) || regex.test(label);
return result;
};
};
el.filter = createCustomFilter(el);
const textInput = 'ax';
await setInputEl(el, textInput);
await elementUpdated(el);
expect(el.query).to.equal(textInput, `Query should be the same as input text: "${textInput}"`);
await expect(el).shadowDom.to.equalSnapshot(snapshotIgnore);
});
});
});
4 changes: 1 addition & 3 deletions packages/elements/src/combo-box/helpers/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { ComboBoxFilter } from './types';
* @param el ComboBox instance to filter
* @returns Filter accepting an item
*/
export const defaultFilter = <T extends DataItem = ItemData>(el: ComboBox<T>): ComboBoxFilter<T> => {
export const createDefaultFilter = <T extends DataItem = ItemData>(el: ComboBox<T>): ComboBoxFilter<T> => {
// reference query string for validating queryRegExp cache state
let query = '';
// cache RegExp
Expand All @@ -32,8 +32,6 @@ export const defaultFilter = <T extends DataItem = ItemData>(el: ComboBox<T>): C
return (item): boolean => {
const regex = getRegularExpressionOfQuery();
const result = regex.test((item as unknown as ItemText).label);
// this example uses global scope, so the index needs resetting
regex.lastIndex = 0;
return result;
};
};
Loading

0 comments on commit 6e7f048

Please sign in to comment.