add form attribute; fixes #1130

pull/1128/head
Cory LaViska 2023-01-13 12:34:33 -05:00
rodzic 25c2d2d5bf
commit 6e26daf804
21 zmienionych plików z 346 dodań i 36 usunięć

Wyświetl plik

@ -45,6 +45,7 @@
"fieldsets",
"formaction",
"formdata",
"formenctype",
"formmethod",
"formnovalidate",
"formtarget",

Wyświetl plik

@ -4,6 +4,25 @@
```html preview
<sl-input></sl-input>
F1
<form id="f1" action="" method="GET" target="_blank">
<input type="hidden" name="b" value="2" />
<sl-button type="submit">Submit 1</sl-button>
</form>
<br />
F2
<form id="f2" action="" method="GET" target="_blank">
<input type="hidden" name="c" value="3" />
<sl-button type="submit">Submit 2</sl-button>
</form>
<br />
Custom
<sl-input id="mine" name="a" form="f1" value="123"></sl-input>
```
```jsx react

Wyświetl plik

@ -14,6 +14,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
- Added the `tag` part to `<sl-select>`
- Added `sl-hover` event to `<sl-rating>` [#1125](https://github.com/shoelace-style/shoelace/issues/1125)
- Added the `@documentation` tag with a link to the docs for each component
- Added the `form` attribute to all form controls to allow placing them outside of a `<form>` element [#1130](https://github.com/shoelace-style/shoelace/issues/1130)
- Fixed a bug in `<sl-select>` that prevented placeholders from showing when `multiple` was used [#1109](https://github.com/shoelace-style/shoelace/issues/1109)
- Fixed a bug in `<sl-select>` that caused tags to not be rounded when using the `pill` attribute [#1117](https://github.com/shoelace-style/shoelace/issues/1117)
- Fixed a bug in `<sl-select>` where the `sl-change` and `sl-input` events didn't weren't emitted when removing tags [#1119](https://github.com/shoelace-style/shoelace/issues/1119)

Wyświetl plik

@ -169,6 +169,21 @@ describe('<sl-checkbox>', () => {
const checkbox = await fixture<HTMLFormElement>(html` <sl-checkbox required checked></sl-checkbox> `);
expect(checkbox.checkValidity()).to.be.true;
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-checkbox form="f" name="a" value="1" checked></sl-checkbox>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
});
describe('when resetting a form', () => {

Wyświetl plik

@ -63,9 +63,6 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** Disables the checkbox. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Draws the checkbox in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
@ -78,6 +75,16 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue('checked') defaultChecked = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
firstUpdated() {
this.formControlController.updateValidity();
}

Wyświetl plik

@ -2,6 +2,7 @@ import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import { clickOnElement } from '../../internal/test';
import { serialize } from '../../utilities/form';
import type SlColorPicker from './color-picker';
describe('<sl-color-picker>', () => {
@ -323,6 +324,43 @@ describe('<sl-color-picker>', () => {
expect(previewColor).to.equal('#ff000050');
});
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-color-picker name="a" value="#ffcc00"></sl-color-picker>
</form>
`);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('#ffcc00');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-color-picker name="a" value="#ffcc00"></sl-color-picker>
</form>
`);
const json = serialize(form);
expect(json.a).to.equal('#ffcc00');
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-color-picker form="f" name="a" value="#ffcc00"></sl-color-picker>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('#ffcc00');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`

Wyświetl plik

@ -161,6 +161,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
*/
@property() swatches: string | string[] = '';
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
connectedCallback() {
super.connectedCallback();

Wyświetl plik

@ -155,7 +155,7 @@ describe('<sl-input>', () => {
});
});
describe('when serializing', () => {
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-input name="a" value="1"></sl-input></form> `);
const formData = new FormData(form);
@ -167,9 +167,7 @@ describe('<sl-input>', () => {
const json = serialize(form);
expect(json.a).to.equal('1');
});
});
describe('when submitting a form', () => {
it('should submit the form when pressing enter in a form without a submit button', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-input></sl-input></form> `);
const input = form.querySelector('sl-input')!;
@ -222,6 +220,48 @@ describe('<sl-input>', () => {
expect(input.hasAttribute('data-user-invalid')).to.be.true;
expect(input.hasAttribute('data-user-valid')).to.be.false;
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-input form="f" name="a" value="1"></sl-input>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
it('should submit with the correct form when the form attribute changes (FormControlController)', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f1">
<input type="hidden" name="b" value="2" />
<sl-button type="submit">Submit</sl-button>
</form>
<form id="f2">
<input type="hidden" name="c" value="3" />
<sl-button type="submit">Submit</sl-button>
</form>
<sl-input form="f1" name="a" value="1"></sl-input>
</div>
`);
const form = el.querySelector<HTMLFormElement>('#f2')!;
const input = document.querySelector('sl-input')!;
input.form = 'f2';
await input.updateComplete;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
expect(formData.get('b')).to.be.null;
expect(formData.get('c')).to.equal('3');
});
});
describe('when resetting a form', () => {

Wyświetl plik

@ -133,6 +133,13 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true }) required = false;

Wyświetl plik

@ -210,6 +210,25 @@ describe('when submitting a form', () => {
expect(formData!.get('a')).to.equal('2');
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-radio-group form="f" name="a" value="1">
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
<sl-radio id="radio-3" value="3"></sl-radio>
</sl-radio-group>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
});
describe('when the value changes', () => {

Wyświetl plik

@ -65,6 +65,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** The current value of the radio group, submitted as a name/value pair with form data. */
@property({ reflect: true }) value = '';
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;

Wyświetl plik

@ -138,6 +138,18 @@ describe('<sl-range>', () => {
});
describe('when submitting a form', () => {
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');
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const range = await fixture<HTMLFormElement>(html` <sl-range></sl-range> `);
@ -156,19 +168,20 @@ describe('<sl-range>', () => {
expect(range.hasAttribute('data-user-invalid')).to.be.true;
expect(range.hasAttribute('data-user-valid')).to.be.false;
});
});
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> `);
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-range form="f" name="a" value="50"></sl-range>
</div>
`);
const form = el.querySelector('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');
expect(formData.get('a')).to.equal('50');
});
});

Wyświetl plik

@ -91,6 +91,13 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
*/
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = 0;

Wyświetl plik

@ -296,7 +296,7 @@ describe('<sl-select>', () => {
});
});
describe('when serializing', () => {
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
@ -353,6 +353,25 @@ describe('<sl-select>', () => {
const json = serialize(form);
expect(JSON.stringify(json)).to.equal(JSON.stringify({ a: ['option-2', 'option-3'] }));
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-select form="f" name="a" value="option-1">
<sl-option value="option-1">Option 1</sl-option>
<sl-option value="option-2">Option 2</sl-option>
<sl-option value="option-3">Option 3</sl-option>
</sl-select>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('option-1');
});
});
describe('when resetting a form', () => {

Wyświetl plik

@ -150,6 +150,13 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;

Wyświetl plik

@ -187,6 +187,21 @@ describe('<sl-switch>', () => {
const slSwitch = await fixture<HTMLFormElement>(html` <sl-switch required checked></sl-switch> `);
expect(slSwitch.checkValidity()).to.be.true;
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-switch form="f" name="a" value="1" checked></sl-switch>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
});
describe('when resetting a form', () => {

Wyświetl plik

@ -60,15 +60,22 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
/** Disables the switch. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Makes the switch a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Draws the switch in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue('checked') defaultChecked = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the switch a required field. */
@property({ type: Boolean, reflect: true }) required = false;
firstUpdated() {
this.formControlController.updateValidity();
}

Wyświetl plik

@ -173,7 +173,7 @@ describe('<sl-textarea>', () => {
});
});
describe('when serializing', () => {
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><sl-textarea name="a" value="1"></sl-textarea></form> `);
const formData = new FormData(form);
@ -185,6 +185,41 @@ describe('<sl-textarea>', () => {
const json = serialize(form);
expect(json.a).to.equal('1');
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const textarea = await fixture<HTMLFormElement>(html` <sl-textarea></sl-textarea> `);
textarea.setCustomValidity('Invalid selection');
await textarea.updateComplete;
expect(textarea.checkValidity()).to.be.false;
expect(textarea.hasAttribute('data-invalid')).to.be.true;
expect(textarea.hasAttribute('data-valid')).to.be.false;
expect(textarea.hasAttribute('data-user-invalid')).to.be.false;
expect(textarea.hasAttribute('data-user-valid')).to.be.false;
textarea.focus();
await sendKeys({ type: 'test' });
await textarea.updateComplete;
expect(textarea.hasAttribute('data-user-invalid')).to.be.true;
expect(textarea.hasAttribute('data-user-valid')).to.be.false;
});
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
const el = await fixture<HTMLFormElement>(html`
<div>
<form id="f">
<sl-button type="submit">Submit</sl-button>
</form>
<sl-textarea form="f" name="a" value="1"></sl-textarea>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
});
describe('when resetting a form', () => {

Wyświetl plik

@ -79,6 +79,13 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
/** Makes the textarea readonly. */
@property({ type: Boolean, reflect: true }) readonly = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
* the same document or shadow root for this to work.
*/
@property({ reflect: true }) form = '';
/** Makes the textarea a required field. */
@property({ type: Boolean, reflect: true }) required = false;

Wyświetl plik

@ -49,7 +49,19 @@ export class FormControlController implements ReactiveController {
constructor(host: ReactiveControllerHost & ShoelaceFormControl, options?: Partial<FormControlControllerOptions>) {
(this.host = host).addController(this);
this.options = {
form: input => input.closest('form'),
form: input => {
// If there's a form attribute, use it to find the target form by id
if (input.hasAttribute('form') && input.getAttribute('form') !== '') {
const root = input.getRootNode() as Document | ShadowRoot;
const formId = input.getAttribute('form');
if (formId) {
return root.getElementById(formId) as HTMLFormElement;
}
}
return input.closest('form');
},
name: input => input.name,
value: input => input.value,
defaultValue: input => input.defaultValue,
@ -66,9 +78,43 @@ export class FormControlController implements ReactiveController {
}
hostConnected() {
this.form = this.options.form(this.host);
const form = this.options.form(this.host);
if (form) {
this.attachForm(form);
}
this.host.addEventListener('sl-input', this.handleUserInput);
}
hostDisconnected() {
this.detachForm();
this.host.removeEventListener('sl-input', this.handleUserInput);
}
hostUpdated() {
const form = this.options.form(this.host);
// Detach if the form no longer exists
if (!form) {
this.detachForm();
}
// If the form has changed, reattach it
if (form && this.form !== form) {
this.detachForm();
this.attachForm(form);
}
if (this.host.hasUpdated) {
this.setValidity(this.host.checkValidity());
}
}
private attachForm(form?: HTMLFormElement) {
if (form) {
this.form = form;
if (this.form) {
// Add this element to the form's collection
if (formCollections.has(this.form)) {
formCollections.get(this.form)!.add(this.host);
@ -85,12 +131,12 @@ export class FormControlController implements ReactiveController {
reportValidityOverloads.set(this.form, this.form.reportValidity);
this.form.reportValidity = () => this.reportFormValidity();
}
} else {
this.form = undefined;
}
this.host.addEventListener('sl-input', this.handleUserInput);
}
hostDisconnected() {
private detachForm() {
if (this.form) {
// Remove this element from the form's collection
formCollections.get(this.form)?.delete(this.host);
@ -104,17 +150,9 @@ export class FormControlController implements ReactiveController {
this.form.reportValidity = reportValidityOverloads.get(this.form)!;
reportValidityOverloads.delete(this.form);
}
this.form = undefined;
}
this.host.removeEventListener('sl-input', this.handleUserInput);
}
hostUpdated() {
if (this.host.hasUpdated) {
this.setValidity(this.host.checkValidity());
}
this.form = undefined;
}
private handleFormData(event: FormDataEvent) {

Wyświetl plik

@ -29,6 +29,7 @@ export interface ShoelaceFormControl extends ShoelaceElement {
disabled?: boolean;
defaultValue?: unknown;
defaultChecked?: boolean;
form?: string;
// Standard validation attributes
pattern?: string;