diff --git a/.changeset/plenty-deers-clean.md b/.changeset/plenty-deers-clean.md new file mode 100644 index 000000000..d04e96492 --- /dev/null +++ b/.changeset/plenty-deers-clean.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': patch +--- + +[select-rich] improve rendering by the `lit` `cache` function diff --git a/docs/components/select-rich/use-cases.md b/docs/components/select-rich/use-cases.md index 05f4306dd..8d0d8bb02 100644 --- a/docs/components/select-rich/use-cases.md +++ b/docs/components/select-rich/use-cases.md @@ -20,10 +20,24 @@ import '@lion/ui/define/lion-option.js'; ## Model value +### Setting model by the `modelValue` property + You can set the full `modelValue` for each option, which includes the checked property for whether it is checked or not. ```html -Red +Red +``` + +Note, when rendering with the help of the `cache` function imported from `lit/directives/cache.js`, setting model by +the `modelValue` property is not fully supported. Consider setting the model by the `choiceValue` property instead. See [Setting model by `choiceValue` property](#setting-model-by-the-choicevalue-property) for more details. + +### Setting model by the `choiceValue` property + +You can set the model for each option, providing the value and the checked status as follows: + +```html +Red +Blue ``` ## Options with HTML diff --git a/packages/ui/components/select-rich/src/LionSelectRich.js b/packages/ui/components/select-rich/src/LionSelectRich.js index 755380406..a885de323 100644 --- a/packages/ui/components/select-rich/src/LionSelectRich.js +++ b/packages/ui/components/select-rich/src/LionSelectRich.js @@ -146,8 +146,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L connectedCallback() { super.connectedCallback(); - this._invokerNode.selectedElement = - this.formElements[/** @type {number} */ (this.checkedIndex)]; + this.registrationComplete.then(() => { + this._invokerNode.selectedElement = + this.formElements[/** @type {number} */ (this.checkedIndex)]; + }); this._invokerNode.hostElement = this; diff --git a/packages/ui/components/select-rich/test/lion-select-rich.test.js b/packages/ui/components/select-rich/test/lion-select-rich.test.js index e5032362c..5ce3d88b2 100644 --- a/packages/ui/components/select-rich/test/lion-select-rich.test.js +++ b/packages/ui/components/select-rich/test/lion-select-rich.test.js @@ -6,7 +6,8 @@ import { LionOption } from '@lion/ui/listbox.js'; import '@lion/ui/define/lion-select-rich.js'; import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-option.js'; -import { LitElement } from 'lit'; +import '@lion/ui/define/lion-tabs.js'; +import { LitElement, nothing } from 'lit'; import sinon from 'sinon'; import { fixture as _fixture, @@ -16,7 +17,9 @@ import { defineCE, expect, html, + waitUntil, } from '@open-wc/testing'; +import { cache } from 'lit/directives/cache.js'; import { isActiveElement } from '../../core/test-helpers/isActiveElement.js'; @@ -724,6 +727,378 @@ describe('lion-select-rich', () => { }); }); + describe('Render from `lit` `cache`', () => { + describe('when `lion-option` is set by `.choiceValue` property with and no `checked` attribute', () => { + it('should display the second option', async () => { + const colours = [ + { + label: 'Red', + value: 'red', + }, + { + label: 'Blue', + value: 'blue', + }, + ]; + + /** + * Note, inactive tab content is **destroyed** on every tab switch. + */ + class Wrapper extends LitElement { + static properties = { + ...super.properties, + activeTabIndex: { type: Number }, + }; + + constructor() { + super(); + this.activeTabIndex = 0; + } + + /** + * @param {number} index + */ + changeActiveTabIndex(index) { + this.activeTabIndex = index; + } + + render() { + const changeActiveTabIndexRef = this.changeActiveTabIndex.bind(this); + return html` + + +

+ ${cache( + this.activeTabIndex === 0 + ? html` + ${colours.map( + colour => + html`${colour.label}`, + )} + ` + : nothing, + )} +

+ +

Info page with lots of information about us.

+
+ `; + } + } + + const wrapperFixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + const tagString = defineCE(Wrapper); + const wrapperTag = unsafeStatic(tagString); + const wrapperElement = /** @type {Wrapper} */ ( + await wrapperFixture(html`<${wrapperTag}>`) + ); + await wrapperElement.updateComplete; + const wrapperElementShadowRoot = wrapperElement.shadowRoot; + /** + * @returns { HTMLElement | null | undefined } + */ + const getFirstButton = () => wrapperElementShadowRoot?.querySelector('.first-button'); + /** + * @returns { HTMLElement | null | undefined } + */ + const getSecondButton = () => wrapperElementShadowRoot?.querySelector('.second-button'); + /** + * @returns { HTMLElement | null | undefined } + */ + const getInvoker = () => wrapperElementShadowRoot?.querySelector('lion-select-invoker'); + /** + * @returns { boolean } + */ + const isSelectRichRendered = () => !!getInvoker()?.shadowRoot?.childNodes.length; + /** + * @returns { string | undefined } + */ + const getSelectedColourLabel = () => + getInvoker()?.shadowRoot?.querySelector('#content-wrapper')?.textContent?.trim(); + const getDialog = () => + wrapperElementShadowRoot + ?.querySelector('lion-select-rich') + ?.shadowRoot?.querySelector('dialog'); + const isDialogVisible = () => + // @ts-ignore + getDialog()?.checkVisibility() === true && + getDialog()?.querySelector('#overlay-content-node-wrapper[data-popper-placement]'); + await waitUntil(isSelectRichRendered); + getInvoker()?.click(); + await waitUntil(isDialogVisible); + const optionBlue = /** @type { HTMLElement | undefined } */ ( + wrapperElementShadowRoot?.querySelectorAll('lion-option') + )?.[1]; + optionBlue?.click(); + await waitUntil(() => !isDialogVisible()); + const selectedColourLabelBeforeTabSwitch = getSelectedColourLabel(); + expect(selectedColourLabelBeforeTabSwitch).to.equal('Blue'); + getSecondButton()?.click(); + await waitUntil(() => !isSelectRichRendered()); + getFirstButton()?.click(); + await waitUntil(isSelectRichRendered); + const selectedColourAfterTabSwitch = getSelectedColourLabel(); + expect(selectedColourAfterTabSwitch).to.equal('Blue'); + }); + }); + describe('when `lion-option` is set by `.choiceValue` property and `checked` attribute', () => { + it('should display the second option', async () => { + const colours = [ + { + label: 'Red', + value: 'red', + checked: true, + }, + { + label: 'Blue', + value: 'blue', + checked: false, + }, + ]; + + /** + * Note, inactive tab content is **destroyed** on every tab switch. + */ + class Wrapper extends LitElement { + static properties = { + ...super.properties, + activeTabIndex: { type: Number }, + }; + + constructor() { + super(); + this.activeTabIndex = 0; + } + + /** + * @param {number} index + */ + changeActiveTabIndex(index) { + this.activeTabIndex = index; + } + + render() { + const changeActiveTabIndexRef = this.changeActiveTabIndex.bind(this); + return html` + + +

+ ${cache( + this.activeTabIndex === 0 + ? html` + ${colours.map( + colour => + html`${colour.label}`, + )} + ` + : nothing, + )} +

+ +

Info page with lots of information about us.

+
+ `; + } + } + + const wrapperFixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + const tagString = defineCE(Wrapper); + const wrapperTag = unsafeStatic(tagString); + const wrapperElement = /** @type {Wrapper} */ ( + await wrapperFixture(html`<${wrapperTag}>`) + ); + await wrapperElement.updateComplete; + const wrapperElementShadowRoot = wrapperElement.shadowRoot; + /** + * @returns { HTMLElement | null | undefined } + */ + const getFirstButton = () => wrapperElementShadowRoot?.querySelector('.first-button'); + /** + * @returns { HTMLElement | null | undefined } + */ + const getSecondButton = () => wrapperElementShadowRoot?.querySelector('.second-button'); + /** + * @returns { HTMLElement | null | undefined } + */ + const getInvoker = () => wrapperElementShadowRoot?.querySelector('lion-select-invoker'); + /** + * @returns { boolean } + */ + const isSelectRichRendered = () => !!getInvoker()?.shadowRoot?.childNodes.length; + /** + * @returns { string | undefined } + */ + const getSelectedColourLabel = () => + getInvoker()?.shadowRoot?.querySelector('#content-wrapper')?.textContent?.trim(); + const getDialog = () => + wrapperElementShadowRoot + ?.querySelector('lion-select-rich') + ?.shadowRoot?.querySelector('dialog'); + const isDialogVisible = () => + // @ts-ignore + getDialog()?.checkVisibility() === true && + getDialog()?.querySelector('#overlay-content-node-wrapper[data-popper-placement]'); + await waitUntil(isSelectRichRendered); + getInvoker()?.click(); + await waitUntil(isDialogVisible); + const optionBlue = /** @type { HTMLElement | undefined } */ ( + wrapperElementShadowRoot?.querySelectorAll('lion-option') + )?.[1]; + optionBlue?.click(); + await waitUntil(() => !isDialogVisible()); + const selectedColourLabelBeforeTabSwitch = getSelectedColourLabel(); + expect(selectedColourLabelBeforeTabSwitch).to.equal('Blue'); + getSecondButton()?.click(); + await waitUntil(() => !isSelectRichRendered()); + getFirstButton()?.click(); + await waitUntil(isSelectRichRendered); + const selectedColourAfterTabSwitch = getSelectedColourLabel(); + expect(selectedColourAfterTabSwitch).to.equal('Blue'); + }); + }); + describe('when `lion-option` is set by `.modelValue` property', () => { + it('should display the second option', async () => { + const colours = [ + { + label: 'Red', + value: 'red', + checked: true, + }, + { + label: 'Blue', + value: 'blue', + checked: false, + }, + ]; + + /** + * Note, inactive tab content is **destroyed** on every tab switch. + */ + class Wrapper extends LitElement { + static properties = { + ...super.properties, + activeTabIndex: { type: Number }, + }; + + constructor() { + super(); + this.activeTabIndex = 0; + } + + /** + * @param {number} index + */ + changeActiveTabIndex(index) { + this.activeTabIndex = index; + } + + render() { + const changeActiveTabIndexRef = this.changeActiveTabIndex.bind(this); + return html` + + +

+ ${cache( + this.activeTabIndex === 0 + ? html` + ${colours.map( + colour => + html` { + const lionOption = /** @type {LionOption} */ (ev.target); + // eslint-disable-next-line no-param-reassign + colour.checked = lionOption.modelValue.checked; + }} + .modelValue=${{ value: colour.value, checked: colour.checked }} + >${colour.label}`, + )} + ` + : nothing, + )} +

+ +

Info page with lots of information about us.

+
+ `; + } + } + + const wrapperFixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + const tagString = defineCE(Wrapper); + const wrapperTag = unsafeStatic(tagString); + const wrapperElement = /** @type {Wrapper} */ ( + await wrapperFixture(html`<${wrapperTag}>`) + ); + await wrapperElement.updateComplete; + const wrapperElementShadowRoot = wrapperElement.shadowRoot; + /** + * @returns { HTMLElement | null | undefined } + */ + const getFirstButton = () => wrapperElementShadowRoot?.querySelector('.first-button'); + /** + * @returns { HTMLElement | null | undefined } + */ + const getSecondButton = () => wrapperElementShadowRoot?.querySelector('.second-button'); + /** + * @returns { HTMLElement | null | undefined } + */ + const getInvoker = () => wrapperElementShadowRoot?.querySelector('lion-select-invoker'); + /** + * @returns { boolean } + */ + const isSelectRichRendered = () => !!getInvoker()?.shadowRoot?.childNodes.length; + /** + * @returns { string | undefined } + */ + const getSelectedColourLabel = () => + getInvoker()?.shadowRoot?.querySelector('#content-wrapper')?.textContent?.trim(); + const getDialog = () => + wrapperElementShadowRoot + ?.querySelector('lion-select-rich') + ?.shadowRoot?.querySelector('dialog'); + const isDialogVisible = () => + // @ts-ignore + getDialog()?.checkVisibility() === true && + getDialog()?.querySelector('#overlay-content-node-wrapper[data-popper-placement]'); + await waitUntil(isSelectRichRendered); + getInvoker()?.click(); + await waitUntil(isDialogVisible); + const optionBlue = /** @type { HTMLElement | undefined } */ ( + wrapperElementShadowRoot?.querySelectorAll('lion-option') + )?.[1]; + optionBlue?.click(); + await waitUntil(() => !isDialogVisible()); + const selectedColourLabelBeforeTabSwitch = getSelectedColourLabel(); + expect(selectedColourLabelBeforeTabSwitch).to.equal('Blue'); + getSecondButton()?.click(); + await waitUntil(() => !isSelectRichRendered()); + getFirstButton()?.click(); + await waitUntil(isSelectRichRendered); + const selectedColourAfterTabSwitch = getSelectedColourLabel(); + expect(selectedColourAfterTabSwitch).to.equal('Blue'); + }); + }); + }); + describe('Teardown', () => { it('correctly removes event listeners when disconnected from dom', async () => { const el = await fixture(html`