diff --git a/slider/lib/slider.ts b/slider/lib/slider.ts index cda2ba78b2..d9153652eb 100644 --- a/slider/lib/slider.ts +++ b/slider/lib/slider.ts @@ -17,39 +17,17 @@ import {when} from 'lit/directives/when.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../internal/controller/events.js'; -import {FormController, getFormValue} from '../../internal/controller/form-controller.js'; -import {stringConverter} from '../../internal/controller/string-converter.js'; import {MdRipple} from '../../ripple/ripple.js'; // Disable warning for classMap with destructuring // tslint:disable:quoted-properties-on-dictionary -function inBounds({x, y}: PointerEvent, element?: HTMLElement|null) { - if (!element) { - return false; - } - const {top, left, bottom, right} = element.getBoundingClientRect(); - return x >= left && x <= right && y >= top && y <= bottom; -} - -function isOverlapping(elA: Element|null, elB: Element|null) { - if (!(elA && elB)) { - return false; - } - const a = elA.getBoundingClientRect(); - const b = elB.getBoundingClientRect(); - return !( - a.top > b.bottom || a.right < b.left || a.bottom < b.top || - a.left > b.right); -} - -interface Action { - canFlip: boolean; - flipped: boolean; - target: HTMLInputElement; - fixed: HTMLInputElement; - values: Map; -} +/** The default value for a continuous slider. */ +const DEFAULT_VALUE = 50; +/** The default start value for a range slider. */ +const DEFAULT_VALUE_START = 25; +/** The default end value for a range slider. */ +const DEFAULT_VALUE_END = 75; /** * Slider component. @@ -86,17 +64,19 @@ export class Slider extends LitElement { /** * The slider value displayed when range is false. */ - @property({type: Number}) value = 50; + @property({type: Number}) value = DEFAULT_VALUE; /** * The slider start value displayed when range is true. */ - @property({type: Number}) valueStart = 25; + @property({type: Number, attribute: 'value-start'}) + valueStart = DEFAULT_VALUE_START; /** * The slider end value displayed when range is true. */ - @property({type: Number}) valueEnd = 75; + @property({type: Number, attribute: 'value-end'}) + valueEnd = DEFAULT_VALUE_END; /** * An optional label for the slider's value displayed when range is @@ -153,13 +133,49 @@ export class Slider extends LitElement { /** * The HTML name to use in form submission. */ - @property({reflect: true, converter: stringConverter}) name = ''; + get name() { + return this.getAttribute('name') ?? ''; + } + set name(name: string) { + this.setAttribute('name', name); + } + + /** + * The HTML name to use in form submission for a range slider's starting + * value. Use `name` instead if both the start and end values should use the + * same name. + */ + get nameStart() { + return this.getAttribute('name-start') ?? this.name; + } + set nameStart(name: string) { + this.setAttribute('name-start', name); + } + + /** + * The HTML name to use in form submission for a range slider's ending value. + * Use `name` instead if both the start and end values should use the same + * name. + */ + get nameEnd() { + return this.getAttribute('name-end') ?? this.nameStart; + } + set nameEnd(name: string) { + this.setAttribute('name-end', name); + } /** * The associated form element with which this element's value will submit. */ get form() { - return this.closest('form'); + return this.internals.form; + } + + /** + * The labels this element is associated with. + */ + get labels() { + return this.internals.labels; } @query('input.start') private readonly inputStart!: HTMLInputElement|null; @@ -193,9 +209,11 @@ export class Slider extends LitElement { private action?: Action; + private readonly internals = + (this as HTMLElement /* needed for closure */).attachInternals(); + constructor() { super(); - this.addController(new FormController(this)); if (!isServer) { this.addEventListener('click', (event: MouseEvent) => { if (!isActivationClick(event) || !this.inputEnd) { @@ -211,12 +229,6 @@ export class Slider extends LitElement { this.inputEnd?.focus(); } - // value coerced to a string - [getFormValue]() { - return this.range ? `${this.valueStart}, ${this.valueEnd}` : - `${this.value}`; - } - protected override willUpdate(changed: PropertyValues) { this.renderValueStart = changed.has('valueStart') ? this.valueStart : @@ -235,6 +247,22 @@ export class Slider extends LitElement { } } + protected override update(changed: PropertyValues) { + if (changed.has('value') || changed.has('range') || + changed.has('valueStart') || changed.has('valueEnd')) { + if (this.range) { + const data = new FormData(); + data.append(this.nameStart, String(this.valueStart)); + data.append(this.nameEnd, String(this.valueEnd)); + this.internals.setFormValue(data); + } else { + this.internals.setFormValue(String(this.value)); + } + } + + super.update(changed); + } + protected override updated(changed: PropertyValues) { // Validate input rendered value and re-render if necessary. This ensures // the rendred handle stays in sync with the input thumb which is used for @@ -590,4 +618,58 @@ export class Slider extends LitElement { // ensure keyboard triggered change clears action. this.finishAction(e); } + + /** @private */ + formResetCallback() { + if (this.range) { + this.valueStart = + Number(this.getAttribute('value-start') ?? DEFAULT_VALUE_START); + this.valueEnd = + Number(this.getAttribute('value-end') ?? DEFAULT_VALUE_END); + return; + } + + this.value = Number(this.getAttribute('value') ?? DEFAULT_VALUE); + } + + /** @private */ + formStateRestoreCallback(state: string|Array<[string, string]>|null) { + if (Array.isArray(state)) { + const [[, valueStart], [, valueEnd]] = state; + this.valueStart = Number(valueStart ?? DEFAULT_VALUE_START); + this.valueEnd = Number(valueEnd ?? DEFAULT_VALUE_START); + this.range = true; + return; + } + + this.value = Number(state ?? DEFAULT_VALUE); + this.range = false; + } +} + +function inBounds({x, y}: PointerEvent, element?: HTMLElement|null) { + if (!element) { + return false; + } + const {top, left, bottom, right} = element.getBoundingClientRect(); + return x >= left && x <= right && y >= top && y <= bottom; +} + +function isOverlapping(elA: Element|null, elB: Element|null) { + if (!(elA && elB)) { + return false; + } + const a = elA.getBoundingClientRect(); + const b = elB.getBoundingClientRect(); + return !( + a.top > b.bottom || a.right < b.left || a.bottom < b.top || + a.left > b.right); +} + +interface Action { + canFlip: boolean; + flipped: boolean; + target: HTMLInputElement; + fixed: HTMLInputElement; + values: Map; } diff --git a/slider/slider_test.ts b/slider/slider_test.ts index 6852e96302..f4457bd33d 100644 --- a/slider/slider_test.ts +++ b/slider/slider_test.ts @@ -7,6 +7,7 @@ import {html} from 'lit'; import {Environment} from '../testing/environment.js'; +import {createFormTests} from '../testing/forms.js'; import {createTokenTests} from '../testing/tokens.js'; import {SliderHarness} from './harness.js'; @@ -344,4 +345,140 @@ describe('', () => { expect(input.matches(':focus')).toBe(true); }); }); + + describe('forms', () => { + createFormTests({ + queryControl: root => root.querySelector('md-slider'), + valueTests: [ + { + name: 'unnamed', + render: () => html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form without a name') + .toHaveSize(0); + } + }, + { + name: 'single value', + render: () => html``, + assertValue(formData) { + expect(formData.get('slider')).toBe('10'); + } + }, + { + name: 'multiple values same name', + render: () => + html``, + assertValue(formData) { + expect(formData.getAll('slider')).toEqual(['0', '10']); + } + }, + { + name: 'multiple values different names', + render: () => + html``, + assertValue(formData) { + expect(formData.get('slider-start')).toBe('0'); + expect(formData.get('slider-end')).toBe('10'); + } + }, + { + name: 'disabled', + render: () => + html``, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when disabled') + .toHaveSize(0); + } + } + ], + resetTests: [ + { + name: 'reset single value', + render: () => html``, + change(slider) { + slider.value = 100; + }, + assertReset(slider) { + expect(slider.value) + .withContext('slider.value after reset') + .toBe(10); + } + }, + { + name: 'reset multiple values same name', + render: () => + html``, + change(slider) { + slider.valueStart = 5; + slider.valueEnd = 5; + }, + assertReset(slider) { + expect(slider.valueStart) + .withContext('slider.valueStart after reset') + .toEqual(0); + expect(slider.valueEnd) + .withContext('slider.valueEnd after reset') + .toEqual(10); + } + }, + { + name: 'reset multiple values different names', + render: () => + html``, + change(slider) { + slider.valueStart = 5; + slider.valueEnd = 5; + }, + assertReset(slider) { + expect(slider.valueStart) + .withContext('slider.valueStart after reset') + .toEqual(0); + expect(slider.valueEnd) + .withContext('slider.valueEnd after reset') + .toEqual(10); + } + }, + ], + restoreTests: [ + { + name: 'restore single value', + render: () => html``, + assertRestored(slider) { + expect(slider.value) + .withContext('slider.value after restore') + .toBe(1); + } + }, + { + name: 'restore multiple values same name', + render: () => + html``, + assertRestored(slider) { + expect(slider.valueStart) + .withContext('slider.valueStart after restore') + .toEqual(0); + expect(slider.valueEnd) + .withContext('slider.valueEnd after restore') + .toEqual(10); + } + }, + { + name: 'restore multiple values different names', + render: () => + html``, + assertRestored(slider) { + expect(slider.valueStart) + .withContext('slider.valueStart after restore') + .toEqual(0); + expect(slider.valueEnd) + .withContext('slider.valueEnd after restore') + .toEqual(10); + } + }, + ] + }); + }); }); diff --git a/testing/forms.ts b/testing/forms.ts index ededddec92..c5df1584d8 100644 --- a/testing/forms.ts +++ b/testing/forms.ts @@ -199,6 +199,7 @@ export function createFormTests( it(`it should pass the "${resetTest.name}" reset test`, async () => { const {form, control} = await setupTest(resetTest.render()); resetTest.change(control); + await control?.updateComplete; form.reset(); resetTest.assertReset(control); }); @@ -215,7 +216,8 @@ export function createFormTests( // the form, add a new control, and simulate restoring the state and // value for that control. const [value, state] = - getInternals(control).setFormValue.calls.mostRecent()?.args ?? [null, null]; + getInternals(control).setFormValue.calls.mostRecent()?.args ?? + [null, null]; const newControl = document.createElement(control.tagName) as ExpectedFormAssociatedElement; @@ -223,7 +225,7 @@ export function createFormTests( form.appendChild(newControl); let restoreState: FormState|null|FormData = state ?? value; if (restoreState instanceof FormData) { - restoreState = restoreState.entries(); + restoreState = Array.from(restoreState.entries()); } newControl?.formStateRestoreCallback(restoreState, 'restore'); @@ -261,8 +263,7 @@ interface ExpectedFormAssociatedElement extends HTMLElement { * `formStateRestoreCallback` type for `state`. May be a string, `File`, * `FormData` entries, or null. */ -type FormState = - FormDataEntryValue|IterableIterator<[string, FormDataEntryValue]>; +type FormState = FormDataEntryValue|Array<[string, FormDataEntryValue]>; /** * A test for `FormData` values.