Skip to content

Commit 7c49fcb

Browse files
authored
Merge pull request #330 from cal-smith/slider
feat(slider): add slider component
2 parents 26a5948 + 352541d commit 7c49fcb

File tree

4 files changed

+395
-1
lines changed

4 files changed

+395
-1
lines changed

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./accordion/accordion.module";
22
export * from "./banner/banner.module";
3+
export * from "./breadcrumb/breadcrumb.module";
34
export * from "./button/button.module";
45
export * from "./calendar/calendar.module";
56
export * from "./checkbox/checkbox.module";
@@ -27,9 +28,9 @@ export * from "./progress-indicator/progress-indicator.module";
2728
export * from "./radio/radio.module";
2829
export * from "./search/search.module";
2930
export * from "./select/select.module";
31+
export * from "./slider/slider.module";
3032
export * from "./structured-list/structured-list.module";
3133
export * from "./switch/switch.module";
3234
export * from "./table/table.module";
3335
export * from "./tabs/tabs.module";
3436
export * from "./tiles/tiles.module";
35-
export * from "./breadcrumb/breadcrumb.module";

src/slider/slider.component.ts

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import {
2+
Component,
3+
HostBinding,
4+
Input,
5+
Output,
6+
EventEmitter,
7+
AfterViewInit,
8+
OnDestroy,
9+
ViewChild,
10+
ElementRef
11+
} from "@angular/core";
12+
import { fromEvent, Subscription } from "rxjs";
13+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
14+
15+
/**
16+
* Used to select from ranges of values. [See here](https://www.carbondesignsystem.com/components/slider/usage) for usage information.
17+
*
18+
* The simplest possible slider usage looks something like:
19+
* ```html
20+
* <ibm-slider></ibm-slider>
21+
* ```
22+
*
23+
* That will render a slider without labels or alternative value input. Labels can be provided by
24+
* elements with `[minLabel]` and `[maxLabel]` attributes, and an `input` (may use the `ibmInput` directive) can be supplied
25+
* for use as an alternative value field.
26+
*
27+
* ex:
28+
* ```html
29+
* <!-- full example -->
30+
* <ibm-slider>
31+
* <span minLabel>0GB</span>
32+
* <span maxLabel>100GB</span>
33+
* <input/>
34+
* </ibm-slider>
35+
* <!-- with just an input -->
36+
* <ibm-slider>
37+
* <input/>
38+
* </ibm-slider>
39+
* <!-- with just one label -->
40+
* <ibm-slider>
41+
* <span maxLabel>Maximum</span>
42+
* </ibm-slider>
43+
* ```
44+
*
45+
* Slider supports `NgModel` by default, as well as two way binding to the `value` input.
46+
*/
47+
@Component({
48+
selector: "ibm-slider",
49+
template: `
50+
<div
51+
class="bx--slider"
52+
[ngClass]="{'bx--slider--disabled': disabled}">
53+
<div
54+
#thumb
55+
class="bx--slider__thumb"
56+
tabindex="0"
57+
[ngStyle]="{'left.%': getFractionComplete() * 100}"
58+
(mousedown)="onMouseDown($event)"
59+
(keydown)="onKeyDown($event)">
60+
</div>
61+
<div
62+
#track
63+
class="bx--slider__track"
64+
(click)="onClick($event)">
65+
</div>
66+
<div
67+
class="bx--slider__filled-track"
68+
[ngStyle]="{transform: 'translate(0%, -50%)' + scaleX(getFractionComplete())}">
69+
</div>
70+
<input
71+
#range
72+
aria-label="slider"
73+
class="bx--slider__input"
74+
type="range"
75+
[step]="step"
76+
[min]="min"
77+
[max]="max"
78+
[value]="value">
79+
</div>
80+
<label [id]="bottomRangeId" class="bx--slider__range-label">
81+
<ng-content select="[minLabel]"></ng-content>
82+
</label>
83+
<label [id]="topRangeId" class="bx--slider__range-label">
84+
<ng-content select="[maxLabel]"></ng-content>
85+
</label>
86+
<ng-content select="input"></ng-content>
87+
`,
88+
providers: [
89+
{
90+
provide: NG_VALUE_ACCESSOR,
91+
useExisting: Slider,
92+
multi: true
93+
}
94+
]
95+
})
96+
export class Slider implements AfterViewInit, OnDestroy, ControlValueAccessor {
97+
/** Used to generate unique IDs */
98+
private static count = 0;
99+
/** The lower bound of our range */
100+
@Input() min = 0;
101+
/** The upper bound of our range */
102+
@Input() max = 100;
103+
/** The interval for our range */
104+
@Input() step = 1;
105+
/** Set the initial value. Available for two way binding */
106+
@Input() set value(v) {
107+
if (v > this.max) {
108+
v = this.max;
109+
}
110+
111+
if (v < this.min) {
112+
v = this.min;
113+
}
114+
115+
this._value = v;
116+
this.slidAmount = this.convertToPx(v);
117+
118+
if (this.input) {
119+
this.input.value = v.toString();
120+
}
121+
122+
this.propagateChange(v);
123+
this.valueChange.emit(v);
124+
}
125+
126+
get value() {
127+
return this._value;
128+
}
129+
/** Base ID for the slider. The min and max labels get IDs `${this.id}-bottom-range` and `${this.id}-top-range` respectively */
130+
@Input() id = `slider-${Slider.count++}`;
131+
/** Value used to "multiply" the `step` when using arrow keys to select values */
132+
@Input() shiftMultiplier = 4;
133+
/** Disables the range visually and functionally */
134+
@Input() set disabled(v) {
135+
this._disabled = v;
136+
// for some reason `this.input` never exists here, so we have to query for it here too
137+
const input = this.elementRef.nativeElement.querySelector("input:not([type=range])");
138+
if (input) {
139+
input.disabled = v;
140+
}
141+
}
142+
143+
get disabled() {
144+
return this._disabled;
145+
}
146+
/** Emits every time a new value is selected */
147+
@Output() valueChange: EventEmitter<number> = new EventEmitter();
148+
@HostBinding("class.bx--slider-container") hostClass = true;
149+
@ViewChild("thumb") thumb: ElementRef;
150+
@ViewChild("track") track: ElementRef;
151+
@ViewChild("range") range: ElementRef;
152+
153+
public bottomRangeId = `${this.id}-bottom-range`;
154+
public topRangeId = `${this.id}-top-range`;
155+
156+
protected isMouseDown = false;
157+
/** Array of event subscriptions so we can batch unsubscribe in `ngOnDestroy` */
158+
protected eventSubscriptions: Array<Subscription> = [];
159+
protected slidAmount = 0;
160+
protected input: HTMLInputElement;
161+
protected _value = 0;
162+
protected _disabled = false;
163+
164+
constructor(protected elementRef: ElementRef) {}
165+
166+
ngAfterViewInit() {
167+
// bind mousemove and mouseup to the document so we don't have issues tracking the mouse
168+
this.eventSubscriptions.push(fromEvent(document, "mousemove").subscribe(this.onMouseMove.bind(this)));
169+
this.eventSubscriptions.push(fromEvent(document, "mouseup").subscribe(this.onMouseUp.bind(this)));
170+
171+
// ODO: ontouchstart/ontouchmove/ontouchend
172+
173+
// set up the optional input
174+
this.input = this.elementRef.nativeElement.querySelector("input:not([type=range])");
175+
if (this.input) {
176+
this.input.type = "number";
177+
this.input.classList.add("bx--slider-text-input");
178+
this.input.classList.add("bx--text-input");
179+
this.input.setAttribute("aria-labelledby", `${this.bottomRangeId} ${this.topRangeId}`);
180+
this.input.value = this.value.toString();
181+
// bind events on our optional input
182+
this.eventSubscriptions.push(fromEvent(this.input, "change").subscribe(this.onChange.bind(this)));
183+
this.eventSubscriptions.push(fromEvent(this.input, "focus").subscribe(this.onFocus.bind(this)));
184+
}
185+
}
186+
187+
/** Clean up our DOMEvent subscriptions */
188+
ngOnDestroy() {
189+
this.eventSubscriptions.forEach(subscription => {
190+
subscription.unsubscribe();
191+
});
192+
}
193+
194+
/** Send changes back to the model */
195+
propagateChange = (_: any) => { };
196+
197+
/** Register a change propagation function for `ControlValueAccessor` */
198+
registerOnChange(fn: any) {
199+
this.propagateChange = fn;
200+
}
201+
202+
/** Callback to notify the model when our input has been touched */
203+
onTouched: () => any = () => { };
204+
205+
/** Register a callback to notify when our input has been touched */
206+
registerOnTouched(fn: any) {
207+
this.onTouched = fn;
208+
}
209+
210+
/** Receives a value from the model */
211+
writeValue(v: any) {
212+
this.value = v;
213+
}
214+
215+
/** Returns the amount of "completeness" as a fraction of the total track width */
216+
getFractionComplete() {
217+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
218+
return this.slidAmount / trackWidth;
219+
}
220+
221+
/** Helper function to return the CSS transform `scaleX` function */
222+
scaleX(complete) {
223+
return `scaleX(${complete})`;
224+
}
225+
226+
/** Converts a given px value to a "real" value in our range */
227+
convertToValue(pxAmount) {
228+
// basic concept borrowed from carbon-components
229+
// ref: https://github.com/IBM/carbon-components/blob/43bf3abdc2f8bdaa38aa84e0f733adde1e1e8894/src/components/slider/slider.js#L147-L151
230+
const range = this.max - this.min;
231+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
232+
const unrounded = pxAmount / trackWidth;
233+
const rounded = Math.round((range * unrounded) / this.step) * this.step;
234+
return rounded + this.min;
235+
}
236+
237+
/** Converts a given "real" value to a px value we can update the view with */
238+
convertToPx(value) {
239+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
240+
if (value >= this.max) {
241+
return trackWidth;
242+
}
243+
244+
if (value <= this.min) {
245+
return 0;
246+
}
247+
248+
return Math.round(trackWidth * (value / this.max));
249+
}
250+
251+
/**
252+
* Increments the value by the step value, or the step value multiplied by the `multiplier` argument.
253+
*
254+
* @argument multiplier Defaults to `1`, multiplied with the step value.
255+
*/
256+
incrementValue(multiplier = 1) {
257+
this.value = this.value + (this.step * multiplier);
258+
}
259+
260+
/**
261+
* Decrements the value by the step value, or the step value multiplied by the `multiplier` argument.
262+
*
263+
* @argument multiplier Defaults to `1`, multiplied with the step value.
264+
*/
265+
decrementValue(multiplier = 1) {
266+
this.value = this.value - (this.step * multiplier);
267+
}
268+
269+
/** Change handler for the optional input */
270+
onChange(event) {
271+
this.value = event.target.value;
272+
}
273+
274+
/** Handles clicks on the range track, and setting the value to it's "real" equivalent */
275+
onClick(event) {
276+
if (this.disabled) { return; }
277+
const trackLeft = this.track.nativeElement.getBoundingClientRect().left;
278+
this.value = this.convertToValue(event.clientX - trackLeft);
279+
}
280+
281+
/** Focus handler for the optional input */
282+
onFocus({target}) {
283+
target.select();
284+
}
285+
286+
/** Mouse move handler. Responsible for updating the value and visual selection based on mouse movement */
287+
onMouseMove(event) {
288+
if (this.disabled || !this.isMouseDown) { return; }
289+
const track = this.track.nativeElement.getBoundingClientRect();
290+
if (
291+
event.clientX - track.left <= track.width
292+
&& event.clientX - track.left >= 0
293+
) {
294+
this.slidAmount = event.clientX - track.left;
295+
}
296+
this.value = this.convertToValue(this.slidAmount);
297+
}
298+
299+
/** Enables the `onMouseMove` handler */
300+
onMouseDown(event) {
301+
event.preventDefault();
302+
if (this.disabled) { return; }
303+
this.thumb.nativeElement.focus();
304+
this.isMouseDown = true;
305+
}
306+
307+
/** Disables the `onMouseMove` handler */
308+
onMouseUp() {
309+
this.isMouseDown = false;
310+
}
311+
312+
/** Calls `incrementValue` for ArrowRight and ArrowUp, `decrementValue` for ArrowLeft and ArrowDown */
313+
onKeyDown(event: KeyboardEvent) {
314+
event.preventDefault();
315+
const multiplier = event.shiftKey ? this.shiftMultiplier : 1;
316+
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
317+
this.decrementValue(multiplier);
318+
}
319+
320+
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
321+
this.incrementValue(multiplier);
322+
}
323+
}
324+
}

src/slider/slider.module.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NgModule } from "@angular/core";
2+
import { Slider } from "./slider.component";
3+
import { CommonModule } from "@angular/common";
4+
5+
@NgModule({
6+
declarations: [Slider],
7+
exports: [Slider],
8+
imports: [CommonModule]
9+
})
10+
class SliderModule {}
11+
12+
export {
13+
SliderModule,
14+
Slider
15+
};

0 commit comments

Comments
 (0)