Skip to content

Commit

Permalink
feat(tree): enable custom filter of query (#1169)
Browse files Browse the repository at this point in the history
* 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

* test(combo-box): add custom filter unit test

* docs(combo-box): filter with initial value instead

* test(tree-select): use tree node with testing & demo page

* docs(tree-select): add filter live example with tree node usage

* docs: use nested data to show how filter works with parents & children

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

---------

Co-authored-by: Theeraphat-Sorasetsakul <[email protected]>
  • Loading branch information
wattachai-lseg and Theeraphat-Sorasetsakul authored Jun 10, 2024
1 parent bb43ab7 commit c9ef6b6
Show file tree
Hide file tree
Showing 21 changed files with 868 additions and 59 deletions.
48 changes: 30 additions & 18 deletions documents/src/pages/elements/combo-box.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,25 +233,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 value = item.value;
const label = item.label;
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 @@ -270,7 +274,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 @@ -280,31 +284,34 @@ 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 value = item.value;
const label = item.label;
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);
```

```typescript
import { ItemData } from '@refinitiv-ui/elements/item';
import { ComboBox, ComboBoxFilter } from '@refinitiv-ui/elements/combo-box';
import type { ComboBox, ComboBoxFilter } from '@refinitiv-ui/elements/combo-box';

const comboBox = document.querySelector('ef-combo-box');

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

Expand All @@ -314,26 +321,30 @@ const customFilter = (comboBox: ComboBox): ComboBoxFilter => {
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: ItemData) => {
return (item) => {
const value = item.value as string;
const label = item.label as string;
const regex = getRegularExpressionOfQuery();
const result = query === item.value || regex.test(item.label as string);
regex.lastIndex = 0; // do not forget to reset last index
const result = regex.test(value) || regex.test(label);
return result;
};
};

if (comboBox) {
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 @@ -395,6 +406,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 @@ -410,13 +424,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
142 changes: 139 additions & 3 deletions documents/src/pages/elements/tree-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,148 @@ 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.

Tree Select has built in text filtering applied on item's `label` property 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.
To customise filtering, provide a predicate function testing each item to `filter` property. A typical example is to apply filter on multiple data properties (e.g. `label` and `value` as in the example below).

::
```javascript
::import-elements::
const treeSelect = document.querySelector('ef-tree-select');
treeSelect.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 = (treeSelect) => {
let query = '';
let queryRegExp;
const getRegularExpressionOfQuery = () => {
if (treeSelect.query !== query || !queryRegExp) {
query = treeSelect.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, treeManager) => {
const treeNode = treeManager.getTreeNode(item);
const { label, value } = treeNode;
const regex = getRegularExpressionOfQuery();
const result = regex.test(value) || regex.test(label);
return result;
};
};
treeSelect.filter = createCustomFilter(treeSelect);
```
```css
.wrapper {
padding: 5px;
height: 300px;
}
```
```html
<div class="wrapper">
<ef-tree-select></ef-tree-select>
</div>
```
::

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

// Make a scoped re-usable filter for performance
const createCustomFilter = (treeSelect) => {
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 (treeSelect.query !== query || !queryRegExp) {
query = treeSelect.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, treeManager) => {
const treeNode = treeManager.getTreeNode(item);
const { label, value } = treeNode;
const regex = getRegularExpressionOfQuery();
const result = regex.test(value) || regex.test(label);
return result;
};
};

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

```typescript
import type { TreeSelect, TreeSelectFilter } from '@refinitiv-ui/elements/tree-select';

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

// Make a scoped re-usable filter for performance
const createCustomFilter = (treeSelect: TreeSelect): TreeSelectFilter => {
let query = ''; // reference query string for validating queryRegExp cache state
let queryRegExp: RegExp; // 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 (treeSelect.query !== query || !queryRegExp) {
query = treeSelect.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, treeManager) => {
const treeNode = treeManager.getTreeNode(item)!;
const { label, value } = treeNode;
const regex = getRegularExpressionOfQuery();
const result = regex.test(value) || regex.test(label);
return result;
};
};

if (treeSelect) {
treeSelect.filter = createCustomFilter(treeSelect);
}
```

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

## 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 Expand Up @@ -690,7 +826,7 @@ ef-tree-select {
## Accessibility
::a11y-intro::
Tree select is assigned `role="combo-box"` and it supports similar aria attributes as Combo box such as `aria-multiselectable`, `aria-label` or `aria-labelledby`. It has a modal which has `role="dialog"` and it contains Tree, its filter and controls. When opened, focus is managed within the dialog itself.
Tree Select is assigned `role="combo-box"` and it supports similar aria attributes as Combo box such as `aria-multiselectable`, `aria-label` or `aria-labelledby`. It has a modal which has `role="dialog"` and it contains Tree, its filter and controls. When opened, focus is managed within the dialog itself.
`ef-tree-select` has already managed role and keyboard navigation but you should set accessible name to the element by using `aria-label` or `aria-labelledby`.
Expand Down
Loading

0 comments on commit c9ef6b6

Please sign in to comment.