kopia lustrzana https://github.com/shoelace-style/shoelace
add more resilient lazy loading to sl-select (#2204)
* add more resilient lazy loading to sl-select * prettier * add more resilient lazy loading to sl-select * remove unnecessary assertions * remove unnecessary assertions * fix bad logic * prettier * add changelog entry * prettierrtl-for-older-browsers
rodzic
53aae15d6e
commit
4f78930e99
|
@ -502,6 +502,109 @@ Remember that custom tags are rendered in a shadow root. To style them, you can
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Lazy loading options
|
||||||
|
|
||||||
|
Lazy loading options is very hard to get right. `<wa-select>` largely follows how a native `<select>` works.
|
||||||
|
|
||||||
|
Here are the following conditions:
|
||||||
|
|
||||||
|
- If a `<wa-select>` is created without any options, but is given a `value` attribute, its `value` will be `""`, and then when options are added, if any of the options have a value equal to the `<wa-select>` value, the value of the `<wa-select>` will equal that of the option.
|
||||||
|
|
||||||
|
EX: `<wa-select value="foo">` will have a value of `""` until `<wa-option value="foo">Foo</wa-option>` connects, at which point its value will become `"foo"` when submitting.
|
||||||
|
|
||||||
|
- If a `<wa-select multiple>` with an initial value has multiple values, but only some of the options are present, it will only respect the options that are present, and if a selected option is loaded in later, _AND_ the value of the select has not changed via user interaction or direct property assignment, it will add the selected option to the form value and to the `.value` of the select.
|
||||||
|
|
||||||
|
This can be hard to conceptualize, so heres a fairly large example showing how lazy loaded options work with `<wa-select>` and `<wa-select multiple>` when given initial value attributes. Feel free to play around with it in a codepen.
|
||||||
|
|
||||||
|
```html:preview
|
||||||
|
<form id="lazy-options-example">
|
||||||
|
<div>
|
||||||
|
<sl-select name="select-1" value="foo" label="Single select (with existing options)">
|
||||||
|
<sl-option value="bar">Bar</sl-option>
|
||||||
|
<sl-option value="baz">Baz</sl-option>
|
||||||
|
</sl-select>
|
||||||
|
<br>
|
||||||
|
<sl-button type="button">Add "foo" option</sl-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<sl-select name="select-2" value="foo" label="Single select (with no existing options)">
|
||||||
|
</sl-select>
|
||||||
|
<br>
|
||||||
|
<sl-button type="button">Add "foo" option</sl-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<sl-select name="select-3" value="foo bar baz" multiple label="Multiple Select (with existing options)">
|
||||||
|
<sl-option value="bar">Bar</sl-option>
|
||||||
|
<sl-option value="baz">Baz</sl-option>
|
||||||
|
</sl-select>
|
||||||
|
<br>
|
||||||
|
<sl-button type="button">Add "foo" option</sl-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<sl-select name="select-4" value="foo" multiple label="Multiple Select (with no existing options)">
|
||||||
|
</sl-select>
|
||||||
|
<br>
|
||||||
|
<sl-button type="button">Add "foo" option</sl-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 16px;">
|
||||||
|
<sl-button type="reset">Reset</sl-button>
|
||||||
|
<sl-button type="submit" variant="brand">Show FormData</sl-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<pre hidden><code id="lazy-options-example-form-data"></code></pre>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
function addFooOption(e) {
|
||||||
|
const addFooButton = e.target.closest("sl-button[type='button']")
|
||||||
|
if (!addFooButton) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const select = addFooButton.parentElement.querySelector("sl-select")
|
||||||
|
if (select.querySelector("sl-option[value='foo']")) {
|
||||||
|
// Foo already exists. no-op.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const option = document.createElement("sl-option")
|
||||||
|
option.setAttribute("value", "foo")
|
||||||
|
option.innerText = "Foo"
|
||||||
|
select.append(option)
|
||||||
|
}
|
||||||
|
function handleLazySubmit (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const formData = new FormData(event.target)
|
||||||
|
const codeElement = document.querySelector("#lazy-options-example-form-data")
|
||||||
|
const obj = {}
|
||||||
|
for (const key of formData.keys()) {
|
||||||
|
const val = formData.getAll(key).length > 1 ? formData.getAll(key) : formData.get(key)
|
||||||
|
obj[key] = val
|
||||||
|
}
|
||||||
|
codeElement.textContent = JSON.stringify(obj, null, 2)
|
||||||
|
const preElement = codeElement.parentElement
|
||||||
|
preElement.removeAttribute("hidden")
|
||||||
|
}
|
||||||
|
const container = document.querySelector("#lazy-options-example")
|
||||||
|
container.addEventListener("click", addFooOption)
|
||||||
|
container.addEventListener("submit", handleLazySubmit)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
Be sure you trust the content you are outputting! Passing unsanitized user input to `getTag()` can result in XSS vulnerabilities.
|
Be sure you trust the content you are outputting! Passing unsanitized user input to `getTag()` can result in XSS vulnerabilities.
|
||||||
:::
|
:::
|
||||||
|
|
|
@ -15,6 +15,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- Added Finnish translations [#2211]
|
- Added Finnish translations [#2211]
|
||||||
|
- Fixed a bug with with `<sl-select>` not respecting its initial value. [#2204]
|
||||||
- Fixed a bug with certain bundlers when using dynamic imports [#2210]
|
- Fixed a bug with certain bundlers when using dynamic imports [#2210]
|
||||||
- Fixed a bug in `<sl-textarea>` causing scroll jumping when using `resize="auto"` [#2182]
|
- Fixed a bug in `<sl-textarea>` causing scroll jumping when using `resize="auto"` [#2182]
|
||||||
- Fixed a bug in `<sl-relative-time>` where the title attribute would show with redundant info [#2184]
|
- Fixed a bug in `<sl-relative-time>` where the title attribute would show with redundant info [#2184]
|
||||||
|
|
|
@ -97,6 +97,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
@state() displayLabel = '';
|
@state() displayLabel = '';
|
||||||
@state() currentOption: SlOption;
|
@state() currentOption: SlOption;
|
||||||
@state() selectedOptions: SlOption[] = [];
|
@state() selectedOptions: SlOption[] = [];
|
||||||
|
@state() private valueHasChanged: boolean = false;
|
||||||
|
|
||||||
/** The name of the select, submitted as a name/value pair with form data. */
|
/** The name of the select, submitted as a name/value pair with form data. */
|
||||||
@property() name = '';
|
@property() name = '';
|
||||||
|
@ -216,6 +217,10 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.handleDefaultSlotChange();
|
||||||
|
});
|
||||||
|
|
||||||
// Because this is a form control, it shouldn't be opened initially
|
// Because this is a form control, it shouldn't be opened initially
|
||||||
this.open = false;
|
this.open = false;
|
||||||
}
|
}
|
||||||
|
@ -310,6 +315,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
|
|
||||||
// If it is open, update the value based on the current selection and close it
|
// If it is open, update the value based on the current selection and close it
|
||||||
if (this.currentOption && !this.currentOption.disabled) {
|
if (this.currentOption && !this.currentOption.disabled) {
|
||||||
|
this.valueHasChanged = true;
|
||||||
if (this.multiple) {
|
if (this.multiple) {
|
||||||
this.toggleOptionSelection(this.currentOption);
|
this.toggleOptionSelection(this.currentOption);
|
||||||
} else {
|
} else {
|
||||||
|
@ -470,6 +476,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
const oldValue = this.value;
|
const oldValue = this.value;
|
||||||
|
|
||||||
if (option && !option.disabled) {
|
if (option && !option.disabled) {
|
||||||
|
this.valueHasChanged = true;
|
||||||
if (this.multiple) {
|
if (this.multiple) {
|
||||||
this.toggleOptionSelection(option);
|
this.toggleOptionSelection(option);
|
||||||
} else {
|
} else {
|
||||||
|
@ -495,20 +502,20 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDefaultSlotChange() {
|
private handleDefaultSlotChange() {
|
||||||
|
if (!customElements.get('wa-option')) {
|
||||||
|
customElements.whenDefined('wa-option').then(() => this.handleDefaultSlotChange());
|
||||||
|
}
|
||||||
|
|
||||||
const allOptions = this.getAllOptions();
|
const allOptions = this.getAllOptions();
|
||||||
const value = Array.isArray(this.value) ? this.value : [this.value];
|
const val = this.valueHasChanged ? this.value : this.defaultValue;
|
||||||
|
const value = Array.isArray(val) ? val : [val];
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
|
|
||||||
// Check for duplicate values in menu items
|
// Check for duplicate values in menu items
|
||||||
if (customElements.get('sl-option')) {
|
allOptions.forEach(option => values.push(option.value));
|
||||||
allOptions.forEach(option => values.push(option.value));
|
|
||||||
|
|
||||||
// Select only the options that match the new value
|
// Select only the options that match the new value
|
||||||
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
||||||
} else {
|
|
||||||
// Rerun this handler when <sl-option> is registered
|
|
||||||
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
|
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
|
||||||
|
@ -586,8 +593,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
// This method must be called whenever the selection changes. It will update the selected options cache, the current
|
// This method must be called whenever the selection changes. It will update the selected options cache, the current
|
||||||
// value, and the display value
|
// value, and the display value
|
||||||
private selectionChanged() {
|
private selectionChanged() {
|
||||||
|
const options = this.getAllOptions();
|
||||||
// Update selected options cache
|
// Update selected options cache
|
||||||
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
|
this.selectedOptions = options.filter(el => el.selected);
|
||||||
|
|
||||||
// Update the value and display label
|
// Update the value and display label
|
||||||
if (this.multiple) {
|
if (this.multiple) {
|
||||||
|
@ -600,8 +608,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
|
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.value = this.selectedOptions[0]?.value ?? '';
|
const selectedOption = this.selectedOptions[0];
|
||||||
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
|
this.value = selectedOption?.value ?? '';
|
||||||
|
this.displayLabel = selectedOption?.getTextLabel?.() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update validity
|
// Update validity
|
||||||
|
@ -750,7 +759,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||||
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
|
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
|
||||||
const isPlaceholderVisible = this.placeholder && this.value.length === 0;
|
const isPlaceholderVisible = this.placeholder && this.value && this.value.length <= 0;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -593,6 +593,128 @@ describe('<sl-select>', () => {
|
||||||
|
|
||||||
expect(tag.hasAttribute('pill')).to.be.true;
|
expect(tag.hasAttribute('pill')).to.be.true;
|
||||||
});
|
});
|
||||||
|
describe('With lazily loaded options', () => {
|
||||||
|
describe('With no existing options', () => {
|
||||||
|
it('Should wait to select the option when the option exists for single select', async () => {
|
||||||
|
const form = await fixture<HTMLFormElement>(
|
||||||
|
html`<form><sl-select name="select" value="option-1"></sl-select></form>`
|
||||||
|
);
|
||||||
|
const el = form.querySelector<SlSelect>('sl-select')!;
|
||||||
|
|
||||||
|
expect(el.value).to.equal('');
|
||||||
|
expect(new FormData(form).get('select')).equal('');
|
||||||
|
|
||||||
|
const option = document.createElement('sl-option');
|
||||||
|
option.value = 'option-1';
|
||||||
|
option.innerText = 'Option 1';
|
||||||
|
el.append(option);
|
||||||
|
|
||||||
|
await aTimeout(10);
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.value).to.equal('option-1');
|
||||||
|
expect(new FormData(form).get('select')).equal('option-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should wait to select the option when the option exists for multiple select', async () => {
|
||||||
|
const form = await fixture<HTMLFormElement>(
|
||||||
|
html`<form><sl-select name="select" value="option-1" multiple></sl-select></form>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const el = form.querySelector<SlSelect>('sl-select')!;
|
||||||
|
expect(Array.isArray(el.value)).to.equal(true);
|
||||||
|
expect(el.value.length).to.equal(0);
|
||||||
|
|
||||||
|
const option = document.createElement('sl-option');
|
||||||
|
option.value = 'option-1';
|
||||||
|
option.innerText = 'Option 1';
|
||||||
|
el.append(option);
|
||||||
|
|
||||||
|
await aTimeout(10);
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.value.length).to.equal(1);
|
||||||
|
expect(el.value).to.have.members(['option-1']);
|
||||||
|
expect(new FormData(form).getAll('select')).have.members(['option-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('With existing options', () => {
|
||||||
|
it('Should not select the option if options already exist for single select', async () => {
|
||||||
|
const form = await fixture<HTMLFormElement>(
|
||||||
|
html` <form>
|
||||||
|
<sl-select name="select" value="foo">
|
||||||
|
<sl-option value="bar">Bar</sl-option>
|
||||||
|
<sl-option value="baz">Baz</sl-option>
|
||||||
|
</sl-select>
|
||||||
|
</form>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const el = form.querySelector<SlSelect>('sl-select')!;
|
||||||
|
expect(el.value).to.equal('');
|
||||||
|
expect(new FormData(form).get('select')).to.equal('');
|
||||||
|
|
||||||
|
const option = document.createElement('sl-option');
|
||||||
|
option.value = 'foo';
|
||||||
|
option.innerText = 'Foo';
|
||||||
|
el.append(option);
|
||||||
|
|
||||||
|
await aTimeout(10);
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.value).to.equal('foo');
|
||||||
|
expect(new FormData(form).get('select')).to.equal('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not select the option if options already exists for multiple select', async () => {
|
||||||
|
const form = await fixture<HTMLFormElement>(
|
||||||
|
html` <form>
|
||||||
|
<sl-select name="select" value="foo" multiple>
|
||||||
|
<sl-option value="bar">Bar</sl-option>
|
||||||
|
<sl-option value="baz">Baz</sl-option>
|
||||||
|
</sl-select>
|
||||||
|
</form>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const el = form.querySelector<SlSelect>('sl-select')!;
|
||||||
|
expect(el.value).to.be.an('array');
|
||||||
|
expect(el.value.length).to.equal(0);
|
||||||
|
|
||||||
|
const option = document.createElement('sl-option');
|
||||||
|
option.value = 'foo';
|
||||||
|
option.innerText = 'Foo';
|
||||||
|
el.append(option);
|
||||||
|
|
||||||
|
await aTimeout(10);
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.value).to.have.members(['foo']);
|
||||||
|
expect(new FormData(form).getAll('select')).to.have.members(['foo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should only select the existing options if options already exists for multiple select', async () => {
|
||||||
|
const form = await fixture<HTMLFormElement>(
|
||||||
|
html` <form>
|
||||||
|
<sl-select name="select" value="foo bar baz" multiple>
|
||||||
|
<sl-option value="bar">Bar</sl-option>
|
||||||
|
<sl-option value="baz">Baz</sl-option>
|
||||||
|
</sl-select>
|
||||||
|
</form>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const el = form.querySelector<SlSelect>('sl-select')!;
|
||||||
|
expect(el.value).to.have.members(['bar', 'baz']);
|
||||||
|
expect(el.value.length).to.equal(2);
|
||||||
|
expect(new FormData(form).getAll('select')).to.have.members(['bar', 'baz']);
|
||||||
|
|
||||||
|
const option = document.createElement('sl-option');
|
||||||
|
option.value = 'foo';
|
||||||
|
option.innerText = 'Foo';
|
||||||
|
el.append(option);
|
||||||
|
|
||||||
|
await aTimeout(10);
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.value).to.have.members(['foo', 'bar', 'baz']);
|
||||||
|
expect(new FormData(form).getAll('select')).to.have.members(['foo', 'bar', 'baz']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
runFormControlBaseTests('sl-select');
|
runFormControlBaseTests('sl-select');
|
||||||
});
|
});
|
||||||
|
|
Ładowanie…
Reference in New Issue