Compare commits

...

19 Commits

Author SHA1 Message Date
konnorrogers
37de495703 fix documentation 2024-10-02 16:01:42 -04:00
konnorrogers
54de7b02a6 prettier 2024-10-02 11:18:27 -04:00
konnorrogers
a8c7298dea fix tree hydration issue 2024-10-02 11:05:18 -04:00
konnorrogers
1fbd8e48b6 comment out hydration error script 2024-10-01 17:47:04 -04:00
konnorrogers
cadc1b9267 remove index.html 2024-10-01 13:47:31 -04:00
konnorrogers
75fbdb0155 Merge branch 'next' of https://github.com/shoelace-style/webawesome into konnorrogers/fix-select-loading-issues 2024-10-01 13:47:19 -04:00
konnorrogers
43506918e1 prettier 2024-09-27 10:36:34 -04:00
konnorrogers
312225e8c3 prettier 2024-09-27 10:05:59 -04:00
konnorrogers
33f04fe934 prettier 2024-09-26 18:01:11 -04:00
konnorrogers
dfa679cf5d prettier 2024-09-26 17:07:28 -04:00
konnorrogers
8a78049e11 prettier 2024-09-26 17:06:49 -04:00
konnorrogers
9d400906e3 prettier 2024-09-26 16:58:17 -04:00
konnorrogers
b56761373b prettier 2024-09-26 16:57:34 -04:00
konnorrogers
4390b57d8e prettier 2024-09-26 16:54:48 -04:00
konnorrogers
634a796841 working on dynamic options 2024-09-25 18:18:04 -04:00
konnorrogers
f9b3f1e01d prettier 2024-09-24 13:54:39 -04:00
konnorrogers
6d2acab81f try this for select 2024-09-24 13:53:56 -04:00
konnorrogers
45f5bac5ac prettier 2024-09-24 12:17:06 -04:00
konnorrogers
545ad3dced fix select loading issues 2024-09-24 11:04:53 -04:00
8 changed files with 323 additions and 94 deletions

View File

@@ -280,7 +280,9 @@ Remember that custom tags are rendered in a shadow root. To style them, you can
</wa-select>
<script type="module">
await customElements.whenDefined("wa-select")
const select = document.querySelector('.custom-tag');
await select.updateComplete
select.getTag = (option, index) => {
// Use the same icon used in wa-option
@@ -299,4 +301,115 @@ Remember that custom tags are rendered in a shadow root. To style them, you can
:::warning
Be sure you trust the content you are outputting! Passing unsanitized user input to `getTag()` can result in XSS vulnerabilities.
:::
:::
### 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 {.example}
<form id="lazy-options-example">
<div>
<wa-select name="select-1" value="foo" label="Single select (with existing options)">
<wa-option value="bar">Bar</wa-option>
<wa-option value="baz">Baz</wa-option>
</wa-select>
<br>
<wa-button type="button">Add "foo" option</wa-button>
</div>
<br>
<div>
<wa-select name="select-2" value="foo" label="Single select (with no existing options)">
</wa-select>
<br>
<wa-button type="button">Add "foo" option</wa-button>
</div>
<br>
<div>
<wa-select name="select-3" value="foo bar baz" multiple label="Multiple Select (with existing options)">
<wa-option value="bar">Bar</wa-option>
<wa-option value="baz">Baz</wa-option>
</wa-select>
<br>
<wa-button type="button">Add "foo" option</wa-button>
</div>
<br>
<div>
<wa-select name="select-4" value="foo" multiple label="Multiple Select (with no existing options)">
</wa-select>
<br>
<wa-button type="button">Add "foo" option</wa-button>
</div>
<br><br>
<div style="display: flex; gap: 16px;">
<wa-button type="reset">Reset</wa-button>
<wa-button type="submit" variant="brand">Show FormData</wa-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("wa-button[type='button']")
if (!addFooButton) {
return
}
const select = addFooButton.parentElement.querySelector("wa-select")
if (select.querySelector("wa-option[value='foo']")) {
// Foo already exists. no-op.
return
}
const option = document.createElement("wa-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>
```

View File

@@ -4,6 +4,20 @@ description: Trees allow you to display a hierarchical list of selectable tree i
layout: component
---
```html {.example}
<wa-tree selection="multiple">
<wa-tree-item>
Parent Node
<wa-tree-item selected>Child Node 1</wa-tree-item>
<wa-tree-item>
Child Node 2
<wa-tree-item>Child Node 2 - 1</wa-tree-item>
<wa-tree-item>Child Node 2 - 2</wa-tree-item>
</wa-tree-item>
</wa-tree-item>
</wa-tree>
```
```html {.example}
<wa-tree>
<wa-tree-item>
@@ -241,4 +255,4 @@ Decorative icons can be used before labels to provide hints for each node.
</wa-tree-item>
</wa-tree-item>
</wa-tree>
```
```

View File

View File

@@ -614,6 +614,129 @@ describe('<wa-select>', () => {
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><wa-select name="select" value="option-1"></wa-select></form>`
);
const el = form.querySelector<WaSelect>('wa-select')!;
expect(el.value).to.equal('');
expect(new FormData(form).get('select')).equal('');
const option = document.createElement('wa-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><wa-select name="select" value="option-1" multiple></wa-select></form>`
);
const el = form.querySelector<WaSelect>('wa-select')!;
expect(Array.isArray(el.value)).to.equal(true);
expect(el.value!.length).to.equal(0);
const option = document.createElement('wa-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>
<wa-select name="select" value="foo">
<wa-option value="bar">Bar</wa-option>
<wa-option value="baz">Baz</wa-option>
</wa-select>
</form>`
);
const el = form.querySelector<WaSelect>('wa-select')!;
expect(el.value).to.equal('');
expect(new FormData(form).get('select')).to.equal('');
const option = document.createElement('wa-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>
<wa-select name="select" value="foo" multiple>
<wa-option value="bar">Bar</wa-option>
<wa-option value="baz">Baz</wa-option>
</wa-select>
</form>`
);
const el = form.querySelector<WaSelect>('wa-select')!;
expect(el.value).to.be.an('array');
expect(el.value!.length).to.equal(0);
const option = document.createElement('wa-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>
<wa-select name="select" value="foo bar baz" multiple>
<wa-option value="bar">Bar</wa-option>
<wa-option value="baz">Baz</wa-option>
</wa-select>
</form>`
);
const el = form.querySelector<WaSelect>('wa-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('wa-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']);
});
});
});
});
}
});

View File

@@ -162,30 +162,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
return val;
}
private _value: string | string[] | null = this.defaultValue;
/**
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
* value attribute will be a space-delimited list of values based on the options selected, and the value property will
* be an array. **For this reason, values must not contain spaces.**
*/
get value() {
if (this.valueHasChanged) {
return this._value;
}
return this._value ?? this.defaultValue;
}
@property({ attribute: false })
set value(val: string | string[] | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
@property({ attribute: false }) value: string | string[] | null = null;
/** The select's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -286,11 +263,8 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
connectedCallback() {
super.connectedCallback();
this.updateComplete.then(() => {
if (!this.hasInteracted) {
this.value = this.defaultValue;
}
});
this.handleDefaultSlotChange();
// Because this is a form control, it shouldn't be opened initially
this.open = false;
}
@@ -541,6 +515,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
const oldValue = this.value;
if (option && !option.disabled) {
this.valueHasChanged = true;
if (this.multiple) {
this.toggleOptionSelection(option);
} else {
@@ -566,20 +541,20 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
}
private handleDefaultSlotChange() {
if (!customElements.get('wa-option')) {
customElements.whenDefined('wa-option').then(() => this.handleDefaultSlotChange());
}
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[] = [];
// Check for duplicate values in menu items
if (customElements.get('wa-option')) {
allOptions.forEach(option => values.push(option.value));
allOptions.forEach(option => values.push(option.value));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
} else {
// Rerun this handler when `<wa-option>` is registered
customElements.whenDefined('wa-option').then(() => this.handleDefaultSlotChange());
}
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
private handleTagRemove(event: WaRemoveEvent, option: WaOption) {
@@ -657,8 +632,10 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
// This method must be called whenever the selection changes. It will update the selected options cache, the current
// value, and the display value
private selectionChanged() {
const options = this.getAllOptions();
// 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
if (this.multiple) {
@@ -671,8 +648,9 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
}
} else {
this.value = this.selectedOptions[0]?.value ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel?.() ?? '';
const selectedOption = this.selectedOptions[0];
this.value = selectedOption?.value ?? '';
this.displayLabel = selectedOption?.getTextLabel?.() ?? '';
}
// Update validity
@@ -896,7 +874,8 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
@blur=${this.handleBlur}
/>
${this.multiple ? html`<div part="tags" class="select__tags">${this.tags}</div>` : ''}
<!-- Tags need to wait for first hydration before populating otherwise it will create a hydration mismatch. -->
${this.multiple && this.hasUpdated ? html`<div part="tags" class="select__tags">${this.tags}</div>` : ''}
<input
class="select__value-input"

View File

@@ -680,18 +680,17 @@ describe('<wa-tree>', () => {
</wa-tree-item>
</wa-tree>
`);
const treeItems = Array.from<WaTreeItem>(tree.querySelectorAll('wa-tree-item'));
// Act
await tree.updateComplete;
await Promise.allSettled(treeItems.map(treeItem => treeItem.updateComplete));
// Assert
// @TODO: Figure out why this fails in hydration
if (fixture.type !== 'ssr-client-hydrated') {
treeItems.forEach(treeItem => {
expect(treeItem).to.have.attribute('selected');
});
}
treeItems.forEach(treeItem => {
expect(treeItem).to.have.attribute('selected');
});
});
});
@@ -716,14 +715,12 @@ describe('<wa-tree>', () => {
// Act
await tree.updateComplete;
await Promise.allSettled(treeItems.map(treeItem => treeItem.updateComplete));
// Assert
// @TODO: Figure out why this fails in hydration
if (fixture.type !== 'ssr-client-hydrated') {
treeItems.forEach(treeItem => {
expect(treeItem).to.have.attribute('selected');
});
}
treeItems.forEach(treeItem => {
expect(treeItem).to.have.attribute('selected');
});
expect(treeItems[0].indeterminate).to.be.false;
});
});
@@ -748,15 +745,11 @@ describe('<wa-tree>', () => {
// Act
await tree.updateComplete;
await Promise.allSettled(treeItems.map(treeItem => treeItem.updateComplete));
// Assert
expect(treeItems[0]).not.to.have.attribute('selected');
// @TODO: figure out why this fails with SSR.
if (fixture.type !== 'ssr-client-hydrated') {
expect(treeItems[0].indeterminate).to.be.true;
}
expect(treeItems[0].indeterminate).to.be.true;
expect(treeItems[1]).to.have.attribute('selected');
expect(treeItems[2]).not.to.have.attribute('selected');
expect(treeItems[3]).not.to.have.attribute('selected');
@@ -767,7 +760,7 @@ describe('<wa-tree>', () => {
});
});
// https://github.com/shoelace-style/shoelace/issues/1916
// // https://github.com/shoelace-style/shoelace/issues/1916
it("Should not render 'null' if it can't find a custom icon", async () => {
const tree = await fixture<WaTree>(html`
<wa-tree>

View File

@@ -141,26 +141,28 @@ export default class WaTree extends WebAwesomeElement {
// Initializes new items by setting the `selectable` property and the expanded/collapsed icons if any
private initTreeItem = (item: WaTreeItem) => {
item.selectable = this.selection === 'multiple';
item.updateComplete.then(() => {
item.selectable = this.selection === 'multiple';
['expand', 'collapse']
.filter(status => !!this.querySelector(`[slot="${status}-icon"]`))
.forEach((status: 'expand' | 'collapse') => {
const existingIcon = item.querySelector(`[slot="${status}-icon"]`);
const expandButtonIcon = this.getExpandButtonIcon(status);
['expand', 'collapse']
.filter(status => !!this.querySelector(`[slot="${status}-icon"]`))
.forEach((status: 'expand' | 'collapse') => {
const existingIcon = item.querySelector(`[slot="${status}-icon"]`);
const expandButtonIcon = this.getExpandButtonIcon(status);
if (!expandButtonIcon) return;
if (!expandButtonIcon) return;
if (existingIcon === null) {
// No separator exists, add one
item.append(expandButtonIcon);
} else if (existingIcon.hasAttribute('data-default')) {
// A default separator exists, replace it
existingIcon.replaceWith(expandButtonIcon);
} else {
// The user provided a custom icon, leave it alone
}
});
if (existingIcon === null) {
// No separator exists, add one
item.append(expandButtonIcon);
} else if (existingIcon.hasAttribute('data-default')) {
// A default separator exists, replace it
existingIcon.replaceWith(expandButtonIcon);
} else {
// The user provided a custom icon, leave it alone
}
});
});
};
private handleTreeChanged = (mutations: MutationRecord[]) => {
@@ -359,15 +361,19 @@ export default class WaTree extends WebAwesomeElement {
this.setAttribute('aria-multiselectable', isSelectionMultiple ? 'true' : 'false');
for (const item of items) {
item.selectable = isSelectionMultiple;
item.updateComplete.then(() => {
item.selectable = isSelectionMultiple;
});
}
if (isSelectionMultiple) {
await this.updateComplete;
[...this.querySelectorAll(':scope > wa-tree-item')].forEach((treeItem: WaTreeItem) =>
syncCheckboxes(treeItem, true)
);
[...this.querySelectorAll(':scope > wa-tree-item')].forEach((treeItem: WaTreeItem) => {
treeItem.updateComplete.then(() => {
syncCheckboxes(treeItem, true);
});
});
}
}

View File

@@ -3,9 +3,10 @@
* These fixtures will also auto-load all of our components.
*/
import { aTimeout, fixture } from '@open-wc/testing';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { cleanupFixtures, ssrFixture as LitSSRFixture } from '@lit-labs/testing/fixtures.js';
import type { LitElement, TemplateResult } from 'lit';
import type WebAwesomeElement from '../webawesome-element.js';
declare global {
interface Window {
@@ -19,8 +20,11 @@ declare global {
/**
* This will hopefully move to a library or be built into Lit. Right now this does nothing.
*/
function handleHydrationError() {
// console.error('LIT HYDRATION ERROR');
function handleHydrationError(e: Event) {
const element = e.target as WebAwesomeElement;
const str = `Expected <${element.localName}> to not have hydration error.`;
expect(true).to.equal(false, str);
}
// This is a non-standard event I have added to the WebAwesomeElement base class.
@@ -32,7 +36,6 @@ document.addEventListener('lit-hydration-error', handleHydrationError);
*/
export async function clientFixture<T extends HTMLElement = HTMLElement>(template: TemplateResult | string) {
// Load all component definitions "customElements.define()"
// await Promise.allSettled(window.clientComponents.map(str => import(str)));
return await fixture<T>(template);
}
@@ -49,18 +52,16 @@ export async function hydratedFixture<T extends HTMLElement = HTMLElement>(templ
hydrate: true
});
// Load all component definitions "customElements.define()"
// await Promise.allSettled(window.clientComponents.map(str => import(str)));
// @ts-expect-error Assume its a lit element.
await hydratedElement.updateComplete;
// This can be removed when this is fixed: https://github.com/lit/lit/issues/4709
// This forces every element to "hydrate" and then wait for an update to complete (hydration)
await Promise.allSettled(
[...hydratedElement.querySelectorAll<LitElement>('*')].map(el => {
[...hydratedElement.querySelectorAll<LitElement>('*')].map(async el => {
el.removeAttribute('defer-hydration');
return el.updateComplete;
}),
// @ts-expect-error Assume its a lit element.
await hydratedElement.updateComplete
})
);
return hydratedElement;