From 6e26daf80441f160994eb086a71444afa471a9a4 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 13 Jan 2023 12:34:33 -0500 Subject: [PATCH] add form attribute; fixes #1130 --- cspell.json | 1 + docs/components/input.md | 19 ++++++ docs/resources/changelog.md | 1 + src/components/checkbox/checkbox.test.ts | 15 ++++ src/components/checkbox/checkbox.ts | 13 +++- .../color-picker/color-picker.test.ts | 38 +++++++++++ src/components/color-picker/color-picker.ts | 7 ++ src/components/input/input.test.ts | 46 ++++++++++++- src/components/input/input.ts | 7 ++ .../radio-group/radio-group.test.ts | 19 ++++++ src/components/radio-group/radio-group.ts | 7 ++ src/components/range/range.test.ts | 33 ++++++--- src/components/range/range.ts | 7 ++ src/components/select/select.test.ts | 21 +++++- src/components/select/select.ts | 7 ++ src/components/switch/switch.test.ts | 15 ++++ src/components/switch/switch.ts | 13 +++- src/components/textarea/textarea.test.ts | 37 +++++++++- src/components/textarea/textarea.ts | 7 ++ src/internal/form.ts | 68 +++++++++++++++---- src/internal/shoelace-element.ts | 1 + 21 files changed, 346 insertions(+), 36 deletions(-) diff --git a/cspell.json b/cspell.json index 9ed67b7f..32ba3d08 100644 --- a/cspell.json +++ b/cspell.json @@ -45,6 +45,7 @@ "fieldsets", "formaction", "formdata", + "formenctype", "formmethod", "formnovalidate", "formtarget", diff --git a/docs/components/input.md b/docs/components/input.md index f28ac059..a19ee26b 100644 --- a/docs/components/input.md +++ b/docs/components/input.md @@ -4,6 +4,25 @@ ```html preview + +F1 +
+ + Submit 1 +
+ +
+ +F2 +
+ + Submit 2 +
+ +
+ +Custom + ``` ```jsx react diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 030ffe93..bd4e9715 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -14,6 +14,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti - Added the `tag` part to `` - Added `sl-hover` event to `` [#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 `
` element [#1130](https://github.com/shoelace-style/shoelace/issues/1130) - Fixed a bug in `` that prevented placeholders from showing when `multiple` was used [#1109](https://github.com/shoelace-style/shoelace/issues/1109) - Fixed a bug in `` 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 `` 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) diff --git a/src/components/checkbox/checkbox.test.ts b/src/components/checkbox/checkbox.test.ts index da5d0ecb..117746ca 100644 --- a/src/components/checkbox/checkbox.test.ts +++ b/src/components/checkbox/checkbox.test.ts @@ -169,6 +169,21 @@ describe('', () => { const checkbox = await fixture(html` `); expect(checkbox.checkValidity()).to.be.true; }); + + it('should be present in form data when using the form attribute and located outside of a ', async () => { + const el = await fixture(html` +
+ + Submit + + +
+ `); + const form = el.querySelector('form')!; + const formData = new FormData(form); + + expect(formData.get('a')).to.equal('1'); + }); }); describe('when resetting a form', () => { diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index d988181f..bb95f0a2 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -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 `
` 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(); } diff --git a/src/components/color-picker/color-picker.test.ts b/src/components/color-picker/color-picker.test.ts index 367cd495..80494118 100644 --- a/src/components/color-picker/color-picker.test.ts +++ b/src/components/color-picker/color-picker.test.ts @@ -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('', () => { @@ -323,6 +324,43 @@ describe('', () => { expect(previewColor).to.equal('#ff000050'); }); + describe('when submitting a form', () => { + it('should serialize its name and value with FormData', async () => { + const form = await fixture(html` + + + + `); + 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(html` +
+ +
+ `); + 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
', async () => { + const el = await fixture(html` +
+ + Submit + + +
+ `); + 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(html` diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index b85b6673..2546ee5f 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -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 `
` 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(); diff --git a/src/components/input/input.test.ts b/src/components/input/input.test.ts index f0b2ea00..96b9d6b0 100644 --- a/src/components/input/input.test.ts +++ b/src/components/input/input.test.ts @@ -155,7 +155,7 @@ describe('', () => { }); }); - describe('when serializing', () => { + describe('when submitting a form', () => { it('should serialize its name and value with FormData', async () => { const form = await fixture(html` `); const formData = new FormData(form); @@ -167,9 +167,7 @@ describe('', () => { 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(html`
`); const input = form.querySelector('sl-input')!; @@ -222,6 +220,48 @@ describe('', () => { 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
', async () => { + const el = await fixture(html` +
+ + Submit + + +
+ `); + 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(html` +
+
+ + Submit +
+
+ + Submit +
+ +
+ `); + const form = el.querySelector('#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', () => { diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 03ad4c17..6e681f69 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -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 `
` 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; diff --git a/src/components/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts index aadb3e3c..2902a8f6 100644 --- a/src/components/radio-group/radio-group.test.ts +++ b/src/components/radio-group/radio-group.test.ts @@ -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 ', async () => { + const el = await fixture(html` +
+ + Submit + + + + + + +
+ `); + const form = el.querySelector('form')!; + const formData = new FormData(form); + + expect(formData.get('a')).to.equal('1'); + }); }); describe('when the value changes', () => { diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index 5e93d38b..a06e3125 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -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 `
` 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; diff --git a/src/components/range/range.test.ts b/src/components/range/range.test.ts index 6808f226..ff612212 100644 --- a/src/components/range/range.test.ts +++ b/src/components/range/range.test.ts @@ -138,6 +138,18 @@ describe('', () => { }); describe('when submitting a form', () => { + it('should serialize its name and value with FormData', async () => { + const form = await fixture(html` `); + 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(html`
`); + 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(html` `); @@ -156,19 +168,20 @@ describe('', () => { 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(html`
`); + it('should be present in form data when using the form attribute and located outside of a
', async () => { + const el = await fixture(html` +
+ + Submit + + +
+ `); + 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(html`
`); - const json = serialize(form); - expect(json.a).to.equal('1'); + expect(formData.get('a')).to.equal('50'); }); }); diff --git a/src/components/range/range.ts b/src/components/range/range.ts index d5b2b132..fe240e2f 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -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 `
` 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; diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index b1a4cd1a..f51353e3 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -296,7 +296,7 @@ describe('', () => { }); }); - describe('when serializing', () => { + describe('when submitting a form', () => { it('should serialize its name and value with FormData', async () => { const form = await fixture(html` @@ -353,6 +353,25 @@ describe('', () => { 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 ', async () => { + const el = await fixture(html` +
+ + Submit + + + Option 1 + Option 2 + Option 3 + +
+ `); + const form = el.querySelector('form')!; + const formData = new FormData(form); + + expect(formData.get('a')).to.equal('option-1'); + }); }); describe('when resetting a form', () => { diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 955e8895..112ca637 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -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 `
` 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; diff --git a/src/components/switch/switch.test.ts b/src/components/switch/switch.test.ts index c2424a38..738b6db4 100644 --- a/src/components/switch/switch.test.ts +++ b/src/components/switch/switch.test.ts @@ -187,6 +187,21 @@ describe('', () => { const slSwitch = await fixture(html` `); expect(slSwitch.checkValidity()).to.be.true; }); + + it('should be present in form data when using the form attribute and located outside of a ', async () => { + const el = await fixture(html` +
+ + Submit + + +
+ `); + const form = el.querySelector('form')!; + const formData = new FormData(form); + + expect(formData.get('a')).to.equal('1'); + }); }); describe('when resetting a form', () => { diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index 21f14edc..8447d18e 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -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 `
` 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(); } diff --git a/src/components/textarea/textarea.test.ts b/src/components/textarea/textarea.test.ts index ae492968..626c97da 100644 --- a/src/components/textarea/textarea.test.ts +++ b/src/components/textarea/textarea.test.ts @@ -173,7 +173,7 @@ describe('', () => { }); }); - describe('when serializing', () => { + describe('when submitting a form', () => { it('should serialize its name and value with FormData', async () => { const form = await fixture(html` `); const formData = new FormData(form); @@ -185,6 +185,41 @@ describe('', () => { 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(html` `); + + 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
', async () => { + const el = await fixture(html` +
+ + Submit + + +
+ `); + const form = el.querySelector('form')!; + const formData = new FormData(form); + + expect(formData.get('a')).to.equal('1'); + }); }); describe('when resetting a form', () => { diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index a23d5818..7a93c671 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -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 `
` 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; diff --git a/src/internal/form.ts b/src/internal/form.ts index db8c1096..d9003541 100644 --- a/src/internal/form.ts +++ b/src/internal/form.ts @@ -49,7 +49,19 @@ export class FormControlController implements ReactiveController { constructor(host: ReactiveControllerHost & ShoelaceFormControl, options?: Partial) { (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) { diff --git a/src/internal/shoelace-element.ts b/src/internal/shoelace-element.ts index faadbebb..2188e9e9 100644 --- a/src/internal/shoelace-element.ts +++ b/src/internal/shoelace-element.ts @@ -29,6 +29,7 @@ export interface ShoelaceFormControl extends ShoelaceElement { disabled?: boolean; defaultValue?: unknown; defaultChecked?: boolean; + form?: string; // Standard validation attributes pattern?: string;