Skip to content

Commit

Permalink
feat(slider): add full form association support
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 537331425
  • Loading branch information
asyncLiz authored and copybara-github committed Jun 2, 2023
1 parent d5b4951 commit ae70f1e
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 44 deletions.
162 changes: 122 additions & 40 deletions slider/lib/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement|undefined, number|undefined>;
}
/** 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 :
Expand All @@ -235,6 +247,22 @@ export class Slider extends LitElement {
}
}

protected override update(changed: PropertyValues<Slider>) {
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
Expand Down Expand Up @@ -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<HTMLInputElement|undefined, number|undefined>;
}
137 changes: 137 additions & 0 deletions slider/slider_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -344,4 +345,140 @@ describe('<md-slider>', () => {
expect(input.matches(':focus')).toBe(true);
});
});

describe('forms', () => {
createFormTests({
queryControl: root => root.querySelector('md-slider'),
valueTests: [
{
name: 'unnamed',
render: () => html`<md-slider></md-slider>`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form without a name')
.toHaveSize(0);
}
},
{
name: 'single value',
render: () => html`<md-slider name="slider" value="10"></md-slider>`,
assertValue(formData) {
expect(formData.get('slider')).toBe('10');
}
},
{
name: 'multiple values same name',
render: () =>
html`<md-slider range name="slider" value-start="0" value-end="10"></md-slider>`,
assertValue(formData) {
expect(formData.getAll('slider')).toEqual(['0', '10']);
}
},
{
name: 'multiple values different names',
render: () =>
html`<md-slider range name-start="slider-start" name-end="slider-end" value-start="0" value-end="10"></md-slider>`,
assertValue(formData) {
expect(formData.get('slider-start')).toBe('0');
expect(formData.get('slider-end')).toBe('10');
}
},
{
name: 'disabled',
render: () =>
html`<md-slider name="slider" value="10" disabled></md-slider>`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form when disabled')
.toHaveSize(0);
}
}
],
resetTests: [
{
name: 'reset single value',
render: () => html`<md-slider name="slider" value="10"></md-slider>`,
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`<md-slider range name="slider" value-start="0" value-end="10"></md-slider>`,
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`<md-slider range name-start="slider-start" name-end="slider-end" value-start="0" value-end="10"></md-slider>`,
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`<md-slider name="checkbox" value="1"></md-slider>`,
assertRestored(slider) {
expect(slider.value)
.withContext('slider.value after restore')
.toBe(1);
}
},
{
name: 'restore multiple values same name',
render: () =>
html`<md-slider range name="slider" value-start="0" value-end="10"></md-slider>`,
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`<md-slider range name-start="slider-start" name-end="slider-end" value-start="0" value-end="10"></md-slider>`,
assertRestored(slider) {
expect(slider.valueStart)
.withContext('slider.valueStart after restore')
.toEqual(0);
expect(slider.valueEnd)
.withContext('slider.valueEnd after restore')
.toEqual(10);
}
},
]
});
});
});
Loading

0 comments on commit ae70f1e

Please sign in to comment.