diff --git a/package-lock.json b/package-lock.json index 78575aa61..cdeb2a19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8750,6 +8750,15 @@ "node": ">=6" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.68.tgz", + "integrity": "sha512-CnvsEJWK7cugigckt13AeY80FMzH+OMdEP0j0bS3/zjs44NiRe49x8FZC6R9suRXGMVMXtUHet0zbTp/Ec9Wfg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", @@ -48162,6 +48171,7 @@ "@freshworks/crayons-i18n": "^4.2.0", "@popperjs/core": "^2.10.2", "@stencil/core": "2.17.4", + "@tanstack/virtual-core": "^3.0.0-beta.68", "date-fns": "^2.28.0", "libphonenumber-js": "^1.10.8", "multi-nprogress": "0.3.5" @@ -51009,6 +51019,7 @@ "@stencil/core": "2.17.4", "@stencil/postcss": "^2.1.0", "@stencil/sass": "^1.4.1", + "@tanstack/virtual-core": "3.0.0-beta.68", "@types/jest": "^26.0.22", "@types/node": "^14.0.10", "@types/puppeteer": "^5.4.3", @@ -56520,6 +56531,11 @@ "defer-to-connect": "^1.0.1" } }, + "@tanstack/virtual-core": { + "version": "3.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.68.tgz", + "integrity": "sha512-CnvsEJWK7cugigckt13AeY80FMzH+OMdEP0j0bS3/zjs44NiRe49x8FZC6R9suRXGMVMXtUHet0zbTp/Ec9Wfg==" + }, "@tootallnate/once": { "version": "1.1.2", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" diff --git a/packages/crayons-core/package.json b/packages/crayons-core/package.json index f16170676..9070711b2 100644 --- a/packages/crayons-core/package.json +++ b/packages/crayons-core/package.json @@ -107,6 +107,7 @@ "@freshworks/crayons-i18n": "^4.2.0", "@popperjs/core": "^2.10.2", "@stencil/core": "2.17.4", + "@tanstack/virtual-core": "^3.0.0-beta.68", "date-fns": "^2.28.0", "libphonenumber-js": "^1.10.8", "multi-nprogress": "0.3.5" diff --git a/packages/crayons-core/src/components.d.ts b/packages/crayons-core/src/components.d.ts index ea791b4f7..060f08bb2 100644 --- a/packages/crayons-core/src/components.d.ts +++ b/packages/crayons-core/src/components.d.ts @@ -1230,6 +1230,14 @@ export namespace Components { * Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. */ "disabled": boolean; + /** + * Virtualize long list of elements in list options *Experimental* + */ + "enableVirtualScroll": boolean; + /** + * Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. + */ + "estimatedSize": number; /** * The text to filter the options. */ @@ -1692,10 +1700,18 @@ export namespace Components { * Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. */ "disabled": boolean; + /** + * Virtualize long list of elements in list options *Experimental* + */ + "enableVirtualScroll": boolean; /** * Error text displayed below the text box. */ "errorText": string; + /** + * Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. + */ + "estimatedSize": number; /** * Alternative placement for popover if the default placement is not possible. */ @@ -4178,6 +4194,14 @@ declare namespace LocalJSX { * Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. */ "disabled"?: boolean; + /** + * Virtualize long list of elements in list options *Experimental* + */ + "enableVirtualScroll"?: boolean; + /** + * Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. + */ + "estimatedSize"?: number; /** * The text to filter the options. */ @@ -4644,10 +4668,18 @@ declare namespace LocalJSX { * Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. */ "disabled"?: boolean; + /** + * Virtualize long list of elements in list options *Experimental* + */ + "enableVirtualScroll"?: boolean; /** * Error text displayed below the text box. */ "errorText"?: string; + /** + * Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. + */ + "estimatedSize"?: number; /** * Alternative placement for popover if the default placement is not possible. */ diff --git a/packages/crayons-core/src/components/options-list/list-options.e2e.ts b/packages/crayons-core/src/components/options-list/list-options.e2e.ts index a70c968ba..1187cb86d 100644 --- a/packages/crayons-core/src/components/options-list/list-options.e2e.ts +++ b/packages/crayons-core/src/components/options-list/list-options.e2e.ts @@ -406,4 +406,27 @@ describe('fw-list-options', () => { expect(await options[0].getProperty('checkbox')).toBeFalsy(); expect(await options[0].getProperty('text')).toBe('Cannot find item'); }); + + it('should have virtual scroll (display only limited nodes based on space available in parent) when popup opens up', async () => { + const page = await newE2EPage(); + + await page.setContent(` + Click Me! + + { + elm.options = Array.from(Array(7000), (_, i) => ({ + text: `Item No: ${i + 1}`, + value: i, + })); + }); + await page.waitForChanges(); + const trigger = await page.find('fw-button'); + await trigger.click(); + await page.waitForChanges(); + await new Promise((resolve) => setTimeout(() => resolve(null), 2000)); + const options = await page.findAll('fw-list-options >>> fw-select-option'); + expect(options.length).toBeGreaterThan(5); + expect(options.length).toBeLessThan(20); + }); }); diff --git a/packages/crayons-core/src/components/options-list/list-options.tsx b/packages/crayons-core/src/components/options-list/list-options.tsx index f2c21bcbd..9afd99f0e 100644 --- a/packages/crayons-core/src/components/options-list/list-options.tsx +++ b/packages/crayons-core/src/components/options-list/list-options.tsx @@ -48,11 +48,23 @@ export class ListOptions { resolve(filteredValue); }); }; + private scrollVirtualizer = null; + private scrollVirtualizerCleanup = null; + private renderPromiseResolve = null; @State() filteredOptions = []; @State() selectOptions = []; @State() selectedOptionsState = []; @State() isLoading = false; + /** + * State to trigger rerender. + * + * Necessary for virtual-scroll to initiate + * As virtual scroll is created from inside the lifecycle event (we need reference container) + * but stencil expects store to be imported before render, thus necessary to trigger rerender + * to subscribe to changes in scroller + */ + @State() tick = {}; /** * Value corresponding to the option, that is saved when the form data is saved. @@ -158,6 +170,17 @@ export class ListOptions { * Key for determining the value for a given option */ @Prop() optionValuePath = 'value'; + + /** + * Virtualize long list of elements in list options *Experimental* + */ + @Prop() enableVirtualScroll = false; + + /** + * Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. + */ + @Prop() estimatedSize = 35; + /** * Triggered when a value is selected or deselected from the list box options. */ @@ -167,6 +190,65 @@ export class ListOptions { */ @Event({ cancelable: true }) fwLoading: EventEmitter; + /** + * componentDidRender lifecycle event + */ + componentDidRender() { + if (this.enableVirtualScroll && this.renderPromiseResolve) { + this.renderPromiseResolve(); + this.renderPromiseResolve = null; + } + } + + /** + * WorkAround for wait until next render in stenciljs + * https://github.com/ionic-team/stencil/issues/2744 + */ + waitForNextRender() { + return new Promise((resolve) => (this.renderPromiseResolve = resolve)); + } + + connectedCallback() { + if (this.enableVirtualScroll) { + const parent = this.host?.parentElement; + if (parent && parent.tagName === 'FW-POPOVER') { + parent.addEventListener('fwShow', this.debouncedFwShowHandler); + parent.addEventListener('fwHide', this.debouncedFwHideHandler); + } else { + this.waitForNextRender().then(() => this.initScroller()); + } + } + } + + disconnectedCallback() { + if (this.enableVirtualScroll) { + const parent = this.host?.parentElement; + if (parent && parent.tagName === 'FW-POPOVER') { + parent.removeEventListener('fwShow', this.debouncedFwShowHandler); + parent.removeEventListener('fwHide', this.debouncedFwHideHandler); + } + this.scrollVirtualizerCleanup?.(); + } + } + + debouncedFwShowHandler = debounce( + () => { + this.initScroller(); + }, + this, + 100 + ); + + debouncedFwHideHandler = debounce( + () => { + this.scrollVirtualizerCleanup?.(); + this.scrollVirtualizer?.scrollToOffset(0); + this.scrollVirtualizer = null; + }, + this, + 100 + ); + @Listen('fwSelected') fwSelectedHandler(selectedItem) { const { value, selected } = selectedItem.detail; @@ -235,7 +317,11 @@ export class ListOptions { @Method() async scrollToLastSelected() { - if (this.filteredOptions.length > 0 && this.valueExists()) { + if ( + !this.enableVirtualScroll && + this.filteredOptions.length > 0 && + this.valueExists() + ) { this.container .querySelector( `fw-select-option[id='${ @@ -338,6 +424,44 @@ export class ListOptions { this.setSelectedOptions(newValue); } + @Watch('filteredOptions') + onFilteredOptionsChange(): void { + if (this.enableVirtualScroll) { + this.scrollVirtualizerCleanup?.(); + this.initScroller(); + } + } + + async initScroller() { + const scrollElement = this.getScrollElement(); + if (this.filteredOptions?.length && scrollElement) { + const options: any = { + count: this.filteredOptions.length, + getScrollElement: () => { + return scrollElement; + }, + estimateSize: () => this.estimatedSize, + paddingEnd: 10, + }; + if (this.scrollVirtualizer) { + this.scrollVirtualizerCleanup?.(); + } + const createVirtualizer = await ( + await import('../../utils/stencil-virtual-scroll') + ).createVirtualizer; + const virtualScroll = createVirtualizer(options); + this.scrollVirtualizer = virtualScroll.virtualizer; + this.scrollVirtualizerCleanup = () => { + virtualScroll.cleanup(); + this.scrollVirtualizer = null; + this.scrollVirtualizerCleanup = null; + }; + this.tick = {}; + } else { + this.scrollVirtualizer = null; + } + } + valueExists() { return this.multiple ? this.value.length > 0 : !!this.value; } @@ -505,36 +629,81 @@ export class ListOptions { this.filteredOptions = this.selectOptions; } + renderVirtualList(options: Array): JSX.Element { + const scrollElement = this.getScrollElement(); + const virtualItems = this.scrollVirtualizer?.getVirtualItems(); + return ( + scrollElement && + this.scrollVirtualizer && + virtualItems && ( +
+
+ {virtualItems + .filter((virtualItem) => !!options[virtualItem.index]) + .map((virtualItem) => { + const option = options[virtualItem.index]; + return ( +
this.scrollVirtualizer?.measureElement(item)} + > + {this.renderSelectOption(option)} +
+ ); + })} +
+
+ ) + ); + } + + renderSelectOption(option) { + const isDisabled = + this.selectedOptionsState?.find( + (selected) => + selected[this.optionValuePath] === option[this.optionValuePath] + )?.disabled || + option.disabled || + (!this.allowDeselect && option.selected) || + (this.multiple && !option.selected && this.value?.length >= this.max); + const isDefaultOption = [ + this.noDataText, + TranslationController.t('search.noDataAvailable'), + this.notFoundText, + TranslationController.t('search.noItemsFound'), + ].includes(option[this.optionLabelPath]); + const checkbox = !isDefaultOption && (this.checkbox || option.checkbox); + return ( + + ); + } + renderSelectOptions(options: Array) { - return options.map((option) => { - const isDisabled = - this.selectedOptionsState?.find( - (selected) => - selected[this.optionValuePath] === option[this.optionValuePath] - )?.disabled || - option.disabled || - (!this.allowDeselect && option.selected) || - (this.multiple && !option.selected && this.value?.length >= this.max); - const isDefaultOption = [ - this.noDataText, - TranslationController.t('search.noDataAvailable'), - this.notFoundText, - TranslationController.t('search.noItemsFound'), - ].includes(option[this.optionLabelPath]); - const checkbox = !isDefaultOption && (this.checkbox || option.checkbox); - return ( - - ); - }); + return options.map((option) => this.renderSelectOption(option)); } renderSearchInput() { @@ -570,6 +739,16 @@ export class ListOptions { this.setDataSource(this.options); } + getScrollElement() { + const parent = this.host?.parentElement; + if (parent && parent.tagName === 'FW-POPOVER') { + const content = parent.shadowRoot?.querySelector('.popper-content'); + return content; + } else { + return parent; + } + } + render() { return (
{this.searchable && this.renderSearchInput()} - {this.renderSelectOptions(this.filteredOptions)} + {this.enableVirtualScroll + ? this.renderVirtualList(this.filteredOptions) + : this.renderSelectOptions(this.filteredOptions)}
); } diff --git a/packages/crayons-core/src/components/options-list/readme.md b/packages/crayons-core/src/components/options-list/readme.md index 6bb6115f5..925d6c7fa 100644 --- a/packages/crayons-core/src/components/options-list/readme.md +++ b/packages/crayons-core/src/components/options-list/readme.md @@ -321,36 +321,128 @@ The data-source and the visual variant for the list options can be altered via t +### Demo with virtual scroll + +**This feature is experimental, it needs to be explicitly activated using the `enableVirtualScroll` feature flag.** + +`enableVirtualScroll` property can be used to enable virtualisation of long list of options. +`estimatedSize` property is used to set estimated size of items in the list box to ensure smooth-scrolling. + +```html live + + + Open Long List + + + + +``` + +### Usage of Virtual scroll + + + + +```html + + + Open Long List + + + + +``` + + + + +```jsx +function ListOptions() { + const longListOptions = Array.from(Array(50000), (_,i) => ({ + text: `Item No: ${i + 1}`, + value: i + })); + return ( + + Open Long List + + + ); +} +export default ListOptions; +``` + + + + ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------- | -| `allowDeselect` | `allow-deselect` | Whether clicking on the already selected option disables it. | `boolean` | `true` | -| `allowSelect` | `allow-select` | Whether clicking on option selects it. | `boolean` | `true` | -| `checkbox` | `checkbox` | Place a checkbox. | `boolean` | `false` | -| `debounceTimer` | `debounce-timer` | Debounce timer for the search promise function. | `number` | `300` | -| `disabled` | `disabled` | Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | -| `filterText` | `filter-text` | The text to filter the options. | `any` | `undefined` | -| `formatCreateLabel` | -- | Works only when 'isCreatable' is selected. Function to format the create label displayed as an option. | `(value: string) => string` | `undefined` | -| `hideTick` | `hide-tick` | hide tick mark icon on select option | `boolean` | `false` | -| `isCreatable` | `is-creatable` | Allows user to create the option if the provided input doesn't match with any of the options. | `boolean` | `false` | -| `max` | `max` | Works with `multiple` enabled. Configures the maximum number of options that can be selected with a multi-select component. | `number` | `Number.MAX_VALUE` | -| `multiple` | `multiple` | Enables selection of multiple options. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | -| `noDataText` | `no-data-text` | Text to be displayed when there is no data available in the select. | `string` | `''` | -| `notFoundText` | `not-found-text` | Default option to be shown if the option doesn't match the filterText. | `string` | `''` | -| `optionLabelPath` | `option-label-path` | Key for determining the label for a given option | `string` | `'text'` | -| `optionValuePath` | `option-value-path` | Key for determining the value for a given option | `string` | `'value'` | -| `options` | -- | Value corresponding to the option, that is saved when the form data is saved. | `any[]` | `[]` | -| `search` | -- | Filter function which takes in filterText and dataSource and return a Promise. Where filter text is the text to filter the value in dataSource array. The returned promise should contain the array of options to be displayed. | `(text: string, dataSource: any[]) => Promise` | `this.defaultSearchFunction` | -| `searchText` | `search-text` | Placeholder to placed on the search text box. | `string` | `''` | -| `searchable` | `searchable` | Enables the input with in the popup for filtering the options. | `boolean` | `false` | -| `selectedOptions` | -- | The option that is displayed as the default selection, in the list box. Must be a valid value corresponding to the fw-select-option components used in Select. | `any[]` | `[]` | -| `validateNewOption` | -- | Works only when 'isCreatable' is selected. Function to validate the newly created value. Should return true if new option is valid or false if invalid. | `(value: string) => boolean` | `undefined` | -| `value` | `value` | Value of the option that is displayed as the default selection, in the list box. Must be a valid value corresponding to the fw-select-option components used in Select. | `any` | `''` | -| `variant` | `variant` | Standard is the default option without any graphics other options are icon and avatar which places either the icon or avatar at the beginning of the row. The props for the icon or avatar are passed as an object via the graphicsProps. | `"avatar" \| "icon" \| "standard"` | `'standard'` | +| Property | Attribute | Description | Type | Default | +| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------- | +| `allowDeselect` | `allow-deselect` | Whether clicking on the already selected option disables it. | `boolean` | `true` | +| `allowSelect` | `allow-select` | Whether clicking on option selects it. | `boolean` | `true` | +| `checkbox` | `checkbox` | Place a checkbox. | `boolean` | `false` | +| `debounceTimer` | `debounce-timer` | Debounce timer for the search promise function. | `number` | `300` | +| `disabled` | `disabled` | Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | +| `enableVirtualScroll` | `enable-virtual-scroll` | Virtualize long list of elements in list options *Experimental* | `boolean` | `false` | +| `estimatedSize` | `estimated-size` | Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. | `number` | `35` | +| `filterText` | `filter-text` | The text to filter the options. | `any` | `undefined` | +| `formatCreateLabel` | -- | Works only when 'isCreatable' is selected. Function to format the create label displayed as an option. | `(value: string) => string` | `undefined` | +| `hideTick` | `hide-tick` | hide tick mark icon on select option | `boolean` | `false` | +| `isCreatable` | `is-creatable` | Allows user to create the option if the provided input doesn't match with any of the options. | `boolean` | `false` | +| `max` | `max` | Works with `multiple` enabled. Configures the maximum number of options that can be selected with a multi-select component. | `number` | `Number.MAX_VALUE` | +| `multiple` | `multiple` | Enables selection of multiple options. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | +| `noDataText` | `no-data-text` | Text to be displayed when there is no data available in the select. | `string` | `''` | +| `notFoundText` | `not-found-text` | Default option to be shown if the option doesn't match the filterText. | `string` | `''` | +| `optionLabelPath` | `option-label-path` | Key for determining the label for a given option | `string` | `'text'` | +| `optionValuePath` | `option-value-path` | Key for determining the value for a given option | `string` | `'value'` | +| `options` | -- | Value corresponding to the option, that is saved when the form data is saved. | `any[]` | `[]` | +| `search` | -- | Filter function which takes in filterText and dataSource and return a Promise. Where filter text is the text to filter the value in dataSource array. The returned promise should contain the array of options to be displayed. | `(text: string, dataSource: any[]) => Promise` | `this.defaultSearchFunction` | +| `searchText` | `search-text` | Placeholder to placed on the search text box. | `string` | `''` | +| `searchable` | `searchable` | Enables the input with in the popup for filtering the options. | `boolean` | `false` | +| `selectedOptions` | -- | The option that is displayed as the default selection, in the list box. Must be a valid value corresponding to the fw-select-option components used in Select. | `any[]` | `[]` | +| `validateNewOption` | -- | Works only when 'isCreatable' is selected. Function to validate the newly created value. Should return true if new option is valid or false if invalid. | `(value: string) => boolean` | `undefined` | +| `value` | `value` | Value of the option that is displayed as the default selection, in the list box. Must be a valid value corresponding to the fw-select-option components used in Select. | `any` | `''` | +| `variant` | `variant` | Standard is the default option without any graphics other options are icon and avatar which places either the icon or avatar at the beginning of the row. The props for the icon or avatar are passed as an object via the graphicsProps. | `"avatar" \| "icon" \| "standard"` | `'standard'` | ## Events diff --git a/packages/crayons-core/src/components/popover/popover.scss b/packages/crayons-core/src/components/popover/popover.scss index 25311d92e..c020551d1 100644 --- a/packages/crayons-core/src/components/popover/popover.scss +++ b/packages/crayons-core/src/components/popover/popover.scss @@ -29,6 +29,7 @@ transform: scale(0.01); transition: 150ms color, 150ms border, 150ms box-shadow; will-change: auto; + overflow-anchor: none; } .popper-content.no-border { diff --git a/packages/crayons-core/src/components/select/readme.md b/packages/crayons-core/src/components/select/readme.md index a363dc2ab..6b625b8f3 100644 --- a/packages/crayons-core/src/components/select/readme.md +++ b/packages/crayons-core/src/components/select/readme.md @@ -1857,6 +1857,84 @@ export default Select; +### Demo with virtual scroll + +**This feature is experimental, it needs to be explicitly activated using the `enableVirtualScroll` feature flag.** + +`enableVirtualScroll` property can be used to enable virtualisation of long list of options. +`estimatedSize` property is used to set estimated size of items in the list box to ensure smooth-scrolling. + +```html live +

+ + + +``` + +### Usage of Virtual scroll + + + + +```html +

+ + + +``` +
+ + + +```jsx +function Select() { + const longListOptions = Array.from(Array(50000), (_,i) => ({ + text: `Item No: ${i + 1}`, + value: i + })); + return ( + + ); +} +export default Select; +``` + + +
+ ## Styling Refer the css variables in fw-popover to control the height and width of the select popup. @@ -1867,47 +1945,49 @@ Refer the [css variables](#css-custom-properties) for modifying the appearance o ## Properties -| Property | Attribute | Description | Type | Default | -| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `allowDeselect` | `allow-deselect` | Whether clicking on the already selected option disables it. | `boolean` | `true` | -| `boundary` | -- | Describes the select's boundary HTMLElement | `HTMLElement` | `undefined` | -| `caret` | `caret` | Whether the arrow/caret should be shown in the select. | `boolean` | `true` | -| `checkbox` | `checkbox` | Place a checkbox. | `boolean` | `false` | -| `creatableProps` | -- | Props to be passed for creatable select isCreatable: boolean - If true, select accepts user input that are not present as options and add them as options validateNewOption: (value) => boolean - If passed, this function will determine the error state for every new option entered. If return value is true, error state of the newly created option will be false and if return value is false, then the error state of the newly created option will be true. formatCreateLabel: (label) => string - Gets the label for the "create new ..." option in the menu. Current input value is provided as argument. | `{ isCreatable: boolean; validateNewOption: (_value: any) => boolean; formatCreateLabel: (label: any) => string; }` | `{ isCreatable: false, validateNewOption: (_value): boolean => true, formatCreateLabel: (label): string => label, }` | -| `debounceTimer` | `debounce-timer` | Debounce timer for the search promise function. | `number` | `300` | -| `disabled` | `disabled` | Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | -| `errorText` | `error-text` | Error text displayed below the text box. | `string` | `''` | -| `fallbackPlacements` | -- | Alternative placement for popover if the default placement is not possible. | `[PopoverPlacementType]` | `['top']` | -| `forceSelect` | `force-select` | If true, the user must select a value. The default value is not displayed. | `boolean` | `true` | -| `hintText` | `hint-text` | Hint text displayed below the text box. | `string` | `''` | -| `hoist` | `hoist` | Option to prevent the select options from being clipped when the component is placed inside a container with `overflow: auto\|hidden\|scroll`. | `boolean` | `false` | -| `label` | `label` | Label displayed on the interface, for the component. | `string` | `''` | -| `labelledBy` | `labelled-by` | If the default label prop is not used, then use this prop to pass the id of the label. | `string` | `''` | -| `max` | `max` | Works with `multiple` enabled. Configures the maximum number of options that can be selected with a multi-select component. | `number` | `Number.MAX_VALUE` | -| `maxHeight` | `max-height` | Sets the max height of select with multiple options selected and displays a scroll when maxHeight value is exceeded | `string` | `'none'` | -| `multiple` | `multiple` | Enables selection of multiple options. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | -| `name` | `name` | Name of the component, saved as part of form data. | `string` | `''` | -| `noDataText` | `no-data-text` | Text to be displayed when there is no data available in the select. | `string` | `''` | -| `notFoundText` | `not-found-text` | Default option to be shown if the option doesn't match the filterText. | `string` | `''` | -| `optionLabelPath` | `option-label-path` | Key for determining the label for a given option | `string` | `'text'` | -| `optionValuePath` | `option-value-path` | Key for determining the value for a given option | `string` | `'value'` | -| `options` | `options` | The data for the select component, the options will be of type array of fw-select-options. | `any` | `undefined` | -| `optionsPlacement` | `options-placement` | Placement of the options list with respect to select. | `"bottom" \| "bottom-end" \| "bottom-start" \| "left" \| "left-end" \| "left-start" \| "right" \| "right-end" \| "right-start" \| "top" \| "top-end" \| "top-start"` | `'bottom'` | -| `optionsVariant` | `options-variant` | Standard is the default option without any graphics other options are icon and avatar which places either the icon or avatar at the beginning of the row. The props for the icon or avatar are passed as an object via the graphicsProps. | `"avatar" \| "icon" \| "standard"` | `'standard'` | -| `placeholder` | `placeholder` | Text displayed in the list box before an option is selected. | `string` | `undefined` | -| `readonly` | `readonly` | If true, the user cannot modify the default value selected. If the attribute's value is undefined, the value is set to true. | `boolean` | `false` | -| `required` | `required` | Specifies the select field as a mandatory field and displays an asterisk next to the label. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | -| `sameWidth` | `same-width` | Whether the select width to be same as that of the options. | `boolean` | `true` | -| `search` | `search` | Filter function which takes in filterText and dataSource and return a Promise. Where filter text is the text to filter the value in dataSource array. The returned promise should contain the array of options to be displayed. | `any` | `undefined` | -| `searchable` | `searchable` | Allow to search for value. Default is true. | `boolean` | `true` | -| `selectedOptions` | -- | Array of the options that is displayed as the default selection, in the list box. Must be a valid option corresponding to the fw-select-option components used in Select. | `any[]` | `[]` | -| `state` | `state` | Theme based on which the list box is styled. | `"error" \| "normal" \| "warning"` | `'normal'` | -| `tagProps` | -- | Props to be passed for fw-tag components displayed in multi-select. | `{}` | `{}` | -| `tagVariant` | `tag-variant` | The variant of tag to be used. | `"avatar" \| "standard"` | `'standard'` | -| `type` | `type` | Type of option accepted as the input value. If a user tries to enter an option other than the specified type, the list is not populated. | `"number" \| "text"` | `'text'` | -| `value` | `value` | Value of the option that is displayed as the default selection, in the list box. Must be a valid value corresponding to the fw-select-option components used in Select. | `any` | `undefined` | -| `variant` | `variant` | The UI variant of the select to be used. | `"button" \| "mail" \| "search" \| "standard"` | `'standard'` | -| `warningText` | `warning-text` | Warning text displayed below the text box. | `string` | `''` | +| Property | Attribute | Description | Type | Default | +| --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `allowDeselect` | `allow-deselect` | Whether clicking on the already selected option disables it. | `boolean` | `true` | +| `boundary` | -- | Describes the select's boundary HTMLElement | `HTMLElement` | `undefined` | +| `caret` | `caret` | Whether the arrow/caret should be shown in the select. | `boolean` | `true` | +| `checkbox` | `checkbox` | Place a checkbox. | `boolean` | `false` | +| `creatableProps` | -- | Props to be passed for creatable select isCreatable: boolean - If true, select accepts user input that are not present as options and add them as options validateNewOption: (value) => boolean - If passed, this function will determine the error state for every new option entered. If return value is true, error state of the newly created option will be false and if return value is false, then the error state of the newly created option will be true. formatCreateLabel: (label) => string - Gets the label for the "create new ..." option in the menu. Current input value is provided as argument. | `{ isCreatable: boolean; validateNewOption: (_value: any) => boolean; formatCreateLabel: (label: any) => string; }` | `{ isCreatable: false, validateNewOption: (_value): boolean => true, formatCreateLabel: (label): string => label, }` | +| `debounceTimer` | `debounce-timer` | Debounce timer for the search promise function. | `number` | `300` | +| `disabled` | `disabled` | Disables the component on the interface. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | +| `enableVirtualScroll` | `enable-virtual-scroll` | Virtualize long list of elements in list options *Experimental* | `boolean` | `false` | +| `errorText` | `error-text` | Error text displayed below the text box. | `string` | `''` | +| `estimatedSize` | `estimated-size` | Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. | `number` | `35` | +| `fallbackPlacements` | -- | Alternative placement for popover if the default placement is not possible. | `[PopoverPlacementType]` | `['top']` | +| `forceSelect` | `force-select` | If true, the user must select a value. The default value is not displayed. | `boolean` | `true` | +| `hintText` | `hint-text` | Hint text displayed below the text box. | `string` | `''` | +| `hoist` | `hoist` | Option to prevent the select options from being clipped when the component is placed inside a container with `overflow: auto\|hidden\|scroll`. | `boolean` | `false` | +| `label` | `label` | Label displayed on the interface, for the component. | `string` | `''` | +| `labelledBy` | `labelled-by` | If the default label prop is not used, then use this prop to pass the id of the label. | `string` | `''` | +| `max` | `max` | Works with `multiple` enabled. Configures the maximum number of options that can be selected with a multi-select component. | `number` | `Number.MAX_VALUE` | +| `maxHeight` | `max-height` | Sets the max height of select with multiple options selected and displays a scroll when maxHeight value is exceeded | `string` | `'none'` | +| `multiple` | `multiple` | Enables selection of multiple options. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | +| `name` | `name` | Name of the component, saved as part of form data. | `string` | `''` | +| `noDataText` | `no-data-text` | Text to be displayed when there is no data available in the select. | `string` | `''` | +| `notFoundText` | `not-found-text` | Default option to be shown if the option doesn't match the filterText. | `string` | `''` | +| `optionLabelPath` | `option-label-path` | Key for determining the label for a given option | `string` | `'text'` | +| `optionValuePath` | `option-value-path` | Key for determining the value for a given option | `string` | `'value'` | +| `options` | `options` | The data for the select component, the options will be of type array of fw-select-options. | `any` | `undefined` | +| `optionsPlacement` | `options-placement` | Placement of the options list with respect to select. | `"bottom" \| "bottom-end" \| "bottom-start" \| "left" \| "left-end" \| "left-start" \| "right" \| "right-end" \| "right-start" \| "top" \| "top-end" \| "top-start"` | `'bottom'` | +| `optionsVariant` | `options-variant` | Standard is the default option without any graphics other options are icon and avatar which places either the icon or avatar at the beginning of the row. The props for the icon or avatar are passed as an object via the graphicsProps. | `"avatar" \| "icon" \| "standard"` | `'standard'` | +| `placeholder` | `placeholder` | Text displayed in the list box before an option is selected. | `string` | `undefined` | +| `readonly` | `readonly` | If true, the user cannot modify the default value selected. If the attribute's value is undefined, the value is set to true. | `boolean` | `false` | +| `required` | `required` | Specifies the select field as a mandatory field and displays an asterisk next to the label. If the attribute’s value is undefined, the value is set to false. | `boolean` | `false` | +| `sameWidth` | `same-width` | Whether the select width to be same as that of the options. | `boolean` | `true` | +| `search` | `search` | Filter function which takes in filterText and dataSource and return a Promise. Where filter text is the text to filter the value in dataSource array. The returned promise should contain the array of options to be displayed. | `any` | `undefined` | +| `searchable` | `searchable` | Allow to search for value. Default is true. | `boolean` | `true` | +| `selectedOptions` | -- | Array of the options that is displayed as the default selection, in the list box. Must be a valid option corresponding to the fw-select-option components used in Select. | `any[]` | `[]` | +| `state` | `state` | Theme based on which the list box is styled. | `"error" \| "normal" \| "warning"` | `'normal'` | +| `tagProps` | -- | Props to be passed for fw-tag components displayed in multi-select. | `{}` | `{}` | +| `tagVariant` | `tag-variant` | The variant of tag to be used. | `"avatar" \| "standard"` | `'standard'` | +| `type` | `type` | Type of option accepted as the input value. If a user tries to enter an option other than the specified type, the list is not populated. | `"number" \| "text"` | `'text'` | +| `value` | `value` | Value of the option that is displayed as the default selection, in the list box. Must be a valid value corresponding to the fw-select-option components used in Select. | `any` | `undefined` | +| `variant` | `variant` | The UI variant of the select to be used. | `"button" \| "mail" \| "search" \| "standard"` | `'standard'` | +| `warningText` | `warning-text` | Warning text displayed below the text box. | `string` | `''` | ## Events diff --git a/packages/crayons-core/src/components/select/select.e2e.ts b/packages/crayons-core/src/components/select/select.e2e.ts index 1d7f6ae89..45a9b4e5c 100644 --- a/packages/crayons-core/src/components/select/select.e2e.ts +++ b/packages/crayons-core/src/components/select/select.e2e.ts @@ -864,4 +864,28 @@ describe('fw-select', () => { ); expect(popoverContent).not.toHaveAttribute('data-show'); }); + + it('should have virtual scroll (display only limited nodes based on space available in parent) when popup opens up', async () => { + const page = await newE2EPage(); + + await page.setContent(` + `); + await page.$eval('fw-select', (elm: any) => { + elm.options = Array.from(Array(7000), (_, i) => ({ + text: `Item No: ${i + 1}`, + value: i, + })); + }); + await page.waitForChanges(); + const element = await page.find('fw-select'); + await element.click(); + await page.waitForChanges(); + await new Promise((resolve) => setTimeout(() => resolve(null), 2000)); + const popover = await page.find('fw-select >>> fw-popover'); + const options = await popover.findAll( + 'fw-list-options >>> fw-select-option' + ); + expect(options.length).toBeGreaterThan(5); + expect(options.length).toBeLessThan(20); + }); }); diff --git a/packages/crayons-core/src/components/select/select.tsx b/packages/crayons-core/src/components/select/select.tsx index cb754ff62..50b97dbb0 100644 --- a/packages/crayons-core/src/components/select/select.tsx +++ b/packages/crayons-core/src/components/select/select.tsx @@ -279,6 +279,16 @@ export class Select { */ @Prop() tagProps = {}; + /** + * Virtualize long list of elements in list options *Experimental* + */ + @Prop() enableVirtualScroll = false; + + /** + * Works only when 'enableVirtualScroll' is true. Estimated size of each item in the list box to ensure smooth-scrolling. + */ + @Prop() estimatedSize = 35; + // Events /** * Triggered when a value is selected or deselected from the list box options. @@ -1120,6 +1130,8 @@ export class Select { slot='popover-content' optionLabelPath={this.optionLabelPath} optionValuePath={this.optionValuePath} + enableVirtualScroll={this.enableVirtualScroll} + estimatedSize={this.estimatedSize} {...listAttributes} >
diff --git a/packages/crayons-core/src/utils/stencil-virtual-scroll.ts b/packages/crayons-core/src/utils/stencil-virtual-scroll.ts new file mode 100644 index 000000000..70ac594bb --- /dev/null +++ b/packages/crayons-core/src/utils/stencil-virtual-scroll.ts @@ -0,0 +1,142 @@ +/** + * Usage: + * + * Tanstack virtual does not have an adapter for stencil framework. + * This is an attempt to bridge the gap. We have made use of stencils store to identify changes to + * virtual scroll instance states and update UI accordingly. + * + * Only one method is exposed from the adapter, createVirtualizer. + * This method takes virtualizerOptions as a parameter. The required options are similar to virtualizer core as in below docs: + * https://tanstack.com/virtual/v3/docs/api/virtualizer + * + * + * EX: + * const { virtualizerInstance, cleanup } = createVirtualizer({ + * count: 1000, // Total items to virtualize + * getScrollElement: () => parentRef.current, // function to return scroll container + * estimateSize: () => 35 // the actual size of items (or estimated size if you will be dynamically measuring items) + * }); + * + * In components lifecycle methods, + * connectedCallback() { + * // waiting for render as options require ref. This will be available only after render. + * this.waitForNextRender().then(() => { + * // this.virtualizerInstance and this.cleanup are private properties to a component. + * ({virtualizer: this.virtualizerInstance, cleanup: this.cleanup} = createVirtualizer(options)); + * // force rerender once for stencil to pick up changes from store. We can use 'this.tick = {};' where tick is a state to component, just for purpose of rerendering. + * // Stencil seems to pick up store updates only after rerender if store is dynamically created after a render. + * }); + * } + * disconnectedCallback() { + * this.cleanup(); + * } + * + * For usage in render method, refer to below link: + * https://tanstack.com/virtual/v3/docs/guide/introduction + */ + +import { + elementScroll, + observeElementOffset, + observeElementRect, + PartialKeys, + Virtualizer, + VirtualizerOptions, +} from '@tanstack/virtual-core'; +export * from '@tanstack/virtual-core'; + +import { createStore } from '@stencil/store'; +import { debounce } from '.'; + +function createVirtualizerBase< + TScrollElement extends Element | Window, + TItemElement extends Element +>( + options: VirtualizerOptions +): { + virtualizer: Virtualizer; + cleanup: () => void; +} { + const scrollVirtualizer = new Virtualizer( + options as unknown as VirtualizerOptions + ); + const cleanup = scrollVirtualizer._didMount(); + const virtualizerStore = createStore({ + totalSize: scrollVirtualizer.getTotalSize(), + virtualItems: scrollVirtualizer.getVirtualItems(), + }); + scrollVirtualizer._willUpdate(); + + const handler = { + get( + target: Virtualizer, + prop: keyof Virtualizer + ) { + switch (prop) { + case 'getVirtualItems': + return () => virtualizerStore.state.virtualItems; + case 'getTotalSize': + return () => virtualizerStore.state.totalSize; + default: + return Reflect.get(target, prop); + } + }, + }; + + const storeCleanup = () => { + cleanup(); + virtualizerStore.dispose(); + }; + + const scrollVirtualizerProxy = new Proxy(scrollVirtualizer, handler); + + const debouncedOnChange = debounce( + ( + newVirtualizer: Virtualizer, + sync: boolean, + options: any + ) => { + const virtualItems = newVirtualizer.getVirtualItems(); + virtualizerStore.set('virtualItems', virtualItems); + virtualizerStore.set('totalSize', newVirtualizer.getTotalSize()); + scrollVirtualizerProxy._willUpdate(); + options.onChange?.(newVirtualizer, sync); + }, + this, + 50 + ); + + scrollVirtualizerProxy.setOptions({ + ...options, + onChange: ( + newVirtualizer: Virtualizer, + sync: boolean + ) => debouncedOnChange(newVirtualizer, sync, options), + }); + scrollVirtualizerProxy.measure(); + + return { + virtualizer: scrollVirtualizerProxy, + cleanup: storeCleanup, + }; +} + +export function createVirtualizer< + TScrollElement extends Element, + TItemElement extends Element +>( + options: PartialKeys< + VirtualizerOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + > +): { + virtualizer: Virtualizer; + cleanup: () => void; +} { + return createVirtualizerBase({ + observeElementRect: observeElementRect, + observeElementOffset: observeElementOffset, + scrollToFn: elementScroll, + ...options, + }); +}