fix: improve rendering for lion-select-rich by lit cache (#2566)

This commit is contained in:
Oleksii Kadurin 2025-09-03 15:01:43 +02:00 committed by GitHub
parent 07f7ea6bab
commit 5360c5a969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 400 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[select-rich] improve rendering by the `lit` `cache` function

View file

@ -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
<lion-option .modelValue="${{ value: 'red', checked: false }}">Red</lion-option>
<lion-option .modelValue="${ value: 'red', checked: false }">Red</lion-option>
```
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
<lion-option .choiceValue="${'red'}" checked>Red</lion-option>
<lion-option .choiceValue="${'blue'}">Blue</lion-option>
```
## Options with HTML

View file

@ -146,8 +146,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
connectedCallback() {
super.connectedCallback();
this.registrationComplete.then(() => {
this._invokerNode.selectedElement =
this.formElements[/** @type {number} */ (this.checkedIndex)];
});
this._invokerNode.hostElement = this;

View file

@ -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`
<lion-tabs>
<button slot="tab" class="first-button" @click=${() => changeActiveTabIndexRef(0)}>
Info
</button>
<p slot="panel">
${cache(
this.activeTabIndex === 0
? html`<lion-select-rich name="favoriteColor" label="Favorite color">
${colours.map(
colour =>
html`<lion-option .choiceValue="${colour.value}"
>${colour.label}</lion-option
>`,
)}
</lion-select-rich>`
: nothing,
)}
</p>
<button slot="tab" class="second-button" @click=${() => changeActiveTabIndexRef(1)}>
Work
</button>
<p slot="panel">Info page with lots of information about us.</p>
</lion-tabs>
`;
}
}
const wrapperFixture = /** @type {(arg: TemplateResult) => Promise<Wrapper>} */ (_fixture);
const tagString = defineCE(Wrapper);
const wrapperTag = unsafeStatic(tagString);
const wrapperElement = /** @type {Wrapper} */ (
await wrapperFixture(html`<${wrapperTag}></${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`
<lion-tabs>
<button slot="tab" class="first-button" @click=${() => changeActiveTabIndexRef(0)}>
Info
</button>
<p slot="panel">
${cache(
this.activeTabIndex === 0
? html`<lion-select-rich name="favoriteColor" label="Favorite color">
${colours.map(
colour =>
html`<lion-option
.choiceValue="${colour.value}"
checked="${colour.checked || nothing}"
>${colour.label}</lion-option
>`,
)}
</lion-select-rich>`
: nothing,
)}
</p>
<button slot="tab" class="second-button" @click=${() => changeActiveTabIndexRef(1)}>
Work
</button>
<p slot="panel">Info page with lots of information about us.</p>
</lion-tabs>
`;
}
}
const wrapperFixture = /** @type {(arg: TemplateResult) => Promise<Wrapper>} */ (_fixture);
const tagString = defineCE(Wrapper);
const wrapperTag = unsafeStatic(tagString);
const wrapperElement = /** @type {Wrapper} */ (
await wrapperFixture(html`<${wrapperTag}></${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`
<lion-tabs>
<button slot="tab" class="first-button" @click=${() => changeActiveTabIndexRef(0)}>
Info
</button>
<p slot="panel">
${cache(
this.activeTabIndex === 0
? html`<lion-select-rich name="favoriteColor" label="Favorite color">
${colours.map(
colour =>
html`<lion-option
@model-value-changed=${(/** @type {Event} */ ev) => {
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}</lion-option
>`,
)}
</lion-select-rich>`
: nothing,
)}
</p>
<button slot="tab" class="second-button" @click=${() => changeActiveTabIndexRef(1)}>
Work
</button>
<p slot="panel">Info page with lots of information about us.</p>
</lion-tabs>
`;
}
}
const wrapperFixture = /** @type {(arg: TemplateResult) => Promise<Wrapper>} */ (_fixture);
const tagString = defineCE(Wrapper);
const wrapperTag = unsafeStatic(tagString);
const wrapperElement = /** @type {Wrapper} */ (
await wrapperFixture(html`<${wrapperTag}></${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`