Skip to content

Commit

Permalink
feat(searchable-select): support form association (VIV-2046) (#1919)
Browse files Browse the repository at this point in the history
* Support form association

* Fix formatting
  • Loading branch information
RichardHelm authored Oct 3, 2024
1 parent 20baabe commit 750a20e
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 3 deletions.
32 changes: 32 additions & 0 deletions libs/components/src/lib/searchable-select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,8 @@ Use `--searchable-select-height` to set the max-height of the dropdown.
| `selectedIndex` | `number` | Index of the first selected option or `-1` if no option is selected. |
| `options` | `ListboxOption[]` | Read-only collections of all options. |
| `selectedOptions` | `ListboxOption[]` | Read-only collections of selected options. |
| `initialValues` | `string[]` | List of initially selected option's values. Used in case of form reset. |
| `initialValue` | `string` | Initially selected option's value. Used in case of form reset. |

</div>

Expand All @@ -912,3 +914,33 @@ If an option is disabled, it cannot be selected or unselected.
<vwc-option value="3" text="Option 3"></vwc-option>
</vwc-searchable-select>
```

### In a Form

```html preview 250px
<style>
.buttons {
display: flex;
gap: 12px;
}
</style>
<form>
<vwc-layout column-spacing="small" column-basis="block">
<div>
<vwc-searchable-select name="country" multiple required>
<vwc-option
icon="flag-afghanistan"
value="AF"
text="Afghanistan"
></vwc-option>
<vwc-option icon="flag-albania" value="AL" text="Albania"></vwc-option>
<vwc-option icon="flag-algeria" value="DZ" text="Algeria"></vwc-option>
</vwc-searchable-select>
</div>
<div class="buttons">
<vwc-button label="Reset" type="reset"></vwc-button>
<vwc-button label="Submit" appearance="filled" type="submit"></vwc-button>
</div>
</vwc-layout>
</form>
```
160 changes: 158 additions & 2 deletions libs/components/src/lib/searchable-select/searchable-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ describe('vwc-searchable-select', () => {
| undefined;

const setUpFixture = async (template: string) => {
element = fixture(template) as SearchableSelect;
const root = fixture(template);
element = (
root.tagName === COMPONENT_TAG.toUpperCase()
? root
: root.querySelector(COMPONENT_TAG)!
) as SearchableSelect;
input = element.shadowRoot!.querySelector('input') as HTMLInputElement;
popup = element.shadowRoot!.querySelector('.popup') as Popup;
fieldset = element.shadowRoot!.querySelector('.fieldset') as HTMLDivElement;
Expand Down Expand Up @@ -1009,6 +1014,21 @@ describe('vwc-searchable-select', () => {
expect(eventSpy).toHaveBeenCalledTimes(1);
});

it('should fire when the form is reset', async () => {
await setUpFixture(`
<form>
<${COMPONENT_TAG} name="fruit">
<${OPTION_TAG} value="apple" text="Apple" selected></${OPTION_TAG}>
</${COMPONENT_TAG}>
</form>
`);
element.addEventListener(eventName, eventSpy);

element.closest('form')!.reset();

expect(eventSpy).toHaveBeenCalledTimes(1);
});

it('should not fire when values is changed programmatically', async () => {
element.values = ['apple'];

Expand Down Expand Up @@ -1494,6 +1514,142 @@ describe('vwc-searchable-select', () => {
});
});

describe('required', () => {
it('should have valueMissing error if no option is selected', async () => {
await setUpFixture(`
<${COMPONENT_TAG} name="fruit" required>
<${OPTION_TAG} value="apple" text="Apple"></${OPTION_TAG}>
</${COMPONENT_TAG}>
`);

expect(element.validity.valid).toBe(false);
expect(element.validity.valueMissing).toBe(true);
});

it('should be valid if an option is selected', async () => {
await setUpFixture(`
<${COMPONENT_TAG} name="fruit" required>
<${OPTION_TAG} value="apple" text="Apple" selected></${OPTION_TAG}>
</${COMPONENT_TAG}>
`);

expect(element.validity.valid).toBe(true);
});
});

describe('initialValue', () => {
it('should initialize values to initialValue', async () => {
const component = document.createElement(
COMPONENT_TAG
) as SearchableSelect;
component.innerHTML = `
<${OPTION_TAG} value="apple" text="Apple"></${OPTION_TAG}>
<${OPTION_TAG} value="banana" text="Banana"></${OPTION_TAG}>
`;

component.initialValue = 'banana';
element.replaceWith(component);

expect(component.values).toEqual(['banana']);
});

it('should set values if values have not been explicitly set', async () => {
element.initialValue = 'apple';

expect(element.values).toEqual(['apple']);
});

it('should not set values if values has already been explicitly set', async () => {
element.values = ['banana'];

element.initialValue = 'apple';

expect(element.values).toEqual(['banana']);
});
});

describe('initialValues', () => {
it('should initialize values to initialValues', async () => {
const component = document.createElement(
COMPONENT_TAG
) as SearchableSelect;
component.innerHTML = `
<${OPTION_TAG} value="apple" text="Apple"></${OPTION_TAG}>
<${OPTION_TAG} value="banana" text="Banana"></${OPTION_TAG}>
`;

component.initialValues = ['banana'];
element.replaceWith(component);

expect(component.values).toEqual(['banana']);
});

it('should set values if values have not been explicitly set', async () => {
element.initialValues = ['apple'];

expect(element.values).toEqual(['apple']);
});

it('should not set values if values have already been explicitly set', async () => {
element.values = ['banana'];

element.initialValues = ['apple'];

expect(element.values).toEqual(['banana']);
});
});

describe('form reset', () => {
let form: HTMLFormElement;
beforeEach(async () => {
await setUpFixture(`
<form>
<${COMPONENT_TAG} name="fruit" required>
<${OPTION_TAG} value="apple" text="Apple"></${OPTION_TAG}>
<${OPTION_TAG} value="banana" text="Banana"></${OPTION_TAG}>
</${COMPONENT_TAG}>
</form>
`);
form = element.closest('form') as HTMLFormElement;
});

it('should clear values by default', async () => {
element.values = ['banana'];

form.reset();

expect(element.values).toEqual([]);
});

it('should reset values to initialValues', async () => {
element.values = ['banana'];
element.initialValues = ['apple'];

form.reset();

expect(element.values).toEqual(['apple']);
});

it('should reset values to initialValue', async () => {
element.values = ['banana'];
element.initialValue = 'apple';

form.reset();

expect(element.values).toEqual(['apple']);
});

it('should prefer initialValues over initialValue if both are present', async () => {
element.values = ['banana'];
element.initialValues = ['apple'];
element.initialValue = 'banana';

form.reset();

expect(element.values).toEqual(['apple']);
});
});

describe('option tag icon', () => {
let icon: Icon;
beforeEach(async () => {
Expand Down Expand Up @@ -1522,7 +1678,7 @@ describe('vwc-searchable-select', () => {
expect(icon.getRootNode()).toBe(document);
});

it('should cleanup the cloned when the option is unselected', async () => {
it('should cleanup the cloned icon when the option is unselected', async () => {
element.values = [];

expect(element.querySelectorAll(ICON_TAG).length).toBe(1);
Expand Down
85 changes: 84 additions & 1 deletion libs/components/src/lib/searchable-select/searchable-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ interface TagLayoutEntry {
width: number;
}

const isFormAssociatedTryingToSetFormValue = (
value: File | string | FormData | null
) => typeof value === 'string';

/**
* @public
* @component searchable-select
Expand Down Expand Up @@ -143,6 +147,7 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
if (this.$fastController.isConnected) {
this.#updateTagLayout();
}
this.#updateFormValue();
}

#updateValuesThroughUserInteraction(newValues: string[]) {
Expand All @@ -163,14 +168,31 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
.concat([...newValues].filter((v) => !oldSet.has(v)));
}

/**
* The initial values. This value sets the `values` property
* only when the `values` property has not been explicitly set.
*/
@observable initialValues: string[] = [];
/**
* @internal
*/
initialValuesChanged() {
if (!this.dirtyValue) {
this.values = this.initialValues;
this.dirtyValue = false;
}
}

#isValidValue(value: string) {
return this._slottedOptions.some((option) => option.value === value);
}

/**
* @internal
*/
override valueChanged(_: string, next: string) {
override valueChanged(prev: string, next: string) {
super.valueChanged(prev, next);

if (!this._areOptionsInitialized) {
// Leave value in potential invalid state until options are available
return;
Expand Down Expand Up @@ -915,6 +937,56 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
);
}

// --- Form handling ---

#determineInitialValues() {
return this.initialValues.length
? this.initialValues
: this.initialValue
? [this.initialValue]
: [];
}

/**
* @internal
*/
override nameChanged(previous: string, next: string) {
super.nameChanged!(previous, next);
this.#updateFormValue();
}

#updateFormValue() {
if (!this.name) {
this.setFormValue(null);
} else {
const formData = new FormData();
for (const value of this.values) {
formData.append(this.name, value);
}
this.setFormValue(formData);
}
}

override setFormValue = (
value: File | string | FormData | null,
state?: File | string | FormData | null
) => {
if (isFormAssociatedTryingToSetFormValue(value)) {
return;
}

super.setFormValue(value, state);
};

/**
* @internal
*/
override formResetCallback() {
super.formResetCallback();

this.#updateValuesThroughUserInteraction(this.#determineInitialValues());
}

// --- Core ---

#resizeObserver = new ResizeObserver(() => {
Expand Down Expand Up @@ -943,6 +1015,10 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
override connectedCallback() {
super.connectedCallback();

if (!this.values.length) {
this.values = this.#determineInitialValues();
}

this.#resizeObserver.observe(this._contentArea);
}

Expand All @@ -951,6 +1027,13 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {

this.#resizeObserver.disconnect();
}

/**
* @internal
*/
override validate() {
super.validate(this._input ?? undefined);
}
}

export interface SearchableSelect
Expand Down
Loading

0 comments on commit 750a20e

Please sign in to comment.