diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 52bf425a7..df78631fe 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -32,9 +32,12 @@ New versions of Shoelace are released as-needed and generally occur when a criti - Added `--sl-input-required-content-color` custom property to all form controls [#948](https://github.com/shoelace-style/shoelace/pull/948) - Added the ability to cancel `sl-show` and `sl-hide` events in `` [#993](https://github.com/shoelace-style/shoelace/issues/993) - Added `focus()` and `blur()` methods to `` +- Added `stepUp()` and `stepDown()` methods to `` and `` [#1013](https://github.com/shoelace-style/shoelace/pull/1013) +- Added `showPicker()` method to `` [#1013](https://github.com/shoelace-style/shoelace/pull/1013) - Added the `handle-icon` part to `` - Added `caret`, `check`, `grip-vertical`, `indeterminate`, and `radio` icons to the system library and removed `check-lg` [#985](https://github.com/shoelace-style/shoelace/issues/985) - Added the `loading` attribute to `` to allow lazy loading of image avatars [#1006](https://github.com/shoelace-style/shoelace/pull/1006) +- Added `formenctype` attribute to `` [#1009](https://github.com/shoelace-style/shoelace/pull/1009) - Added `exports` to `package.json` and removed the `main` and `module` properties [#1007](https://github.com/shoelace-style/shoelace/pull/1007) - Fixed a bug in `` that prevented the border radius to apply correctly to the header [#934](https://github.com/shoelace-style/shoelace/pull/934) - Fixed a bug in `` where the inner border disappeared on focus [#980](https://github.com/shoelace-style/shoelace/pull/980) diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 191a5cd3e..8191a15e6 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -116,6 +116,10 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon /** Used to override the form owner's `action` attribute. */ @property({ attribute: 'formaction' }) formAction: string; + /** Used to override the form owner's `enctype` attribute. */ + @property({ attribute: 'formenctype' }) + formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'; + /** Used to override the form owner's `method` attribute. */ @property({ attribute: 'formmethod' }) formMethod: 'post' | 'get'; diff --git a/src/components/input/input.test.ts b/src/components/input/input.test.ts index ce59b74cc..8d2ff7737 100644 --- a/src/components/input/input.test.ts +++ b/src/components/input/input.test.ts @@ -10,6 +10,43 @@ describe('', () => { await expect(el).to.be.accessible(); }); + it('default properties', async () => { + const el = await fixture(html` `); + + expect(el.type).to.equal('text'); + expect(el.size).to.equal('medium'); + expect(el.name).to.equal(''); + expect(el.value).to.equal(''); + expect(el.defaultValue).to.equal(''); + expect(el.filled).to.be.false; + expect(el.pill).to.be.false; + expect(el.label).to.equal(''); + expect(el.helpText).to.equal(''); + expect(el.clearable).to.be.false; + expect(el.passwordToggle).to.be.false; + expect(el.passwordVisible).to.be.false; + expect(el.noSpinButtons).to.be.false; + expect(el.placeholder).to.equal(''); + expect(el.disabled).to.be.false; + expect(el.readonly).to.be.false; + expect(el.minlength).to.be.undefined; + expect(el.maxlength).to.be.undefined; + expect(el.min).to.be.undefined; + expect(el.max).to.be.undefined; + expect(el.step).to.be.undefined; + expect(el.pattern).to.be.undefined; + expect(el.required).to.be.false; + expect(el.autocapitalize).to.be.undefined; + expect(el.autocorrect).to.be.undefined; + expect(el.autocomplete).to.be.undefined; + expect(el.autofocus).to.be.undefined; + expect(el.enterkeyhint).to.be.undefined; + expect(el.spellcheck).to.be.undefined; + expect(el.inputmode).to.be.undefined; + expect(el.valueAsDate).to.be.null; + expect(isNaN(el.valueAsNumber)).to.be.true; + }); + it('should be disabled with the disabled attribute', async () => { const el = await fixture(html` `); const input = el.shadowRoot!.querySelector('[part~="input"]')!; @@ -208,5 +245,52 @@ describe('', () => { await el.updateComplete; expect(el.invalid).to.be.true; }); + + it('should increment by step if stepUp() is called', async () => { + const el = await fixture(html` `); + + el.stepUp(); + await el.updateComplete; + expect(el.value).to.equal('4'); + }); + + it('should decrement by step if stepDown() is called', async () => { + const el = await fixture(html` `); + + el.stepDown(); + await el.updateComplete; + expect(el.value).to.equal('0'); + }); + + it('should fire sl-input and sl-change if stepUp() is called', async () => { + const el = await fixture(html` `); + + const inputHandler = sinon.spy(); + const changeHandler = sinon.spy(); + el.addEventListener('sl-input', inputHandler); + el.addEventListener('sl-change', changeHandler); + + el.stepUp(); + + await waitUntil(() => inputHandler.calledOnce); + await waitUntil(() => changeHandler.calledOnce); + expect(inputHandler).to.have.been.calledOnce; + expect(changeHandler).to.have.been.calledOnce; + }); + + it('should fire sl-input and sl-change if stepDown() is called', async () => { + const el = await fixture(html` `); + + const inputHandler = sinon.spy(); + const changeHandler = sinon.spy(); + el.addEventListener('sl-input', inputHandler); + el.addEventListener('sl-change', changeHandler); + + el.stepUp(); + + await waitUntil(() => inputHandler.calledOnce); + await waitUntil(() => changeHandler.calledOnce); + expect(changeHandler).to.have.been.calledOnce; + }); }); }); diff --git a/src/components/input/input.ts b/src/components/input/input.ts index a2d0cd163..d7f6a3252 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -88,7 +88,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** The input's name attribute. */ - @property() name: string; + @property() name = ''; /** The input's value attribute. */ @property() value = ''; @@ -121,7 +121,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont @property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false; /** The input's placeholder text. */ - @property() placeholder: string; + @property() placeholder = ''; /** Disables the input. */ @property({ type: Boolean, reflect: true }) disabled = false; @@ -247,6 +247,33 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont } } + /** Displays the browser picker for an input element (only works if the browser supports it for the input type). */ + showPicker() { + if ('showPicker' in HTMLInputElement.prototype) { + this.input.showPicker(); + } + } + + /** Increments the value of a numeric input type by the value of the step attribute. */ + stepUp() { + this.input.stepUp(); + if (this.value !== this.input.value) { + this.value = this.input.value; + this.emit('sl-input'); + this.emit('sl-change'); + } + } + + /** Decrements the value of a numeric input type by the value of the step attribute. */ + stepDown() { + this.input.stepDown(); + if (this.value !== this.input.value) { + this.value = this.input.value; + this.emit('sl-input'); + this.emit('sl-change'); + } + } + /** Checks for validity but does not show the browser's validation message. */ checkValidity() { return this.input.checkValidity(); diff --git a/src/components/range/range.test.ts b/src/components/range/range.test.ts index 02a4cd9b1..b737b1b64 100644 --- a/src/components/range/range.test.ts +++ b/src/components/range/range.test.ts @@ -1,4 +1,5 @@ -import { expect, fixture, html, oneEvent } 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 SlRange from './range'; @@ -8,6 +9,22 @@ describe('', () => { await expect(el).to.be.accessible(); }); + it('default properties', async () => { + const el = await fixture(html` `); + + expect(el.name).to.equal(''); + expect(el.value).to.equal(0); + expect(el.label).to.equal(''); + expect(el.helpText).to.equal(''); + expect(el.disabled).to.be.false; + expect(el.invalid).to.be.false; + expect(el.min).to.equal(0); + expect(el.max).to.equal(100); + expect(el.step).to.equal(1); + expect(el.tooltip).to.equal('top'); + expect(el.defaultValue).to.equal(0); + }); + it('should be disabled with the disabled attribute', async () => { const el = await fixture(html` `); const input = el.shadowRoot!.querySelector('[part~="input"]')!; @@ -15,6 +32,44 @@ describe('', () => { expect(input.disabled).to.be.true; }); + describe('step', () => { + it('should increment by step if stepUp() is called', async () => { + const el = await fixture(html` `); + + el.stepUp(); + await el.updateComplete; + expect(el.value).to.equal(4); + }); + + it('should decrement by step if stepDown() is called', async () => { + const el = await fixture(html` `); + + el.stepDown(); + await el.updateComplete; + expect(el.value).to.equal(0); + }); + + it('should fire sl-change if stepUp() is called', async () => { + const el = await fixture(html` `); + + const changeHandler = sinon.spy(); + el.addEventListener('sl-change', changeHandler); + el.stepUp(); + await waitUntil(() => changeHandler.calledOnce); + expect(changeHandler).to.have.been.calledOnce; + }); + + it('should fire sl-change if stepDown() is called', async () => { + const el = await fixture(html` `); + + const changeHandler = sinon.spy(); + el.addEventListener('sl-change', changeHandler); + el.stepUp(); + await waitUntil(() => changeHandler.calledOnce); + expect(changeHandler).to.have.been.calledOnce; + }); + }); + describe('when serializing', () => { it('should serialize its name and value with FormData', async () => { const form = await fixture(html`
`); diff --git a/src/components/range/range.ts b/src/components/range/range.ts index 1702dd847..0133843e4 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -123,6 +123,24 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont this.input.blur(); } + /** Increments the value of the input by the value of the step attribute. */ + stepUp() { + this.input.stepUp(); + if (this.value !== Number(this.input.value)) { + this.value = Number(this.input.value); + this.emit('sl-change'); + } + } + + /** Decrements the value of the input by the value of the step attribute. */ + stepDown() { + this.input.stepDown(); + if (this.value !== Number(this.input.value)) { + this.value = Number(this.input.value); + this.emit('sl-change'); + } + } + /** Checks for validity but does not show the browser's validation message. */ checkValidity() { return this.input.checkValidity(); diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 32f57a3fb..0b85af49d 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -51,7 +51,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** The textarea's name attribute. */ - @property() name: string; + @property() name = ''; /** The textarea's value attribute. */ @property() value = ''; @@ -66,7 +66,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC @property({ attribute: 'help-text' }) helpText = ''; /** The textarea's placeholder text. */ - @property() placeholder: string; + @property() placeholder = ''; /** The number of rows to display by default. */ @property({ type: Number }) rows = 4; diff --git a/src/declaration.d.ts b/src/declaration.d.ts index d16b948d5..2b9065278 100644 --- a/src/declaration.d.ts +++ b/src/declaration.d.ts @@ -10,3 +10,7 @@ declare namespace Chai { accessible: (options?: Object) => PromiseLike; } } + +interface HTMLInputElement { + showPicker: () => void; +} diff --git a/src/internal/form.ts b/src/internal/form.ts index 8089a9439..042cc6d96 100644 --- a/src/internal/form.ts +++ b/src/internal/form.ts @@ -234,7 +234,7 @@ export class FormSubmitController implements ReactiveController { // Pass form attributes through to the temporary button if (invoker) { - ['formaction', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => { + ['formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => { if (invoker.hasAttribute(attr)) { button.setAttribute(attr, invoker.getAttribute(attr)!); }