Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tree): enable custom filter of query #1172

Merged
merged 22 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3b3013a
feat(tree, tree-select, combo-box): allow filtering latest value of i…
wattachai-lseg Mar 29, 2024
d8baf88
docs(combo-box): improve regexp usage
wattachai-lseg May 17, 2024
974722d
docs(tree-select): add missing filter API reference
wattachai-lseg May 17, 2024
8fc7be6
test(tree-select): add custom filter unit test
wattachai-lseg May 17, 2024
d7db5d9
docs(combo-box): use collection composer in filter examples for lates…
wattachai-lseg May 24, 2024
f355153
docs(tree-select): remove non-existing filter attribute in JSDoc
wattachai-lseg May 24, 2024
f8633aa
refactor(elements): remove unnecessary type option in property decora…
wattachai-lseg May 24, 2024
7057690
docs(tree-select): add missing queryDebounceRate API ref
wattachai-lseg May 24, 2024
08eb0bf
feat(tree): expose filter as property only
wattachai-lseg May 24, 2024
17b7aff
test(tree): new unit test for custom filter
wattachai-lseg May 24, 2024
eed7c4c
docs(tree): add filter section
wattachai-lseg May 24, 2024
1d3fe80
docs(tree-select): workaround overflow table issue in API reference
wattachai-lseg May 24, 2024
97cb29a
refactor(tree-select): fix tree manager prop visibility
wattachai-lseg May 30, 2024
32ee6f1
docs(tree): use v6 API in the docs
wattachai-lseg May 30, 2024
d98bb62
feat: filter with initial value
wattachai-lseg May 31, 2024
5276d60
Revert "feat(tree, tree-select, combo-box): allow filtering latest va…
wattachai-lseg May 31, 2024
1e4434a
docs(tree-select): improve paragraph format
wattachai-lseg May 31, 2024
7de6e63
Merge branch 'v6' into feat/tree-tree-select-filter-v6
wattachai-lseg Jun 5, 2024
28d1e13
refactor(combo-box,tree-select, tree): remove unnecessary lastIndex u…
wattachai-lseg Jun 7, 2024
9331615
Merge branch 'v6' into feat/tree-tree-select-filter-v6
wattachai-lseg Jun 7, 2024
b873aa8
Merge remote-tracking branch 'origin/feat/tree-tree-select-filter-v6'…
wattachai-lseg Jun 7, 2024
55070a0
test(combo-box): add custom filter test case
wattachai-lseg Jun 7, 2024
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
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