Skip to content

Commit d0a700f

Browse files
committed
feat(slider): add slider component
1 parent 2bed080 commit d0a700f

File tree

4 files changed

+306
-1
lines changed

4 files changed

+306
-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";
@@ -26,9 +27,9 @@ export * from "./progress-indicator/progress-indicator.module";
2627
export * from "./radio/radio.module";
2728
export * from "./search/search.module";
2829
export * from "./select/select.module";
30+
export * from "./slider/slider.module";
2931
export * from "./structured-list/structured-list.module";
3032
export * from "./switch/switch.module";
3133
export * from "./table/table.module";
3234
export * from "./tabs/tabs.module";
3335
export * from "./tiles/tiles.module";
34-
export * from "./breadcrumb/breadcrumb.module";

src/slider/slider.component.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
*
17+
*/
18+
@Component({
19+
selector: "ibm-slider",
20+
template: `
21+
<div class="bx--slider">
22+
<div
23+
#thumb
24+
class="bx--slider__thumb"
25+
tabindex="0"
26+
[ngStyle]="{'left.%': getPercentComplete() * 100}"
27+
(mousedown)="onMouseDown($event)"
28+
(keydown)="onKeyDown($event)">
29+
</div>
30+
<div
31+
#track
32+
class="bx--slider__track"
33+
(click)="onClick($event)">
34+
</div>
35+
<div
36+
class="bx--slider__filled-track"
37+
[ngStyle]="{transform: 'translate(0%, -50%)' + scaleX(getPercentComplete())}">
38+
</div>
39+
<input
40+
#range
41+
aria-label="slider"
42+
class="bx--slider__input"
43+
type="range"
44+
[step]="step"
45+
[min]="min"
46+
[max]="max"
47+
[value]="value">
48+
</div>
49+
<label [id]="bottomRangeId" class="bx--slider__range-label">
50+
<ng-content select="[minLabel]"></ng-content>
51+
</label>
52+
<label [id]="topRangeId" class="bx--slider__range-label">
53+
<ng-content select="[maxLabel]"></ng-content>
54+
</label>
55+
<ng-content select="input"></ng-content>
56+
`,
57+
providers: [
58+
{
59+
provide: NG_VALUE_ACCESSOR,
60+
useExisting: Slider,
61+
multi: true
62+
}
63+
]
64+
})
65+
export class Slider implements AfterViewInit, OnDestroy, ControlValueAccessor {
66+
private static count = 0;
67+
68+
@Input() min = 0;
69+
@Input() max = 100;
70+
@Input() step = 1;
71+
@Input() set value(v) {
72+
if (v > this.max) {
73+
v = this.max;
74+
}
75+
76+
if (v < this.min) {
77+
v = this.min;
78+
}
79+
80+
this._value = v;
81+
this.slidAmount = this.convertToPx(v);
82+
83+
if (this.input) {
84+
this.input.value = v;
85+
}
86+
87+
this.propagateChange(v);
88+
this.valueChange.emit(v);
89+
}
90+
91+
get value() {
92+
return this._value;
93+
}
94+
@Input() id = `slider-${Slider.count++}`;
95+
@Input() shiftMultiplier = 4;
96+
@Output() valueChange: EventEmitter<number> = new EventEmitter();
97+
@HostBinding("class.bx--slider-container") hostClass = true;
98+
@ViewChild("thumb") thumb: ElementRef;
99+
@ViewChild("track") track: ElementRef;
100+
@ViewChild("range") range: ElementRef;
101+
102+
public bottomRangeId = `${this.id}-bottom-range`;
103+
public topRangeId = `${this.id}-top-range`;
104+
105+
protected isMouseDown = false;
106+
protected eventSubscriptions: Array<Subscription> = [];
107+
protected slidAmount = 0;
108+
protected input;
109+
protected _value = 0;
110+
111+
constructor(protected elementRef: ElementRef) {}
112+
113+
ngAfterViewInit() {
114+
this.eventSubscriptions.push(fromEvent(document, "mousemove").subscribe(this.onMouseMove.bind(this)));
115+
this.eventSubscriptions.push(fromEvent(document, "mouseup").subscribe(this.onMouseUp.bind(this)));
116+
117+
// set up the optional input
118+
this.input = this.elementRef.nativeElement.querySelector("input:not([type=range])");
119+
if (this.input) {
120+
this.input.type = "number";
121+
this.input.classList.add("bx--slider-text-input");
122+
this.input.classList.add("bx--text-input");
123+
this.input.setAttribute("aria-labelledby", `${this.bottomRangeId} ${this.topRangeId}`);
124+
this.input.value = this.value;
125+
this.eventSubscriptions.push(fromEvent(this.input, "change").subscribe(this.onChange.bind(this)));
126+
this.eventSubscriptions.push(fromEvent(this.input, "focus").subscribe(this.onFocus.bind(this)));
127+
}
128+
}
129+
130+
ngOnDestroy() {
131+
this.eventSubscriptions.forEach(subscription => {
132+
subscription.unsubscribe();
133+
});
134+
}
135+
136+
propagateChange = (_: any) => { };
137+
138+
registerOnChange(fn: any) {
139+
this.propagateChange = fn;
140+
}
141+
142+
onTouched: () => any = () => { };
143+
144+
registerOnTouched(fn: any) {
145+
this.onTouched = fn;
146+
}
147+
148+
writeValue(v: any) {
149+
this.value = v;
150+
}
151+
152+
getPercentComplete() {
153+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
154+
return this.slidAmount / trackWidth;
155+
}
156+
157+
scaleX(complete) {
158+
return `scaleX(${complete})`;
159+
}
160+
161+
convertToValue(pxAmount) {
162+
// basic concept borrowed from carbon-components
163+
// ref: https://github.com/IBM/carbon-components/blob/43bf3abdc2f8bdaa38aa84e0f733adde1e1e8894/src/components/slider/slider.js#L147-L151
164+
const range = this.max - this.min;
165+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
166+
const unrounded = pxAmount / trackWidth;
167+
const rounded = Math.round((range * unrounded) / this.step) * this.step;
168+
return rounded + this.min;
169+
}
170+
171+
convertToPx(value) {
172+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
173+
if (value >= this.max) {
174+
return trackWidth;
175+
}
176+
177+
if (value <= this.min) {
178+
return 0;
179+
}
180+
181+
return Math.round(trackWidth * (value / this.max));
182+
}
183+
184+
incrementValue(multiplier = 1) {
185+
this.value = this.value + (this.step * multiplier);
186+
}
187+
188+
decrementValue(multiplier = 1) {
189+
this.value = this.value - (this.step * multiplier);
190+
}
191+
192+
onChange(event) {
193+
this.value = event.target.value;
194+
}
195+
196+
onClick(event) {
197+
const trackLeft = this.track.nativeElement.getBoundingClientRect().left;
198+
this.value = this.convertToValue(event.clientX - trackLeft);
199+
console.log(event);
200+
}
201+
202+
onFocus({target}) {
203+
target.select();
204+
}
205+
206+
onMouseMove(event) {
207+
if (this.isMouseDown) {
208+
const trackWidth = this.track.nativeElement.getBoundingClientRect().width;
209+
const trackLeft = this.track.nativeElement.getBoundingClientRect().left;
210+
if (
211+
event.clientX - trackLeft <= trackWidth
212+
&& event.clientX - trackLeft >= 0
213+
) {
214+
this.slidAmount = event.clientX - trackLeft;
215+
}
216+
this.value = this.convertToValue(this.slidAmount);
217+
}
218+
}
219+
220+
onMouseDown(event) {
221+
event.preventDefault();
222+
this.thumb.nativeElement.focus();
223+
this.isMouseDown = true;
224+
}
225+
226+
onMouseUp() {
227+
this.isMouseDown = false;
228+
}
229+
230+
onKeyDown(event: KeyboardEvent) {
231+
event.preventDefault();
232+
const multiplier = event.shiftKey ? this.shiftMultiplier : 1;
233+
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
234+
this.decrementValue(multiplier);
235+
}
236+
237+
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
238+
this.incrementValue(multiplier);
239+
}
240+
}
241+
}

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+
};

src/slider/slider.stories.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { storiesOf, moduleMetadata } from "@storybook/angular";
2+
import { action } from "@storybook/addon-actions";
3+
import { withKnobs, number, text } from "@storybook/addon-knobs/angular";
4+
5+
import { SliderModule } from "./slider.module";
6+
7+
storiesOf("Slider", module).addDecorator(moduleMetadata({
8+
imports: [SliderModule]
9+
}))
10+
.addDecorator(withKnobs)
11+
.add("Basic", () => ({
12+
template: "<ibm-slider></ibm-slider>"
13+
}))
14+
.add("Advanced", () => ({
15+
template: `
16+
<ibm-slider
17+
[min]="min"
18+
[max]="max"
19+
[step]="step"
20+
[value]="value"
21+
[shiftMultiplier]="shiftMultiplier"
22+
(valueChange)="valueChange($event)">
23+
<span minLabel>{{minLabel}}</span>
24+
<span maxLabel>{{maxLabel}}</span>
25+
<input/>
26+
</ibm-slider>
27+
`,
28+
props: {
29+
min: number("min", 0),
30+
max: number("max", 100),
31+
step: number("step", 1),
32+
value: number("value", 0),
33+
minLabel: text("minLabel", "0"),
34+
maxLabel: text("maxLabel", "100"),
35+
shiftMultiplier: number("shiftMultiplier", 4),
36+
valueChange: action("Value changed")
37+
}
38+
}))
39+
.add("With NgModel", () => ({
40+
template: `
41+
<ibm-slider [(ngModel)]="model"></ibm-slider>
42+
<br>
43+
<span>model: {{model}}</span>
44+
`,
45+
props: {
46+
model: 0
47+
}
48+
}));

0 commit comments

Comments
 (0)