Skip to content

Commit ae70f1e

Browse files
asyncLizcopybara-github
authored andcommitted
feat(slider): add full form association support
PiperOrigin-RevId: 537331425
1 parent d5b4951 commit ae70f1e

File tree

3 files changed

+264
-44
lines changed

3 files changed

+264
-44
lines changed

slider/lib/slider.ts

+122-40
Original file line numberDiff line numberDiff line change
@@ -17,39 +17,17 @@ import {when} from 'lit/directives/when.js';
1717
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
1818
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
1919
import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../internal/controller/events.js';
20-
import {FormController, getFormValue} from '../../internal/controller/form-controller.js';
21-
import {stringConverter} from '../../internal/controller/string-converter.js';
2220
import {MdRipple} from '../../ripple/ripple.js';
2321

2422
// Disable warning for classMap with destructuring
2523
// tslint:disable:quoted-properties-on-dictionary
2624

27-
function inBounds({x, y}: PointerEvent, element?: HTMLElement|null) {
28-
if (!element) {
29-
return false;
30-
}
31-
const {top, left, bottom, right} = element.getBoundingClientRect();
32-
return x >= left && x <= right && y >= top && y <= bottom;
33-
}
34-
35-
function isOverlapping(elA: Element|null, elB: Element|null) {
36-
if (!(elA && elB)) {
37-
return false;
38-
}
39-
const a = elA.getBoundingClientRect();
40-
const b = elB.getBoundingClientRect();
41-
return !(
42-
a.top > b.bottom || a.right < b.left || a.bottom < b.top ||
43-
a.left > b.right);
44-
}
45-
46-
interface Action {
47-
canFlip: boolean;
48-
flipped: boolean;
49-
target: HTMLInputElement;
50-
fixed: HTMLInputElement;
51-
values: Map<HTMLInputElement|undefined, number|undefined>;
52-
}
25+
/** The default value for a continuous slider. */
26+
const DEFAULT_VALUE = 50;
27+
/** The default start value for a range slider. */
28+
const DEFAULT_VALUE_START = 25;
29+
/** The default end value for a range slider. */
30+
const DEFAULT_VALUE_END = 75;
5331

5432
/**
5533
* Slider component.
@@ -86,17 +64,19 @@ export class Slider extends LitElement {
8664
/**
8765
* The slider value displayed when range is false.
8866
*/
89-
@property({type: Number}) value = 50;
67+
@property({type: Number}) value = DEFAULT_VALUE;
9068

9169
/**
9270
* The slider start value displayed when range is true.
9371
*/
94-
@property({type: Number}) valueStart = 25;
72+
@property({type: Number, attribute: 'value-start'})
73+
valueStart = DEFAULT_VALUE_START;
9574

9675
/**
9776
* The slider end value displayed when range is true.
9877
*/
99-
@property({type: Number}) valueEnd = 75;
78+
@property({type: Number, attribute: 'value-end'})
79+
valueEnd = DEFAULT_VALUE_END;
10080

10181
/**
10282
* An optional label for the slider's value displayed when range is
@@ -153,13 +133,49 @@ export class Slider extends LitElement {
153133
/**
154134
* The HTML name to use in form submission.
155135
*/
156-
@property({reflect: true, converter: stringConverter}) name = '';
136+
get name() {
137+
return this.getAttribute('name') ?? '';
138+
}
139+
set name(name: string) {
140+
this.setAttribute('name', name);
141+
}
142+
143+
/**
144+
* The HTML name to use in form submission for a range slider's starting
145+
* value. Use `name` instead if both the start and end values should use the
146+
* same name.
147+
*/
148+
get nameStart() {
149+
return this.getAttribute('name-start') ?? this.name;
150+
}
151+
set nameStart(name: string) {
152+
this.setAttribute('name-start', name);
153+
}
154+
155+
/**
156+
* The HTML name to use in form submission for a range slider's ending value.
157+
* Use `name` instead if both the start and end values should use the same
158+
* name.
159+
*/
160+
get nameEnd() {
161+
return this.getAttribute('name-end') ?? this.nameStart;
162+
}
163+
set nameEnd(name: string) {
164+
this.setAttribute('name-end', name);
165+
}
157166

158167
/**
159168
* The associated form element with which this element's value will submit.
160169
*/
161170
get form() {
162-
return this.closest('form');
171+
return this.internals.form;
172+
}
173+
174+
/**
175+
* The labels this element is associated with.
176+
*/
177+
get labels() {
178+
return this.internals.labels;
163179
}
164180

165181
@query('input.start') private readonly inputStart!: HTMLInputElement|null;
@@ -193,9 +209,11 @@ export class Slider extends LitElement {
193209

194210
private action?: Action;
195211

212+
private readonly internals =
213+
(this as HTMLElement /* needed for closure */).attachInternals();
214+
196215
constructor() {
197216
super();
198-
this.addController(new FormController(this));
199217
if (!isServer) {
200218
this.addEventListener('click', (event: MouseEvent) => {
201219
if (!isActivationClick(event) || !this.inputEnd) {
@@ -211,12 +229,6 @@ export class Slider extends LitElement {
211229
this.inputEnd?.focus();
212230
}
213231

214-
// value coerced to a string
215-
[getFormValue]() {
216-
return this.range ? `${this.valueStart}, ${this.valueEnd}` :
217-
`${this.value}`;
218-
}
219-
220232
protected override willUpdate(changed: PropertyValues) {
221233
this.renderValueStart = changed.has('valueStart') ?
222234
this.valueStart :
@@ -235,6 +247,22 @@ export class Slider extends LitElement {
235247
}
236248
}
237249

250+
protected override update(changed: PropertyValues<Slider>) {
251+
if (changed.has('value') || changed.has('range') ||
252+
changed.has('valueStart') || changed.has('valueEnd')) {
253+
if (this.range) {
254+
const data = new FormData();
255+
data.append(this.nameStart, String(this.valueStart));
256+
data.append(this.nameEnd, String(this.valueEnd));
257+
this.internals.setFormValue(data);
258+
} else {
259+
this.internals.setFormValue(String(this.value));
260+
}
261+
}
262+
263+
super.update(changed);
264+
}
265+
238266
protected override updated(changed: PropertyValues) {
239267
// Validate input rendered value and re-render if necessary. This ensures
240268
// the rendred handle stays in sync with the input thumb which is used for
@@ -590,4 +618,58 @@ export class Slider extends LitElement {
590618
// ensure keyboard triggered change clears action.
591619
this.finishAction(e);
592620
}
621+
622+
/** @private */
623+
formResetCallback() {
624+
if (this.range) {
625+
this.valueStart =
626+
Number(this.getAttribute('value-start') ?? DEFAULT_VALUE_START);
627+
this.valueEnd =
628+
Number(this.getAttribute('value-end') ?? DEFAULT_VALUE_END);
629+
return;
630+
}
631+
632+
this.value = Number(this.getAttribute('value') ?? DEFAULT_VALUE);
633+
}
634+
635+
/** @private */
636+
formStateRestoreCallback(state: string|Array<[string, string]>|null) {
637+
if (Array.isArray(state)) {
638+
const [[, valueStart], [, valueEnd]] = state;
639+
this.valueStart = Number(valueStart ?? DEFAULT_VALUE_START);
640+
this.valueEnd = Number(valueEnd ?? DEFAULT_VALUE_START);
641+
this.range = true;
642+
return;
643+
}
644+
645+
this.value = Number(state ?? DEFAULT_VALUE);
646+
this.range = false;
647+
}
648+
}
649+
650+
function inBounds({x, y}: PointerEvent, element?: HTMLElement|null) {
651+
if (!element) {
652+
return false;
653+
}
654+
const {top, left, bottom, right} = element.getBoundingClientRect();
655+
return x >= left && x <= right && y >= top && y <= bottom;
656+
}
657+
658+
function isOverlapping(elA: Element|null, elB: Element|null) {
659+
if (!(elA && elB)) {
660+
return false;
661+
}
662+
const a = elA.getBoundingClientRect();
663+
const b = elB.getBoundingClientRect();
664+
return !(
665+
a.top > b.bottom || a.right < b.left || a.bottom < b.top ||
666+
a.left > b.right);
667+
}
668+
669+
interface Action {
670+
canFlip: boolean;
671+
flipped: boolean;
672+
target: HTMLInputElement;
673+
fixed: HTMLInputElement;
674+
values: Map<HTMLInputElement|undefined, number|undefined>;
593675
}

slider/slider_test.ts

+137
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import {html} from 'lit';
88

99
import {Environment} from '../testing/environment.js';
10+
import {createFormTests} from '../testing/forms.js';
1011
import {createTokenTests} from '../testing/tokens.js';
1112

1213
import {SliderHarness} from './harness.js';
@@ -344,4 +345,140 @@ describe('<md-slider>', () => {
344345
expect(input.matches(':focus')).toBe(true);
345346
});
346347
});
348+
349+
describe('forms', () => {
350+
createFormTests({
351+
queryControl: root => root.querySelector('md-slider'),
352+
valueTests: [
353+
{
354+
name: 'unnamed',
355+
render: () => html`<md-slider></md-slider>`,
356+
assertValue(formData) {
357+
expect(formData)
358+
.withContext('should not add anything to form without a name')
359+
.toHaveSize(0);
360+
}
361+
},
362+
{
363+
name: 'single value',
364+
render: () => html`<md-slider name="slider" value="10"></md-slider>`,
365+
assertValue(formData) {
366+
expect(formData.get('slider')).toBe('10');
367+
}
368+
},
369+
{
370+
name: 'multiple values same name',
371+
render: () =>
372+
html`<md-slider range name="slider" value-start="0" value-end="10"></md-slider>`,
373+
assertValue(formData) {
374+
expect(formData.getAll('slider')).toEqual(['0', '10']);
375+
}
376+
},
377+
{
378+
name: 'multiple values different names',
379+
render: () =>
380+
html`<md-slider range name-start="slider-start" name-end="slider-end" value-start="0" value-end="10"></md-slider>`,
381+
assertValue(formData) {
382+
expect(formData.get('slider-start')).toBe('0');
383+
expect(formData.get('slider-end')).toBe('10');
384+
}
385+
},
386+
{
387+
name: 'disabled',
388+
render: () =>
389+
html`<md-slider name="slider" value="10" disabled></md-slider>`,
390+
assertValue(formData) {
391+
expect(formData)
392+
.withContext('should not add anything to form when disabled')
393+
.toHaveSize(0);
394+
}
395+
}
396+
],
397+
resetTests: [
398+
{
399+
name: 'reset single value',
400+
render: () => html`<md-slider name="slider" value="10"></md-slider>`,
401+
change(slider) {
402+
slider.value = 100;
403+
},
404+
assertReset(slider) {
405+
expect(slider.value)
406+
.withContext('slider.value after reset')
407+
.toBe(10);
408+
}
409+
},
410+
{
411+
name: 'reset multiple values same name',
412+
render: () =>
413+
html`<md-slider range name="slider" value-start="0" value-end="10"></md-slider>`,
414+
change(slider) {
415+
slider.valueStart = 5;
416+
slider.valueEnd = 5;
417+
},
418+
assertReset(slider) {
419+
expect(slider.valueStart)
420+
.withContext('slider.valueStart after reset')
421+
.toEqual(0);
422+
expect(slider.valueEnd)
423+
.withContext('slider.valueEnd after reset')
424+
.toEqual(10);
425+
}
426+
},
427+
{
428+
name: 'reset multiple values different names',
429+
render: () =>
430+
html`<md-slider range name-start="slider-start" name-end="slider-end" value-start="0" value-end="10"></md-slider>`,
431+
change(slider) {
432+
slider.valueStart = 5;
433+
slider.valueEnd = 5;
434+
},
435+
assertReset(slider) {
436+
expect(slider.valueStart)
437+
.withContext('slider.valueStart after reset')
438+
.toEqual(0);
439+
expect(slider.valueEnd)
440+
.withContext('slider.valueEnd after reset')
441+
.toEqual(10);
442+
}
443+
},
444+
],
445+
restoreTests: [
446+
{
447+
name: 'restore single value',
448+
render: () => html`<md-slider name="checkbox" value="1"></md-slider>`,
449+
assertRestored(slider) {
450+
expect(slider.value)
451+
.withContext('slider.value after restore')
452+
.toBe(1);
453+
}
454+
},
455+
{
456+
name: 'restore multiple values same name',
457+
render: () =>
458+
html`<md-slider range name="slider" value-start="0" value-end="10"></md-slider>`,
459+
assertRestored(slider) {
460+
expect(slider.valueStart)
461+
.withContext('slider.valueStart after restore')
462+
.toEqual(0);
463+
expect(slider.valueEnd)
464+
.withContext('slider.valueEnd after restore')
465+
.toEqual(10);
466+
}
467+
},
468+
{
469+
name: 'restore multiple values different names',
470+
render: () =>
471+
html`<md-slider range name-start="slider-start" name-end="slider-end" value-start="0" value-end="10"></md-slider>`,
472+
assertRestored(slider) {
473+
expect(slider.valueStart)
474+
.withContext('slider.valueStart after restore')
475+
.toEqual(0);
476+
expect(slider.valueEnd)
477+
.withContext('slider.valueEnd after restore')
478+
.toEqual(10);
479+
}
480+
},
481+
]
482+
});
483+
});
347484
});

0 commit comments

Comments
 (0)