Skip to content

feat(material-experimental/mdc-slider): implement slider thumb ripples #21979

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 25, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/material-experimental/mdc-slider/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ ng_module(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
assets = [":slider_scss"] + glob(["**/*.html"]),
assets = [
":slider_scss",
":slider_thumb_scss",
] + glob(["**/*.html"]),
module_name = "@angular/material-experimental/mdc-slider",
deps = [
"//src/cdk/bidi",
Expand Down Expand Up @@ -50,6 +53,11 @@ sass_binary(
],
)

sass_binary(
name = "slider_thumb_scss",
src = "slider-thumb.scss",
)

###########
# Testing
###########
Expand Down
6 changes: 6 additions & 0 deletions src/material-experimental/mdc-slider/_slider-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,10 @@
base: mdc-theme-prop-value($color)
),
));
.mdc-slider-hover-ripple {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we document where we get these opacity values? Is it possible to grab em from MDC?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I'm aware, no. They don't seem to have a specific opacity for their ripples. I was just visually following the MDC Slider Spec

I did see that mdc does define a ripple opacity var for the mdc-radio, which we use in our implementation. Should I create an issue for them to do this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, if they could expose the opacity then that would be helpful for us and one less thing we need to maintain

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created Issue #6876 requesting they fix their current ripple implementation (they are missing the "pressed" state) and then expose the 'hover', 'focused', and 'pressed' ripple opacities

background-color: rgba(mdc-theme-prop-value($color), 0.05);
}
.mdc-slider-focus-ripple, .mdc-slider-active-ripple {
background-color: rgba(mdc-theme-prop-value($color), 0.2);
}
}
7 changes: 4 additions & 3 deletions src/material-experimental/mdc-slider/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@

import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {MatCommonModule} from '@angular/material-experimental/mdc-core';
import {MatSlider, MatSliderThumb} from './slider';
import {MatCommonModule, MatRippleModule} from '@angular/material-experimental/mdc-core';
import {MatSlider, MatSliderThumb, MatSliderVisualThumb} from './slider';

@NgModule({
imports: [MatCommonModule, CommonModule],
imports: [MatCommonModule, CommonModule, MatRippleModule],
exports: [MatSlider, MatSliderThumb],
declarations: [
MatSlider,
MatSliderThumb,
MatSliderVisualThumb,
],
})
export class MatSliderModule {
Expand Down
7 changes: 7 additions & 0 deletions src/material-experimental/mdc-slider/slider-thumb.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="mdc-slider__value-indicator-container" *ngIf="_discrete">
<div class="mdc-slider__value-indicator">
<span class="mdc-slider__value-indicator-text">{{_valueIndicatorText}}</span>
</div>
</div>
<div class="mdc-slider__thumb-knob" #knob></div>
<div matRipple></div>
4 changes: 4 additions & 0 deletions src/material-experimental/mdc-slider/slider-thumb.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.mat-mdc-slider .mat-ripple {
height: 100%;
width: 100%;
}
18 changes: 10 additions & 8 deletions src/material-experimental/mdc-slider/slider.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
</div>

<!-- Thumbs -->
<div class="mdc-slider__thumb" *ngFor="let thumb of _getThumbTypes()" #thumb>
<div class="mdc-slider__value-indicator-container" *ngIf="discrete">
<div class="mdc-slider__value-indicator">
<span class="mdc-slider__value-indicator-text">{{_getValueIndicatorText(thumb)}}</span>
</div>
</div>
<div class="mdc-slider__thumb-knob" #knob></div>
</div>
<mat-slider-visual-start-thumb
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should start a discussion on naming this thing- having a thumb directive and private thumb component is a bit confusing.

[_discrete]="discrete"
[_valueIndicatorText]="_startValueIndicatorText"
*ngIf="_isRange()">
</mat-slider-visual-start-thumb>

<mat-slider-visual-end-thumb
[_discrete]="discrete"
[_valueIndicatorText]="_endValueIndicatorText">
</mat-slider-visual-end-thumb>
2 changes: 1 addition & 1 deletion src/material-experimental/mdc-slider/slider.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

@include mdc-slider.without-ripple($query: $mat-base-styles-query);

.mdc-slider {
.mat-mdc-slider {
display: block;
}
231 changes: 208 additions & 23 deletions src/material-experimental/mdc-slider/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,22 @@ import {
EventEmitter,
Inject,
Input,
NgZone,
OnDestroy,
Output,
QueryList,
ViewChild,
ViewChildren,
ViewEncapsulation,
} from '@angular/core';
import {CanColorCtor, mixinColor} from '@angular/material/core';
import {
CanColorCtor,
MatRipple,
mixinColor,
RippleAnimationConfig,
RippleRef,
RippleState,
} from '@angular/material/core';
import {SpecificEventListener, EventType} from '@material/base';
import {MDCSliderAdapter, MDCSliderFoundation, Thumb, TickMark} from '@material/slider';

Expand All @@ -48,6 +56,194 @@ export interface MatSliderDragEvent {
value: number;
}

/**
* The visual slider thumb.
*
* Handles the slider thumb ripple states (hover, focus, and active),
* and displaying the value tooltip on discrete sliders.
*/
@Component({
selector: 'mat-slider-visual-start-thumb, mat-slider-visual-end-thumb',
templateUrl: './slider-thumb.html',
styleUrls: ['slider-thumb.css'],
host: {
'class': 'mdc-slider__thumb',
},
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class MatSliderVisualThumb implements AfterViewInit, OnDestroy {
/** Whether the slider displays a numeric value label upon pressing the thumb. */
@Input() _discrete: boolean;

/** The display value of the slider thumb. */
@Input() _valueIndicatorText: string;

/** The MatRipple for this slider thumb. */
@ViewChild(MatRipple) private readonly _ripple: MatRipple;

/** The slider thumb knob */
@ViewChild('knob') _knob: ElementRef<HTMLElement>;

/** Indicates which slider thumb this input corresponds to. */
_thumbPosition: Thumb =
this._elementRef.nativeElement.tagName.toLowerCase()
=== 'mat-slider-visual-start-thumb'.toLowerCase()
? Thumb.START
: Thumb.END;

/** The slider input corresponding to this slider thumb. */
private _sliderInput: MatSliderThumb;

/** The RippleRef for the slider thumbs hover state. */
private _hoverRippleRef: RippleRef;

/** The RippleRef for the slider thumbs focus state. */
private _focusRippleRef: RippleRef;

/** The RippleRef for the slider thumbs active state. */
private _activeRippleRef: RippleRef;

/** Whether the slider thumb is currently being pressed. */
private _isActive: boolean = false;

/** Whether the slider thumb is currently being hovered. */
private _isHovered: boolean = false;

constructor(
private readonly _ngZone: NgZone,
private readonly _slider: MatSlider,
private readonly _elementRef: ElementRef<HTMLElement>) {}

ngAfterViewInit() {
this._ripple.radius = 24;
this._sliderInput = this._slider._getInput(this._thumbPosition);

this._sliderInput.dragStart.subscribe((e: MatSliderDragEvent) => this._onDragStart(e));
this._sliderInput.dragEnd.subscribe((e: MatSliderDragEvent) => this._onDragEnd(e));

this._sliderInput._focus.subscribe(() => this._onFocus());
this._sliderInput._blur.subscribe(() => this._onBlur());

// These two listeners don't update any data bindings so we bind them
// outside of the NgZone to pervent angular from needlessly running change detection.
this._ngZone.runOutsideAngular(() => {
this._elementRef.nativeElement.addEventListener('mouseenter', this._onMouseEnter);
this._elementRef.nativeElement.addEventListener('mouseleave', this._onMouseLeave);
});
}

ngOnDestroy() {
this._sliderInput.dragStart.unsubscribe();
this._sliderInput.dragEnd.unsubscribe();
this._sliderInput._focus.unsubscribe();
this._sliderInput._blur.unsubscribe();
this._elementRef.nativeElement.removeEventListener('mouseenter', this._onMouseEnter);
this._elementRef.nativeElement.removeEventListener('mouseleave', this._onMouseLeave);
}

// ** IMPORTANT NOTE **
//
// _onMouseEnter() and _onMouseLeave() must be arrow functions because they are
// called from _ngZone.runOutsideAngular() which will change what "this" refers to.

private _onMouseEnter = () => {
this._isHovered = true;
// We don't want to show the hover ripple on top of the focus ripple.
// This can happen if the user tabs to a thumb and then the user moves their cursor over it.
if (!this._isShowingRipple(this._focusRippleRef)) {
this._showHoverRipple();
}
}

private _onMouseLeave = () => {
this._isHovered = false;
this._hoverRippleRef?.fadeOut();
}

private _onFocus(): void {
// We don't want to show the hover ripple on top of the focus ripple.
// Happen when the users cursor is over a thumb and then the user tabs to it.
this._hoverRippleRef?.fadeOut();
this._showFocusRipple();
}

private _onBlur(): void {
// Happens when the user tabs away while still dragging a thumb.
if (!this._isActive) {
this._focusRippleRef?.fadeOut();
}
// Happens when the user tabs away from a thumb but their cursor is still over it.
if (this._isHovered) {
this._showHoverRipple();
}
}

private _onDragStart(event: MatSliderDragEvent): void {
if (event.source._thumbPosition === this._thumbPosition) {
this._isActive = true;
this._showActiveRipple();
}
}

private _onDragEnd(event: MatSliderDragEvent): void {
if (event.source._thumbPosition === this._thumbPosition) {
this._isActive = false;
this._activeRippleRef?.fadeOut();
// Happens when the user starts dragging a thumb, tabs away, and then stops dragging.
if (!this._sliderInput._isFocused()) {
this._focusRippleRef?.fadeOut();
}
}
}

/** Handles displaying the hover ripple. */
private _showHoverRipple(): void {
if (!this._isShowingRipple(this._hoverRippleRef)) {
this._hoverRippleRef = this._showRipple({ enterDuration: 0, exitDuration: 0 });
this._hoverRippleRef.element.classList.add('mdc-slider-hover-ripple');
}
}

/** Handles displaying the focus ripple. */
private _showFocusRipple(): void {
if (!this._isShowingRipple(this._focusRippleRef)) {
this._focusRippleRef = this._showRipple({ enterDuration: 0, exitDuration: 0 });
this._focusRippleRef.element.classList.add('mdc-slider-focus-ripple');
}
}

/** Handles displaying the active ripple. */
private _showActiveRipple(): void {
if (!this._isShowingRipple(this._activeRippleRef)) {
this._activeRippleRef = this._showRipple({ enterDuration: 225, exitDuration: 400 });
this._activeRippleRef.element.classList.add('mdc-slider-active-ripple');
}
}

/** Whether the given rippleRef is currently fading in or visible. */
private _isShowingRipple(rippleRef?: RippleRef): boolean {
return rippleRef?.state === RippleState.FADING_IN || rippleRef?.state === RippleState.VISIBLE;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about rippleRef && rippleRef.state !== RippleSate.HIDDEN (that covers fading out too)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't I also need to check rippleRef.state === RippleState.FADING_OUT, too?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't fading out also visible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fading out is still visible but it's on the way out so we don't want to return true for that because that would prevent a new ripple from launching in the place of the one about to fade out

So we'd end up with the inverse of what is there now
return rippleRef?.state !== RippleState.HIDDEN && rippleRef?.state !== RippleState.FADING_OUT

}

/** Manually launches the slider thumb ripple using the specified ripple animation config. */
private _showRipple(animation: RippleAnimationConfig): RippleRef {
return this._ripple.launch(
{animation, centered: true, persistent: true},
);
}

/** Gets the hosts native HTML element. */
_getHostElement(): HTMLElement {
return this._elementRef.nativeElement;
}

/** Gets the native HTML element of the slider thumb knob. */
_getKnob(): HTMLElement {
return this._knob.nativeElement;
}
}

/**
* Directive that adds slider-specific behaviors to an input element inside `<mat-slider>`.
* Up to two may be placed inside of a `<mat-slider>`.
Expand Down Expand Up @@ -111,7 +307,7 @@ export class MatSliderThumb implements AfterViewInit {
@Output() readonly _focus: EventEmitter<void> = new EventEmitter<void>();

/** Indicates which slider thumb this input corresponds to. */
private _thumbPosition: Thumb = this._elementRef.nativeElement.hasAttribute('matSliderStartThumb')
_thumbPosition: Thumb = this._elementRef.nativeElement.hasAttribute('matSliderStartThumb')
? Thumb.START
: Thumb.END;

Expand Down Expand Up @@ -235,14 +431,7 @@ const _MatSliderMixinBase:
})
export class MatSlider extends _MatSliderMixinBase implements AfterViewInit, OnDestroy {
/** The slider thumb(s). */
@ViewChildren('thumb') _thumbs: QueryList<ElementRef<HTMLElement>>;

/** The slider thumb knob(s) */
@ViewChildren('knob') _knobs: QueryList<ElementRef<HTMLElement>>;

/** The span containing the slider thumb value indicator text */
@ViewChildren('valueIndicatorTextElement')
_valueIndicatorTextElements: QueryList<ElementRef<HTMLElement>>;
@ViewChildren(MatSliderVisualThumb) _thumbs: QueryList<MatSliderVisualThumb>;

/** The active section of the slider track. */
@ViewChild('trackActive') _trackActive: ElementRef<HTMLElement>;
Expand Down Expand Up @@ -322,10 +511,10 @@ export class MatSlider extends _MatSliderMixinBase implements AfterViewInit, OnD
_tickMarks: TickMark[];

/** The display value of the start thumb. */
private _startValueIndicatorText: string;
_startValueIndicatorText: string;

/** The display value of the end thumb. */
private _endValueIndicatorText: string;
_endValueIndicatorText: string;

constructor(
readonly _cdr: ChangeDetectorRef,
Expand Down Expand Up @@ -383,30 +572,26 @@ export class MatSlider extends _MatSliderMixinBase implements AfterViewInit, OnD

/** Gets the slider thumb input of the given thumb position. */
_getInput(thumbPosition: Thumb): MatSliderThumb {
return thumbPosition === Thumb.END ? this._inputs.last! : this._inputs.first!;
return thumbPosition === Thumb.END ? this._inputs.last : this._inputs.first;
}

/** Gets the slider thumb HTML input element of the given thumb position. */
_getInputElement(thumbPosition: Thumb): HTMLInputElement {
return this._getInput(thumbPosition)._hostElement;
}

private _getThumb(thumbPosition: Thumb): MatSliderVisualThumb {
return thumbPosition === Thumb.END ? this._thumbs.last : this._thumbs.first;
}

/** Gets the slider thumb HTML element of the given thumb position. */
_getThumbElement(thumbPosition: Thumb): HTMLElement {
const thumbElementRef = thumbPosition === Thumb.END ? this._thumbs.last : this._thumbs.first;
return thumbElementRef.nativeElement;
return this._getThumb(thumbPosition)._getHostElement();
}

/** Gets the slider knob HTML element of the given thumb position. */
_getKnobElement(thumbPosition: Thumb): HTMLElement {
const knobElementRef = thumbPosition === Thumb.END ? this._knobs.last : this._knobs.first;
return knobElementRef.nativeElement;
}

_getValueIndicatorText(thumbPosition: Thumb) {
return thumbPosition === Thumb.START
? this._startValueIndicatorText
: this._endValueIndicatorText;
return this._getThumb(thumbPosition)._getKnob();
}

/**
Expand Down