diff --git a/documents/src/pages/elements/combo-box.md b/documents/src/pages/elements/combo-box.md index 48448fbdf4..f4ffd2cb75 100644 --- a/documents/src/pages/elements/combo-box.md +++ b/documents/src/pages/elements/combo-box.md @@ -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 { @@ -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 @@ -280,6 +284,9 @@ 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; @@ -287,24 +294,24 @@ const customFilter = (comboBox) => { // 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 @@ -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 @@ -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 @@ -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; } } } diff --git a/documents/src/pages/elements/tree-select.md b/documents/src/pages/elements/tree-select.md index c4db151b2d..8fee93e880 100644 --- a/documents/src/pages/elements/tree-select.md +++ b/documents/src/pages/elements/tree-select.md @@ -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 +
+ +
+``` +:: + +```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. @@ -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`. diff --git a/documents/src/pages/elements/tree.md b/documents/src/pages/elements/tree.md index b297588331..c518adc3fb 100644 --- a/documents/src/pages/elements/tree.md +++ b/documents/src/pages/elements/tree.md @@ -604,7 +604,161 @@ export const createTreeRenderer = ( tree.renderer = createTreeRenderer(tree) ``` +## 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, manager) => { + const treeNode = manager.getTreeNode(item); + const { label, value } = treeNode; + 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 +
+ + +
+ +
+``` +:: + +```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, manager) => { + const treeNode = manager.getTreeNode(item); + const { label, value } = treeNode; + const regex = getRegularExpressionOfQuery(); + const result = regex.test(value) || regex.test(label); + return result; + }; +}; + +tree.filter = createCustomFilter(tree); +``` + +```typescript +import type { Tree, TreeFilter } from '@refinitiv-ui/elements/tree'; + +const tree = document.querySelector('ef-tree'); + +// Make a scoped re-usable filter for performance +const createCustomFilter = (tree: Tree): TreeFilter => { + 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 (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, manager) => { + const treeNode = manager.getTreeNode(item)!; + const { label, value } = treeNode; + const regex = getRegularExpressionOfQuery(); + const result = regex.test(value) || regex.test(label); + return result; + }; +}; + +if (tree) { + 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. diff --git a/packages/elements/src/combo-box/__demo__/index.html b/packages/elements/src/combo-box/__demo__/index.html index d3a3713520..711017c7fc 100644 --- a/packages/elements/src/combo-box/__demo__/index.html +++ b/packages/elements/src/combo-box/__demo__/index.html @@ -170,10 +170,10 @@ // return scoped custom filter return (item) => { + const value = item.value; + const label = item.label; 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 + const result = regex.test(value) || regex.test(label); return result; }; }; @@ -314,13 +314,11 @@ hidden: query ? !regex.test(item.label) : false }) ); - regex.lastIndex = 0; continue; } if (query && regex.test(item.label)) { filterData.push(item); - regex.lastIndex = 0; } } } diff --git a/packages/elements/src/combo-box/__test__/__snapshots__/combo-box.filter.test.snap.js b/packages/elements/src/combo-box/__test__/__snapshots__/combo-box.filter.test.snap.js index eaec676f66..8ce4157e5d 100644 --- a/packages/elements/src/combo-box/__test__/__snapshots__/combo-box.filter.test.snap.js +++ b/packages/elements/src/combo-box/__test__/__snapshots__/combo-box.filter.test.snap.js @@ -125,3 +125,62 @@ snapshots["combo-box/Filter Can Filter Data Default filter filters data: changed `; /* end snapshot combo-box/Filter Can Filter Data Default filter filters data: changed */ +snapshots["combo-box/Filter Can Filter Data Should be able to use custom filter function"] = +`
+ +
+ + +
+
+ + + + + + + + + + +`; +/* end snapshot combo-box/Filter Can Filter Data Should be able to use custom filter function */ + diff --git a/packages/elements/src/combo-box/__test__/combo-box.filter.test.js b/packages/elements/src/combo-box/__test__/combo-box.filter.test.js index 5f24fd8f03..6a43e29abc 100644 --- a/packages/elements/src/combo-box/__test__/combo-box.filter.test.js +++ b/packages/elements/src/combo-box/__test__/combo-box.filter.test.js @@ -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'; @@ -40,5 +42,37 @@ describe('combo-box/Filter', function () { expect(el.query).to.equal(textInput, 'Query should be the same as input text: "Aland Islands"'); await expect(el).shadowDom.to.equalSnapshot(snapshotIgnore); }); + + it('Should be able to use custom filter function', async function () { + const el = await fixture(''); + 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); + }); }); }); diff --git a/packages/elements/src/combo-box/helpers/filter.ts b/packages/elements/src/combo-box/helpers/filter.ts index 49dfda94cc..367f106afb 100644 --- a/packages/elements/src/combo-box/helpers/filter.ts +++ b/packages/elements/src/combo-box/helpers/filter.ts @@ -40,8 +40,6 @@ export const createDefaultFilter = (el: ComboBox< const regex = getRegularExpressionOfQuery(); const result = regex.test(label); - // this example uses global scope, so the index needs resetting - regex.lastIndex = 0; return result; }; }; diff --git a/packages/elements/src/combo-box/index.ts b/packages/elements/src/combo-box/index.ts index 37a63b234f..c31ad9d79c 100644 --- a/packages/elements/src/combo-box/index.ts +++ b/packages/elements/src/combo-box/index.ts @@ -114,13 +114,13 @@ export class ComboBox extends FormFieldElement { * Set this to null when data is filtered externally, eg XHR * @type {ComboBoxFilter | null} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public filter: ComboBoxFilter | null = createDefaultFilter(this); /** * Renderer used to render list item elements */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public renderer = createComboBoxRenderer(this); private _multiple = false; diff --git a/packages/elements/src/list/elements/list.ts b/packages/elements/src/list/elements/list.ts index ceb70706bd..173e602f0f 100644 --- a/packages/elements/src/list/elements/list.ts +++ b/packages/elements/src/list/elements/list.ts @@ -86,7 +86,7 @@ export class List extends ControlElement { /** * Renderer used to render list item elements */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public renderer = createListRenderer(this); /** diff --git a/packages/elements/src/swing-gauge/index.ts b/packages/elements/src/swing-gauge/index.ts index 08670a89e0..661fdded65 100644 --- a/packages/elements/src/swing-gauge/index.ts +++ b/packages/elements/src/swing-gauge/index.ts @@ -196,7 +196,7 @@ export class SwingGauge extends ResponsiveElement { * Custom value formatter * @type {SwingGaugeValueFormatter} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public valueFormatter: SwingGaugeValueFormatter = this.defaultValueFormatter; /** diff --git a/packages/elements/src/tooltip/index.ts b/packages/elements/src/tooltip/index.ts index 95ecc4447a..2fdca34f72 100644 --- a/packages/elements/src/tooltip/index.ts +++ b/packages/elements/src/tooltip/index.ts @@ -115,7 +115,7 @@ class Tooltip extends BasicElement { * Return `true` if the target matches * @type {TooltipCondition} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public condition: TooltipCondition | undefined; /** @@ -124,7 +124,7 @@ class Tooltip extends BasicElement { * If the content is not present, tooltip will not be displayed * @type {TooltipRenderer} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public renderer: TooltipRenderer | undefined; /** diff --git a/packages/elements/src/tree-select/__demo__/countries.js b/packages/elements/src/tree-select/__demo__/countries.js index 1a5841bb27..bf1995dca1 100644 --- a/packages/elements/src/tree-select/__demo__/countries.js +++ b/packages/elements/src/tree-select/__demo__/countries.js @@ -1,5 +1,5 @@ /* eslint-disable */ -const treeCollection = [ +export const countries = [ { value: 'AFR', label: 'Africa', diff --git a/packages/elements/src/tree-select/__demo__/index.html b/packages/elements/src/tree-select/__demo__/index.html index 3a2126c148..0a8065f7ba 100644 --- a/packages/elements/src/tree-select/__demo__/index.html +++ b/packages/elements/src/tree-select/__demo__/index.html @@ -3,7 +3,6 @@ Tree Select - + + +

+ Items could be filtered with case-insensitive partial match of both labels & values.
+ There is a debounce rate of 500ms applied. +

+ + +
diff --git a/packages/elements/src/tree-select/__test__/tree-select.filter.test.js b/packages/elements/src/tree-select/__test__/tree-select.filter.test.js index 105deb7492..ca52910e3e 100644 --- a/packages/elements/src/tree-select/__test__/tree-select.filter.test.js +++ b/packages/elements/src/tree-select/__test__/tree-select.filter.test.js @@ -1,4 +1,6 @@ // import element and theme +import escapeStringRegexp from 'escape-string-regexp'; + import '@refinitiv-ui/elements/tree-select'; import '@refinitiv-ui/elemental-theme/light/ef-tree-select'; @@ -397,5 +399,42 @@ describe('tree-select/Filter', function () { await elementUpdated(el); expect(treeEl.children.length).to.equal(1, 'there should be 1 child with the provided query'); }); + + it('Should be able to use custom filter function', async function () { + const el = await fixture(''); + el.data = flatData; + await elementUpdated(el); + + const createCustomFilter = (treeSelect) => { + let query = ''; + let queryRegExp; + // Items could be filtered with case-insensitive partial match of both labels & values. + const getRegularExpressionOfQuery = () => { + if (treeSelect.query !== query || !queryRegExp) { + query = treeSelect.query || ''; + queryRegExp = new RegExp(escapeStringRegexp(query), '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; + }; + }; + el.filter = createCustomFilter(el); + const query = 'is'; + el.query = query; + await elementUpdated(el); + + const expectedLength = 4; + const treeEl = getTreeElPart(el); + expect(treeEl.children.length).to.equal( + expectedLength, + `there should be only ${expectedLength} children with a query of ${query}` + ); + }); }); }); diff --git a/packages/elements/src/tree-select/index.ts b/packages/elements/src/tree-select/index.ts index f778bcfa88..876903b291 100644 --- a/packages/elements/src/tree-select/index.ts +++ b/packages/elements/src/tree-select/index.ts @@ -48,6 +48,9 @@ const valueFormatWarning = new WarningNotice( /** * Dropdown control that allows selection from the tree list * + * @prop {TreeSelectFilter | null} [filter=createDefaultFilter(this)] - Custom filter for static data. Set this to null when data is filtered externally, eg XHR + * @attr {number} [query-debounce-rate] - Control query rate in milliseconds + * @prop {number} [queryDebounceRate] - Control query rate in milliseconds * @attr {boolean} [opened=false] - Set dropdown to open * @prop {boolean} [opened=false] - Set dropdown to open * @attr {string} placeholder - Set placeholder text @@ -168,8 +171,11 @@ export class TreeSelect extends ComboBox { protected override composer: CollectionComposer = new CollectionComposer([]); protected _treeManager: TreeManager = new TreeManager(this.composer); + + // add a space in front of angle bracket for line break opportunity in EF docs with @type /** * Tree manager used for item manipulation + * @type {TreeManager } */ public get treeManager(): TreeManager { return this._treeManager; @@ -242,7 +248,7 @@ export class TreeSelect extends ComboBox { /** * Renderer used to render tree item elements */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public override renderer = createTreeSelectRenderer(this); private _max: string | null = null; diff --git a/packages/elements/src/tree/__demo__/index.html b/packages/elements/src/tree/__demo__/index.html index c29012b9cc..3ae0191594 100644 --- a/packages/elements/src/tree/__demo__/index.html +++ b/packages/elements/src/tree/__demo__/index.html @@ -178,6 +178,14 @@ }; return makeData(); }; + + globalThis.debounce = (callback, delay = 500) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => callback.apply(this, args), delay); + }; + }; @@ -219,19 +227,139 @@ - + + + +

+ Items could be filtered with case-insensitive partial match of both labels & values through query + input +

+ + +
@@ -449,7 +577,7 @@ }; }; - let composer = new CollectionComposer(globalThis.generateMockData(7)); + let composer = new CollectionComposer(generateMockData(7)); let treeManager = new TreeManager(composer); let items = treeManager.items; diff --git a/packages/elements/src/tree/__test__/helpers/data.js b/packages/elements/src/tree/__test__/helpers/data.js index b49b7e1876..c64c291c65 100644 --- a/packages/elements/src/tree/__test__/helpers/data.js +++ b/packages/elements/src/tree/__test__/helpers/data.js @@ -180,3 +180,159 @@ export const deepNestedData = [ ] } ]; + +export const firstFilterData = [ + { + label: 'Group One', + value: '1', + expanded: true, + items: [ + { + label: 'Item One.One', + icon: 'clock', + value: '1.1', + highlighted: true + }, + { + label: 'Item One.Two', + readonly: true, + value: '1.2' + }, + { + label: 'Item One.Three', + icon: 'info', + value: '1.3', + selected: true + }, + { + label: 'Item One.Four', + icon: 'info', + value: '1.4', + selected: true + } + ] + }, + { + label: 'Group Two', + value: '2', + expanded: true, + disabled: true, + items: [ + { + label: 'Item Two.One', + value: '2.1', + hidden: true + }, + { + label: 'Item Two.Two', + value: '2.2', + items: [ + { + label: 'Item Two.Two.One', + value: '2.2.1', + expanded: true + }, + { + label: 'Item Two.Two.Two', + value: '2.2.2' + }, + { + label: 'Item Two.Two.Three', + value: '2.2.3' + } + ] + }, + { + label: 'Item Two.Three', + value: '2.3' + }, + { + label: 'Item Two.Four', + value: '2.4', + disabled: true + } + ] + }, + { + label: 'Item Three', + value: '3' + }, + { + label: 'Item Four', + value: '4' + } +]; + +export const secondFilterData = [ + { + label: 'Group One', + value: '1', + expanded: true, + items: [ + { + label: 'Item One.One', + icon: 'clock', + value: '1.1', + highlighted: true + }, + { + label: 'Item One.Two', + readonly: true, + value: '1.2' + }, + { + label: 'Item One.Three', + icon: 'info', + value: '1.3', + selected: true + }, + { + label: 'Item One.Four', + icon: 'info', + value: '1.4', + selected: true + } + ] + }, + { + label: 'Group Two', + value: '2', + expanded: true, + disabled: true, + items: [ + { + label: 'Item Two.One', + value: '2.1', + hidden: true + }, + { + label: 'Item Two.Two', + value: '2.2', + items: [ + { + label: 'Item Two.Two.One', + value: '2.2.1', + expanded: true + }, + { + label: 'Item Two.Two.Two', + value: '2.2.2' + } + ] + }, + { + label: 'Item Two.Three', + value: '2.3' + }, + { + label: 'Item Two.Four', + value: '2.4', + disabled: true + } + ] + }, + { + label: 'Item Four', + value: '4' + } +]; diff --git a/packages/elements/src/tree/__test__/tree.test.js b/packages/elements/src/tree/__test__/tree.test.js index 1fdfa9b898..dea055ed42 100644 --- a/packages/elements/src/tree/__test__/tree.test.js +++ b/packages/elements/src/tree/__test__/tree.test.js @@ -1,10 +1,19 @@ // import element and theme +import escapeStringRegexp from 'escape-string-regexp'; + import '@refinitiv-ui/elements/tree'; import '@refinitiv-ui/elemental-theme/light/ef-tree'; import { aTimeout, elementUpdated, expect, fixture, nextFrame, oneEvent } from '@refinitiv-ui/test-helpers'; -import { deepNestedData, flatData, multiLevelData, nestedData } from './helpers/data.js'; +import { + deepNestedData, + firstFilterData, + flatData, + multiLevelData, + nestedData, + secondFilterData +} from './helpers/data.js'; import { getIconPart } from './helpers/utils.js'; const keyArrowUp = new KeyboardEvent('keydown', { key: 'ArrowUp' }); @@ -600,6 +609,57 @@ describe('tree/Tree', function () { expect(el.value).to.equal('4', 'Value should be update when selecting a new item on filter applied.'); }); + it('should be able to filter items with custom filter & query', async function () { + const el = await fixture(''); + el.data = firstFilterData; + await elementUpdated(el); + + const createCustomFilter = (tree) => { + let query = ''; + let queryRegExp; + const getRegularExpressionOfQuery = () => { + if (tree.query !== query || !queryRegExp) { + query = tree.query || ''; + queryRegExp = new RegExp(escapeStringRegexp(query), 'i'); + } + return queryRegExp; + }; + return (item, manager) => { + const treeNode = manager.getTreeNode(item); + const { label, value } = treeNode; + const regex = getRegularExpressionOfQuery(); + const result = regex.test(value) || regex.test(label); + return result; + }; + }; + el.filter = createCustomFilter(el); + el.query = '3'; + await elementUpdated(el); + + const firstChildrenCount = 7; + expect(el.children.length).to.equal( + firstChildrenCount, + `there should be ${firstChildrenCount} child(ren) with the provided custom filter & query` + ); + + el.query = 'three'; + await elementUpdated(el); + + expect(el.children.length).to.equal( + firstChildrenCount, + `there should be ${firstChildrenCount} child(ren) with the provided custom filter & query` + ); + + el.data = secondFilterData; + await elementUpdated(el); + + const secondChildrenCount = 4; + expect(el.children.length).to.equal( + secondChildrenCount, + `there should be ${secondChildrenCount} child(ren) with the provided custom filter, query & data` + ); + }); + it('should be able to filter items based on updated label value', async function () { const el = await fixture(''); el.data = flatData; diff --git a/packages/elements/src/tree/elements/tree.ts b/packages/elements/src/tree/elements/tree.ts index 1244a3a77d..af314cc60c 100644 --- a/packages/elements/src/tree/elements/tree.ts +++ b/packages/elements/src/tree/elements/tree.ts @@ -67,9 +67,9 @@ export class Tree extends List { /** * Custom filter for static data * @type {TreeFilter | null} - * @ignore set to protected for now and need to discuss before set to public API */ - protected filter: TreeFilter | null = createDefaultFilter(this); + @property({ attribute: false }) + public filter: TreeFilter | null = createDefaultFilter(this); /** * Renderer used for generating tree items diff --git a/packages/elements/src/tree/helpers/filter.ts b/packages/elements/src/tree/helpers/filter.ts index b0124afb5f..655ccd8cbc 100644 --- a/packages/elements/src/tree/helpers/filter.ts +++ b/packages/elements/src/tree/helpers/filter.ts @@ -34,8 +34,6 @@ export const createDefaultFilter = (el: T const regex = getRegularExpressionOfQuery(); const result = regex.test(label); - // this regex uses global scope, so the index needs resetting - regex.lastIndex = 0; return result; }; }; diff --git a/packages/elements/src/tree/index.ts b/packages/elements/src/tree/index.ts index d6b35b2fe0..83a6e2e7f7 100644 --- a/packages/elements/src/tree/index.ts +++ b/packages/elements/src/tree/index.ts @@ -1,9 +1,7 @@ -import type { TreeData, TreeDataItem } from './helpers/types'; - export * from './elements/tree.js'; export * from './elements/tree-item.js'; export { TreeRenderer, createTreeRenderer } from './helpers/renderer.js'; export { TreeManager, TreeManagerMode, CheckedState } from './managers/tree-manager.js'; export type { TreeNode } from './managers/tree-node.js'; -export type { TreeData, TreeDataItem }; +export type { TreeData, TreeDataItem, TreeFilter } from './helpers/types';