Skip to content

Commit

Permalink
feat(slider): adds explicit multi-value support via range=true, value…
Browse files Browse the repository at this point in the history
…Start, valueEnd

PiperOrigin-RevId: 533264226
  • Loading branch information
material-web-copybara authored and copybara-github committed May 18, 2023
1 parent 017d2a9 commit 7ab37e4
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 156 deletions.
10 changes: 6 additions & 4 deletions slider/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ import './index.js';
import './material-collection.js';

import {KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo} from './material-collection.js';
import {boolInput, Knob, numberInput, textInput} from './index.js';
import {boolInput, Knob, numberInput} from './index.js';

import {stories, StoryKnobs} from './stories.js';

const collection =
new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>('Slider', [
new Knob('value', {ui: numberInput(), defaultValue: 5}),
new Knob('multivalue.value', {ui: textInput(), defaultValue: '5, 10'}),
new Knob('value', {ui: numberInput(), defaultValue: 50}),
new Knob('range', {ui: boolInput(), defaultValue: false}),
new Knob('valueStart', {ui: numberInput(), defaultValue: 30}),
new Knob('valueEnd', {ui: numberInput(), defaultValue: 70}),
new Knob('min', {ui: numberInput(), defaultValue: 0}),
new Knob('max', {ui: numberInput(), defaultValue: 25}),
new Knob('max', {ui: numberInput(), defaultValue: 100}),
new Knob('step', {ui: numberInput(), defaultValue: 1}),
new Knob('withTickMarks', {ui: boolInput(), defaultValue: false}),
new Knob('withLabel', {ui: boolInput(), defaultValue: false}),
Expand Down
39 changes: 21 additions & 18 deletions slider/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {css, html} from 'lit';
/** Knob types for slider stories. */
export interface StoryKnobs {
value: number;
'multivalue.value': string;
valueStart: number;
valueEnd: number;
min: number;
max: number;
step: number;
range: boolean;
withTickMarks: boolean;
withLabel: boolean;
disabled: boolean;
Expand All @@ -36,10 +38,13 @@ const standard: MaterialStoryInit<StoryKnobs> = {
return html`
<label>label
<md-slider
value=${knobs.value}
.value=${knobs.value}
.valueStart=${knobs.valueStart}
.valueEnd=${knobs.valueEnd}
.min=${knobs.min}
.max=${knobs.max}
.step=${knobs.step ?? 1}
.range=${knobs.range}
.withTickMarks=${knobs.withTickMarks}
.withLabel=${knobs.withLabel ?? false}
.disabled=${knobs.disabled ?? false}
Expand All @@ -54,12 +59,10 @@ const multiValue: MaterialStoryInit<StoryKnobs> = {
render(knobs) {
return html`
<label>label
<!--
Value can be a [number, number]|number but can convert string
attribtues separated by commas
-->
<md-slider
value=${(knobs['multivalue.value']) as unknown as number}
range
.valueStart=${(knobs.valueStart)}
.valueEnd=${(knobs.valueEnd)}
.min=${knobs.min}
.max=${knobs.max}
.step=${knobs.step ?? 1}
Expand Down Expand Up @@ -117,21 +120,21 @@ const customStyling: MaterialStoryInit<StoryKnobs> = {
}
function updateLabel(event: Event) {
const target = event.target as MdSlider;
const {valueAsFraction} = target;
const hasValueRange = Array.isArray(valueAsFraction);
target.valueLabel = hasValueRange ?
[labelFor(valueAsFraction[0]), labelFor(valueAsFraction[1])] :
labelFor(valueAsFraction);
const {min, max, valueStart, valueEnd} = target;
const range = max - min;
const fractionStart = valueStart / range;
const fractionEnd = valueEnd / range;
target.valueStartLabel = labelFor(fractionStart);
target.valueEndLabel = labelFor(fractionEnd);
}
return html`
<label>label
<!--
Value can be a [number, number]|number but can convert string
attribtues separated by commas
-->
<md-slider
value=${(knobs['multivalue.value']) as unknown as number}
valueLabel=${`😔, 😌`}
range
.valueStart=${(knobs.valueStart)}
.valueEnd=${(knobs.valueEnd)}
.valueStartLabel=${'😔'}
.valueEndLabel=${'😌'}
withTickMarks
withLabel
.min=${knobs.min}
Expand Down
139 changes: 67 additions & 72 deletions slider/lib/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,6 @@ function inBounds({x, y}: PointerEvent, element?: HTMLElement|null) {
return x >= left && x <= right && y >= top && y <= bottom;
}

// parse values like: foo or foo,bar
function tupleConverter(attr: string|null) {
const [, v, e] =
attr?.match(/\s*\[?\s*([^,]+)(?:(?:\s*$)|(?:\s*,\s*(.*)\s*))/) ?? [];
return e !== undefined ? [v, e] : v;
}

function toNumber(value: string) {
return Number(value) || 0;
}

function tupleAsString(value: unknown|[unknown, unknown]) {
return Array.isArray(value) ? value.join() : String(value ?? '');
}

function valueConverter(attr: string|null) {
const value = tupleConverter(attr);
return Array.isArray(value) ? value.map(i => toNumber(i)) : toNumber(value);
}

function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
Expand Down Expand Up @@ -98,21 +79,40 @@ export class Slider extends LitElement {
/**
* The slider maximum value
*/
@property({type: Number}) max = 10;
@property({type: Number}) max = 100;

/**
* The slider value displayed when range is false.
*/
@property({type: Number}) value = 50;

/**
* The slider start value displayed when range is true.
*/
@property({type: Number}) valueStart = 25;

/**
* The slider end value displayed when range is true.
*/
@property({type: Number}) valueEnd = 75;

/**
* An optional label for the slider's value displayed when range is
* false; if not set, the label is the value itself.
*/
@property() valueLabel?: string|undefined;

/**
* The slider value, can be a single number, or an array tuple indicating
* a start and end value.
* An optional label for the slider's start value displayed when
* range is true; if not set, the label is the valueStart itself.
*/
@property({converter: valueConverter}) value: number|[number, number] = 0;
@property() valueStartLabel?: string|undefined;

/**
* An optinoal label for the slider's value; if not set, the label is the
* value itself. This can be a string or string tuple when start and end
* values are used.
* An optional label for the slider's end value displayed when
* range is true; if not set, the label is the valueEnd itself.
*/
@property({converter: tupleConverter})
valueLabel?: string|[string, string]|undefined;
@property() valueEndLabel?: string|undefined;

/**
* The step between values.
Expand All @@ -129,6 +129,13 @@ export class Slider extends LitElement {
*/
@property({type: Boolean}) withLabel = false;

/**
* Whether or not to show a value range. When false, the slider displays
* a slideable handle for the value property; when true, it displays
* slideable handles for the valueStart and valueEnd properties.
*/
@property({type: Boolean}) range = false;

/**
* The HTML name to use in form submission.
*/
Expand All @@ -141,17 +148,6 @@ export class Slider extends LitElement {
return this.closest('form');
}


/**
* Read only computed value representing the fraction between 0 and 1
* respresenting the value's position between min and max. This is a
* single fraction or a tuple if the value specifies start and end values.
*/
get valueAsFraction() {
const {lowerFraction, upperFraction} = this.getMetrics();
return this.allowRange ? [lowerFraction, upperFraction] : upperFraction;
}

private getMetrics() {
const step = Math.max(this.step, 1);
const range = Math.max(this.max - this.min, step);
Expand Down Expand Up @@ -210,38 +206,26 @@ export class Slider extends LitElement {
this.inputB?.focus();
}

get valueAsString() {
return tupleAsString(this.value);
}

// value coerced to a string
[getFormValue]() {
return this.valueAsString;
return this.range ? `${this.valueStart}, ${this.valueEnd}` :
`${this.value}`;
}

// If range should be allowed (detected via value format).
private allowRange = false;

// indicates input values are crossed over each other from initial rendering.
private isFlipped() {
return this.valueA > this.valueB;
}

protected override willUpdate(changed: PropertyValues) {
if (changed.has('value') || changed.has('min') || changed.has('max') ||
changed.has('step')) {
this.allowRange = Array.isArray(this.value);
const step = Math.max(this.step, 1);
let lower =
this.allowRange ? (this.value as [number, number])[0] : this.min;
lower = clamp(lower - (lower % step), this.min, this.max);
let upper = this.allowRange ? (this.value as [number, number])[1] :
this.value as number;
upper = clamp(upper - (upper % step), this.min, this.max);
const isFlipped = this.isFlipped() && this.allowRange;
this.valueA = isFlipped ? upper : lower;
this.valueB = isFlipped ? lower : upper;
}
const step = Math.max(this.step, 1);
let lower = this.range ? this.valueStart : this.min;
lower = clamp(lower - (lower % step), this.min, this.max);
let upper = this.range ? this.valueEnd : this.value;
upper = clamp(upper - (upper % step), this.min, this.max);
const isFlipped = this.isFlipped() && this.range;
this.valueA = isFlipped ? upper : lower;
this.valueB = isFlipped ? lower : upper;

// manually handle ripple hover state since the handle is pointer events
// none.
Expand All @@ -255,7 +239,7 @@ export class Slider extends LitElement {
}

protected override async updated(changed: PropertyValues) {
if (changed.has('value') || changed.has('valueA') ||
if (changed.has('range') || changed.has('valueA') ||
changed.has('valueB')) {
await this.updateComplete;
this.handlesOverlapping = isOverlapping(this.handleA, this.handleB);
Expand All @@ -272,14 +256,19 @@ export class Slider extends LitElement {
// for generating tick marks
'--slider-tick-count': String(range / step),
};
const containerClasses = {ranged: this.allowRange};
const containerClasses = {ranged: this.range};

// optional label values to show in place of the value.
const labelA = String(this.valueLabel?.[isFlipped ? 1 : 0] ?? this.valueA);
const labelB = String(
(this.allowRange ? this.valueLabel?.[isFlipped ? 0 : 1] :
this.valueLabel) ??
this.valueB);
let labelA = String(this.valueA);
let labelB = String(this.valueB);
if (this.range) {
const a = isFlipped ? this.valueEndLabel : this.valueStartLabel;
const b = isFlipped ? this.valueStartLabel : this.valueEndLabel;
labelA = a ?? labelA;
labelB = b ?? labelB;
} else {
labelB = this.valueLabel ?? labelB;
}

const inputAProps = {
id: 'a',
Expand Down Expand Up @@ -322,13 +311,14 @@ export class Slider extends LitElement {
class="container ${classMap(containerClasses)}"
style=${styleMap(containerStyles)}
>
${when(this.allowRange, () => this.renderInput(inputAProps))}
${when(this.range, () => this.renderInput(inputAProps))}
${this.renderInput(inputBProps)}
${this.renderTrack()}
<div class="handleContainerPadded">
<div class="handleContainerBlock">
<div class="handleContainer ${classMap(handleContainerClasses)}">
${when(this.allowRange, () => this.renderHandle(handleAProps))}
${
when(this.range, () => this.renderHandle(handleAProps))}
${this.renderHandle(handleBProps)}
</div>
</div>
Expand Down Expand Up @@ -379,7 +369,7 @@ export class Slider extends LitElement {
}) {
// when ranged, ensure announcement includes value info.
const ariaLabelDescriptor =
this.allowRange ? ` - ${lesser ? `start` : `end`} handle` : '';
this.range ? ` - ${lesser ? `start` : `end`} handle` : '';
// Needed for closure conformance
const {ariaLabel} = this as ARIAMixinStrict;
return html`<input type="range"
Expand Down Expand Up @@ -507,7 +497,12 @@ export class Slider extends LitElement {
// update value only on interaction
const lower = Math.min(this.valueA, this.valueB);
const upper = Math.max(this.valueA, this.valueB);
this.value = this.allowRange ? [lower, upper] : this.valueB;
if (this.range) {
this.valueStart = lower;
this.valueEnd = upper;
} else {
this.value = this.valueB;
}
}

private handleChange(event: Event) {
Expand Down
Loading

0 comments on commit 7ab37e4

Please sign in to comment.