feat(form): add reset functionality (#799)

* feat(form): add reset functionality

* feat(interal): add defaultValue decorator

* feat: add defaultValue and defaultChecked

* chore: implement unit tests

* chore: remove leftover
This commit is contained in:
Alessandro
2022-06-28 23:59:52 +02:00
committed by GitHub
parent 0d19c46d18
commit b2cf3a5505
22 changed files with 468 additions and 16 deletions

View File

@@ -65,6 +65,7 @@ To make a field required, use the `required` prop. The form will not be submitte
<br />
<sl-checkbox required>Check me before submitting</sl-checkbox>
<br /><br />
<sl-button type="reset" variant="default">Reset</sl-button>
<sl-button type="submit" variant="primary">Submit</sl-button>
</form>
@@ -118,6 +119,7 @@ To restrict a value to a specific [pattern](https://developer.mozilla.org/en-US/
<form class="input-validation-pattern">
<sl-input name="letters" required label="Letters" pattern="[A-Za-z]+"></sl-input>
<br />
<sl-button type="reset" variant="default">Reset</sl-button>
<sl-button type="submit" variant="primary">Submit</sl-button>
</form>
@@ -161,6 +163,7 @@ Some input types will automatically trigger constraints, such as `email` and `ur
<br />
<sl-input variant="url" label="URL" placeholder="https://example.com/" required></sl-input>
<br />
<sl-button type="reset" variant="default">Reset</sl-button>
<sl-button type="submit" variant="primary">Submit</sl-button>
</form>
@@ -204,6 +207,7 @@ To create a custom validation error, pass a non-empty string to the `setCustomVa
<form class="input-validation-custom">
<sl-input label="Type 'shoelace'" required></sl-input>
<br />
<sl-button type="reset" variant="default">Reset</sl-button>
<sl-button type="submit" variant="primary">Submit</sl-button>
</form>

View File

@@ -83,7 +83,7 @@ export default class SlButton extends LitElement {
* The type of button. When the type is `submit`, the button will submit the surrounding form. Note that the default
* value is `button` instead of `submit`, which is opposite of how native `<button>` elements behave.
*/
@property() type: 'button' | 'submit' = 'button';
@property() type: 'button' | 'submit' | 'reset' = 'button';
/** An optional name for the button. Ignored when `href` is set. */
@property() name?: string;
@@ -153,6 +153,10 @@ export default class SlButton extends LitElement {
if (this.type === 'submit') {
this.formSubmitController.submit(this);
}
if (this.type === 'reset') {
this.formSubmitController.reset(this);
}
}
render() {

View File

@@ -121,6 +121,36 @@ describe('<sl-checkbox>', () => {
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-checkbox name="a" value="1" checked></sl-checkbox>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const checkbox = form.querySelector('sl-checkbox')!;
checkbox.checked = false;
await checkbox.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await checkbox.updateComplete;
expect(checkbox.checked).to.true;
checkbox.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await checkbox.updateComplete;
expect(checkbox.checked).to.false;
});
});
describe('click', () => {
it('should click the inner input', async () => {
const el = await fixture<SlCheckbox>(html`<sl-checkbox></sl-checkbox>`);

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { watch } from '../../internal/watch';
@@ -32,7 +33,9 @@ export default class SlCheckbox extends LitElement {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined)
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
defaultValue: (control: SlCheckbox) => control.defaultChecked,
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
});
@state() private hasFocus = false;
@@ -58,6 +61,10 @@ export default class SlCheckbox extends LitElement {
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
firstUpdated() {
this.invalid = !this.input.checkValidity();
}

View File

@@ -1,4 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import type SlColorPicker from './color-picker';
@@ -45,4 +45,34 @@ describe('<sl-color-picker>', () => {
expect(trigger?.style.color).to.equal('rgb(0, 0, 0)');
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-color-picker name="a" value="#FFFFFF"></sl-color-picker>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const colorPicker = form.querySelector('sl-color-picker')!;
colorPicker.value = '#000000';
await colorPicker.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await colorPicker.updateComplete;
expect(colorPicker.value).to.equal('#FFFFFF');
colorPicker.defaultValue = '';
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await colorPicker.updateComplete;
expect(colorPicker.value).to.equal('');
});
});
});

View File

@@ -11,6 +11,7 @@ import '../../components/dropdown/dropdown';
import '../../components/icon/icon';
import '../../components/input/input';
import '../../components/visually-hidden/visually-hidden';
import { defaultValue } from '../../internal/default-value';
import { drag } from '../../internal/drag';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
@@ -105,6 +106,10 @@ export default class SlColorPicker extends LitElement {
/** The current color. */
@property() value = '';
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
/* The color picker's label. This will not be displayed, but it will be announced by assistive devices. */
@property() label = '';

View File

@@ -1,4 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import { serialize } from '../../utilities/form';
@@ -124,6 +124,36 @@ describe('<sl-input>', () => {
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-input name="a" value="test"></sl-input>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const input = form.querySelector('sl-input')!;
input.value = '1234';
await input.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal('test');
input.defaultValue = '';
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal('');
});
});
describe('when calling HTMLFormElement.reportValidity()', () => {
it('should be invalid when the input is empty and form.reportValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import '../../components/icon/icon';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
@@ -77,6 +78,10 @@ export default class SlInput extends LitElement {
/** The input's value attribute. */
@property() value = '';
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
/** Draws a filled input. */
@property({ type: Boolean, reflect: true }) filled = false;

View File

@@ -97,6 +97,38 @@ describe('<sl-radio-button>', () => {
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio-button id="radio-1" name="a" value="1" checked></sl-radio-button>
<sl-radio-button id="radio-2" name="a" value="2"></sl-radio-button>
<sl-radio-button id="radio-3" name="a" value="3"></sl-radio-button>
</sl-radio-group>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio1: SlRadioButton = form.querySelector('#radio-1')!;
const radio2: SlRadioButton = form.querySelector('#radio-2')!;
radio2.click();
await radio2.updateComplete;
expect(radio2.checked).to.be.true;
expect(radio1.checked).to.be.false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radio1.updateComplete;
expect(radio1.checked).to.true;
expect(radio2.checked).to.false;
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { html } from 'lit/static-html.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
@@ -37,7 +38,9 @@ export default class SlRadioButton extends LitElement {
@query('.hidden-input') hiddenInput: HTMLInputElement;
protected readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlRadioButton) => (control.checked ? control.value : undefined)
value: (control: SlRadioButton) => (control.checked ? control.value : undefined),
defaultValue: (control: SlRadioButton) => control.defaultChecked,
setValue: (control: SlRadioButton, checked: boolean) => (control.checked = checked)
});
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@@ -61,6 +64,10 @@ export default class SlRadioButton extends LitElement {
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'radio');

View File

@@ -98,6 +98,36 @@ describe('<sl-radio>', () => {
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio name="a" value="1" checked></sl-radio>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelector('sl-radio')!;
radio.checked = false;
await radio.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radio.updateComplete;
expect(radio.checked).to.true;
radio.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radio.updateComplete;
expect(radio.checked).to.false;
});
});
it('should submit "on" when no value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { watch } from '../../internal/watch';
@@ -30,7 +31,9 @@ export default class SlRadio extends LitElement {
@query('.radio__input') input: HTMLInputElement;
protected readonly formSubmitController = new FormSubmitController(this, {
value: (control: HTMLInputElement) => (control.checked ? control.value || 'on' : undefined)
value: (control: SlRadio) => (control.checked ? control.value || 'on' : undefined),
defaultValue: (control: SlRadio) => control.defaultChecked,
setValue: (control: SlRadio, checked: boolean) => (control.checked = checked)
});
@state() protected hasFocus = false;
@@ -53,6 +56,10 @@ export default class SlRadio extends LitElement {
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'radio');

View File

@@ -0,0 +1,74 @@
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import { serialize } from '../../utilities/form';
import type SlRange from './range';
describe('<sl-range>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<SlRange>(html` <sl-range label="Name"></sl-range> `);
await expect(el).to.be.accessible();
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlRange>(html` <sl-range disabled></sl-range> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('[part="input"]')!;
expect(input.disabled).to.be.true;
});
it('should focus the input when clicking on the label', async () => {
const el = await fixture<SlRange>(html` <sl-range label="Name"></sl-range> `);
const label = el.shadowRoot!.querySelector('[part="form-control-label"]')!;
const submitHandler = sinon.spy();
el.addEventListener('sl-focus', submitHandler);
(label as HTMLLabelElement).click();
await waitUntil(() => submitHandler.calledOnce);
expect(submitHandler).to.have.been.calledOnce;
});
describe('when serializing', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-range name="a" value="1"></sl-range></form> `);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-range name="a" value="1"></sl-range></form> `);
const json = serialize(form);
expect(json.a).to.equal('1');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-range name="a" value="99"></sl-range>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const input = form.querySelector('sl-range')!;
input.value = 80;
await input.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal(99);
input.defaultValue = 0;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal(0);
});
});
});

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
@@ -87,6 +88,10 @@ export default class SlRange extends LitElement {
/** A function used to format the tooltip's value. */
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = 0;
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());

View File

@@ -130,4 +130,33 @@ describe('<sl-select>', () => {
expect(displayLabel.textContent?.trim()).to.equal('updated');
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-select value="option-1">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const select = form.querySelector('sl-select')!;
const option2 = form.querySelectorAll('sl-menu-item')![1];
option2.click();
await option2.updateComplete;
expect(select.value).to.equal('option-2');
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await select.updateComplete;
expect(select.value).to.equal('option-1');
});
});
});

View File

@@ -1,6 +1,7 @@
import { html, LitElement } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { defaultValue } from '../../internal/default-value';
import '../../components/dropdown/dropdown';
import '../../components/icon-button/icon-button';
import '../../components/icon/icon';
@@ -137,6 +138,10 @@ export default class SlSelect extends LitElement {
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.resizeMenu());

View File

@@ -59,4 +59,34 @@ describe('<sl-switch>', () => {
el.checked = false;
await el.updateComplete;
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-switch name="a" value="1" checked></sl-switch>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const switchEl = form.querySelector('sl-switch')!;
switchEl.checked = false;
await switchEl.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await switchEl.updateComplete;
expect(switchEl.checked).to.true;
switchEl.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await switchEl.updateComplete;
expect(switchEl.checked).to.false;
});
});
});

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { watch } from '../../internal/watch';
@@ -35,7 +36,9 @@ export default class SlSwitch extends LitElement {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlSwitch) => (control.checked ? control.value : undefined)
value: (control: SlSwitch) => (control.checked ? control.value : undefined),
defaultValue: (control: SlSwitch) => control.defaultChecked,
setValue: (control: SlSwitch, checked: boolean) => (control.checked = checked)
});
@state() private hasFocus = false;
@@ -58,6 +61,10 @@ export default class SlSwitch extends LitElement {
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
firstUpdated() {
this.invalid = !this.input.checkValidity();
}

View File

@@ -1,4 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import { serialize } from '../../utilities/form';
import type SlTextarea from './textarea';
@@ -71,4 +71,34 @@ describe('<sl-textarea>', () => {
expect(json.a).to.equal('1');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-textarea name="a" value="test"></sl-textarea>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const textarea = form.querySelector('sl-textarea')!;
textarea.value = '1234';
await textarea.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await textarea.updateComplete;
expect(textarea.value).to.equal('test');
textarea.defaultValue = '';
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await textarea.updateComplete;
expect(textarea.value).to.equal('');
});
});
});

View File

@@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
@@ -113,6 +114,10 @@ export default class SlTextarea extends LitElement {
/** The textarea's inputmode attribute. */
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight());

View File

@@ -0,0 +1,51 @@
// @defaultValue decorator
//
// Runs when the corresponding attribute of the observed property changes, e.g. after calling Element.setAttribute or after updating
// the observed property.
//
// The decorator checks whether the value of the attribute is different from the value of the property and in that case
// it saves the new value.
//
//
// Usage:
//
// @property({ type: Boolean, reflect: true })
// checked = false;
//
// @defaultValue('checked')
// defaultChecked = false;
//
import { defaultConverter } from 'lit';
import type { ReactiveElement } from 'lit';
export const defaultValue =
(propertyName = 'value') =>
(proto: ReactiveElement, key: string) => {
const ctor = proto.constructor as typeof ReactiveElement;
const attributeChangedCallback = ctor.prototype.attributeChangedCallback;
ctor.prototype.attributeChangedCallback = function (
this: ReactiveElement & { [name: string]: unknown },
name,
old,
value
) {
const options = ctor.getPropertyOptions(propertyName);
const attributeName = typeof options.attribute === 'string' ? options.attribute : propertyName;
if (name === attributeName) {
const converter = options.converter || defaultConverter;
const fromAttribute =
typeof converter === 'function' ? converter : converter?.fromAttribute ?? defaultConverter.fromAttribute;
const newValue: unknown = fromAttribute!(value, options.type);
if (this[propertyName] !== newValue) {
this[key] = newValue;
}
}
attributeChangedCallback.call(this, name, old, value);
};
};

View File

@@ -11,6 +11,8 @@ export interface FormSubmitControllerOptions {
name: (input: unknown) => string;
/** A function that returns the form control's current value. */
value: (input: unknown) => unknown | unknown[];
/** A function that returns the form control's default value. */
defaultValue: (input: unknown) => unknown | unknown[];
/** A function that returns the form control's current disabled state. If disabled, the value won't be submitted. */
disabled: (input: unknown) => boolean;
/**
@@ -18,6 +20,9 @@ export interface FormSubmitControllerOptions {
* prevent submission and trigger the browser's constraint violation warning.
*/
reportValidity: (input: unknown) => boolean;
/** A function that sets the form control's value */
setValue: (input: unknown, value: unknown) => void;
}
export class FormSubmitController implements ReactiveController {
@@ -31,14 +36,19 @@ export class FormSubmitController implements ReactiveController {
form: (input: HTMLInputElement) => input.closest('form'),
name: (input: HTMLInputElement) => input.name,
value: (input: HTMLInputElement) => input.value,
defaultValue: (input: HTMLInputElement) => input.defaultValue,
disabled: (input: HTMLInputElement) => input.disabled,
reportValidity: (input: HTMLInputElement) => {
return typeof input.reportValidity === 'function' ? input.reportValidity() : true;
},
setValue: (input: HTMLInputElement, value: string) => {
input.value = value;
},
...options
};
this.handleFormData = this.handleFormData.bind(this);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleFormReset = this.handleFormReset.bind(this);
this.reportFormValidity = this.reportFormValidity.bind(this);
}
@@ -48,6 +58,7 @@ export class FormSubmitController implements ReactiveController {
if (this.form) {
this.form.addEventListener('formdata', this.handleFormData);
this.form.addEventListener('submit', this.handleFormSubmit);
this.form.addEventListener('reset', this.handleFormReset);
// Overload the form's reportValidity() method so it looks at Shoelace form controls
if (!reportValidityOverloads.has(this.form)) {
@@ -61,6 +72,7 @@ export class FormSubmitController implements ReactiveController {
if (this.form) {
this.form.removeEventListener('formdata', this.handleFormData);
this.form.removeEventListener('submit', this.handleFormSubmit);
this.form.removeEventListener('reset', this.handleFormReset);
// Remove the overload and restore the original method
if (reportValidityOverloads.has(this.form)) {
@@ -98,6 +110,10 @@ export class FormSubmitController implements ReactiveController {
}
}
handleFormReset() {
this.options.setValue(this.host, this.options.defaultValue(this.host));
}
reportFormValidity() {
//
// Shoelace form controls work hard to act like regular form controls. They support the Constraint Validation API
@@ -128,13 +144,10 @@ export class FormSubmitController implements ReactiveController {
return true;
}
/** Submits the form, triggering validation and form data injection. */
submit(submitter?: HTMLInputElement | SlButton) {
// Calling form.submit() bypasses the submit event and constraint validation. To prevent this, we can inject a
// native submit button into the form, "click" it, then remove it to simulate a standard form submission.
doAction(type: 'submit' | 'reset', invoker?: HTMLInputElement | SlButton) {
if (this.form) {
const button = document.createElement('button');
button.type = 'submit';
button.type = type;
button.style.position = 'absolute';
button.style.width = '0';
button.style.height = '0';
@@ -143,10 +156,10 @@ export class FormSubmitController implements ReactiveController {
button.style.whiteSpace = 'nowrap';
// Pass form attributes through to the temporary button
if (submitter) {
if (invoker) {
['formaction', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => {
if (submitter.hasAttribute(attr)) {
button.setAttribute(attr, submitter.getAttribute(attr)!);
if (invoker.hasAttribute(attr)) {
button.setAttribute(attr, invoker.getAttribute(attr)!);
}
});
}
@@ -156,4 +169,16 @@ export class FormSubmitController implements ReactiveController {
button.remove();
}
}
/** Resets the form, restoring all the control to their default value */
reset(invoker?: HTMLInputElement | SlButton) {
this.doAction('reset', invoker);
}
/** Submits the form, triggering validation and form data injection. */
submit(invoker?: HTMLInputElement | SlButton) {
// Calling form.submit() bypasses the submit event and constraint validation. To prevent this, we can inject a
// native submit button into the form, "click" it, then remove it to simulate a standard form submission.
this.doAction('submit', invoker);
}
}