14.0.4 • Published 1 year ago

@loipham/material-ripple v14.0.4

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

Ripple

MDC Ripple provides the JavaScript and CSS required to provide components (or any element at all) with a material "ink ripple" interaction effect. It is designed to be efficient, uninvasive, and usable without adding any extra DOM to your elements.

MDC Ripple also works without JavaScript, where it gracefully degrades to a simpler CSS-Only implementation.

Design & API Documentation

Installation

npm install @loipham/material-ripple

Usage

A ripple can be applied to a variety of elements to represent interactive surfaces. Several MDC Web components, such as Button, FAB, Checkbox and Radio, also use ripples.

A ripple can be added to an element through either a JavaScript or CSS-only implementation. When a ripple is initialized on an element using JS, it dynamically adds a mdc-ripple-upgraded class to that element. If ripple JS is not initialized but Sass mixins are included on the element, the ripple uses a simpler CSS-only implementation which relies on the :hover, :focus, and :active pseudo-classes.

CSS Classes

CSS ClassDescription
mdc-ripple-surfaceAdds a ripple to the element
mdc-ripple-surface--primarySets the ripple color to the theme primary color
mdc-ripple-surface--accentSets the ripple color to the theme secondary color

Sass APIs

In order to fully style the ripple effect for different states (hover/focus/pressed), the following mixins must be included:

  • surface, for base styles
  • Either radius-bounded or radius-unbounded, to appropriately size the ripple on the surface
  • Either the basic or advanced states mixins, as explained below
Using basic states mixins
@use "@loipham/material-ripple";

.my-surface {
  @include ripple.surface;
  @include ripple.radius-bounded;
  @include ripple.states;
}
Using advanced states mixins
.my-surface {
  @include ripple.surface;
  @include ripple.radius-bounded;
  @include ripple.states-base-color(black);
  @include ripple.states-opacities((hover: .1, focus: .3, press: .4));
}

These APIs use pseudo-elements for the ripple effect: ::before for the background, and ::after for the foreground.

Ripple Mixins

MixinDescription
surfaceMandatory. Adds base styles for a ripple surface
radius-bounded($radius)Adds styles for the radius of the ripple effect,for bounded ripple surfaces
radius-unbounded($radius)Adds styles for the radius of the ripple effect,for unbounded ripple surfaces

NOTE: It is mandatory to include either radius-bounded or radius-unbounded. In both cases, $radius is optional and defaults to 100%.

Basic States Mixins

MixinDescription
states($color, $has-nested-focusable-element)Mandatory. Adds state and ripple styles in the given color
states-activated($color, $has-nested-focusable-element)Optional. Adds state and ripple styles for activated states in the given color
states-selected($color, $has-nested-focusable-element)Optional. Adds state and ripple styles for selected states in the given color

NOTE: Each of the mixins above adds ripple styles using the indicated color, deciding opacity values based on whether the passed color is light or dark.

NOTE: The states-activated and states-selected mixins add the appropriate state styles to the root element containing &--activated or &--selected modifier classes respectively.

NOTE: $has-nested-focusable-element defaults to false but should be set to true if the component contains a focusable element (e.g. an input) inside the root element.

Advanced States Mixins

When using the advanced states mixins instead of the basic states mixins, every one of the mixins below should be included at least once.

These mixins can also be used to emit activated or selected styles, by applying them within a selector for &--activated or &--selected modifier classes.

MixinDescription
states-base-color($color)Mandatory. Sets up base state styles using the provided color
states-opacities($opacity-map, $has-nested-focusable-element)Sets the opacity of the ripple in any of the hover, focus, or press states. The opacity-map can specify one or more of these states as keys. States not specified in the map resort to default opacity values.

NOTE: $has-nested-focusable-element defaults to false but should be set to true if the component contains a focusable element (e.g. an input) inside the root element.

DEPRECATED: The individual mixins states-hover-opacity($opacity), states-focus-opacity($opacity, $has-nested-focusable-element), and states-press-opacity($opacity) are deprecated in favor of the unified states-opacities($opacity-map, $has-nested-focusable-element) mixin above.

Sass Functions

FunctionDescription
states-opacity($color, $state)Returns the appropriate default opacity to apply to the given color in the given state (hover, focus, press, selected, or activated)

MDCRipple

The MDCRipple JavaScript component allows for programmatic activation / deactivation of the ripple, for interdependent interaction between components. For example, this is used for making form field labels trigger the ripples in their corresponding input elements.

To use the MDCRipple component, first import the MDCRipple JS. Then, initialize the ripple with the correct DOM element.

const surface = document.querySelector('.my-surface');
const ripple = new MDCRipple(surface);

You can also use attachTo() as an alias if you don't care about retaining a reference to the ripple.

MDCRipple.attachTo(document.querySelector('.my-surface'));
PropertyValue TypeDescription
unboundedBooleanWhether or not the ripple is unbounded

NOTE: Surfaces for bounded ripples should have the overflow property set to hidden, while surfaces for unbounded ripples should have it set to visible.

Method SignatureDescription
activate() => voidProxies to the foundation's activate method
deactivate() => voidProxies to the foundation's deactivate method
layout() => voidProxies to the foundation's layout method
handleFocus() => voidHandles focus event on the ripple surface
handleBlur() => voidHandles blur event on the ripple surface

MDCRippleAdapter

Method SignatureDescription
browserSupportsCssVars() => booleanWhether or not the given browser supports CSS Variables.
isUnbounded() => booleanWhether or not the ripple should be considered unbounded.
isSurfaceActive() => booleanWhether or not the surface the ripple is acting upon is active
isSurfaceDisabled() => booleanWhether or not the ripple is attached to a disabled component
addClass(className: string) => voidAdds a class to the ripple surface
removeClass(className: string) => voidRemoves a class from the ripple surface
containsEventTarget(target: EventTarget) => booleanWhether or not the ripple surface contains the given event target
registerInteractionHandler(evtType: string, handler: EventListener) => voidRegisters an event handler on the ripple surface
deregisterInteractionHandler(evtType: string, handler: EventListener) => voidUnregisters an event handler on the ripple surface
registerDocumentInteractionHandler(evtType: string, handler: EventListener) => voidRegisters an event handler on the documentElement
deregisterDocumentInteractionHandler(evtType: string, handler: EventListener) => voidUnregisters an event handler on the documentElement
registerResizeHandler(handler: Function) => voidRegisters a handler to be called when the ripple surface (or its viewport) resizes
deregisterResizeHandler(handler: Function) => voidUnregisters a handler to be called when the ripple surface (or its viewport) resizes
updateCssVariable(varName: string, value: (string or null)) => voidSets the CSS property varName on the ripple surface to the value specified
computeBoundingRect() => ClientRectReturns the ClientRect for the surface
getWindowPageOffset() => {x: number, y: number}Returns the page{X,Y}Offset values for the window object

NOTE: When implementing browserSupportsCssVars, please take the Safari 9 considerations into account. We provide a supportsCssVariables function within the util.js which we recommend using, as it handles this for you.

MDCRippleFoundation

Method SignatureDescription
activate() => voidTriggers an activation of the ripple (the first stage, which happens when the ripple surface is engaged via interaction, such as a mousedown or a pointerdown event). It expands from the center.
deactivate() => voidTriggers a deactivation of the ripple (the second stage, which happens when the ripple surface is engaged via interaction, such as a mouseup or a pointerup event). It expands from the center.
layout() => voidRecomputes all dimensions and positions for the ripple element. Useful if a ripple surface's position or dimension is changed programmatically.
setUnbounded(unbounded: boolean) => voidSets the ripple to be unbounded or not, based on the given boolean.

Tips/Tricks

Using a sentinel element for a ripple

Usually, you'll want to leverage ::before and ::after pseudo-elements when integrating the ripple into MDC Web components. If you can't use pseudo-elements, create a sentinel element inside your root element. The sentinel element covers the root element's surface.

<div class="my-component">
  <div class="mdc-ripple-surface"></div>
  <!-- your component DOM -->
</div>

Unbounded ripple

You can set a ripple to be unbounded, such as those used for MDC Checkboxes and MDC Radio Buttons, either imperatively in JS or declaratively using the DOM.

Using JS

Set the unbounded property on the MDCRipple component.

const ripple = new MDCRipple(root);
ripple.unbounded = true;

Using DOM

Add a data-mdc-ripple-is-unbounded attribute to your root element.

<div class="my-surface" data-mdc-ripple-is-unbounded>
  <p>A surface</p>
</div>

MDCRipple with custom functionality

Usually, you'll want to use MDCRipple along with the component for the actual UI element you're trying to add a ripple to. MDCRipple has a static createAdapter(instance) method that can be used to instantiate a ripple within any MDCComponent that requires custom adapter functionality.

class MyMDCComponent extends MDCComponent {
  constructor() {
    super(...arguments);
    const foundation = new MDCRippleFoundation({
      ...MDCRipple.createAdapter(this),
      isSurfaceActive: () => this.isActive, /* Custom functionality */
    });
    this.ripple = new MDCRipple(this.root, foundation);
  }
}

Handling keyboard events for custom UI components

Different keyboard events activate different elements. For example, the space key activates buttons, while the enter key activates links.

MDCRipple uses the adapter.isSurfaceActive() method to detect whether or not a keyboard event has activated the surface the ripple is on. Our vanilla implementation of the adapter does this by checking whether the :active pseudo-class has been applied to the ripple surface. However, this approach will not work for custom components that the browser does not apply this pseudo-class to.

To make your component work properly with keyboard events, you'll have to listen for both keydown and keyup events to set some state that determines whether or not the surface is "active".

class MyComponent {
  constructor(element) {
    this.root = element;
    this.active = false;
    this.root.addEventListener('keydown', evt => {
      if (isSpace(evt)) {
        this.active = true;
      }
    });
    this.root.addEventListener('keyup', evt => {
      if (isSpace(evt)) {
        this.active = false;
      }
    });
    const foundation = new MDCRippleFoundation(
      ...MDCRipple.createAdapter(this),
      // ...
      isSurfaceActive: () => this.active
    });
    this.ripple = new MDCRipple(this.root, foundation);
  }
}

Specifying known element dimensions for asynchronous style loading

If you asynchronously load style resources, such as loading stylesheets dynamically or loading fonts, then adapter.getClientRect() may return incorrect dimensions if the ripple is initialized before the stylesheet/font has loaded. In this case, you can override the default behavior of getClientRect() to return the correct results.

For example, if you know an icon font sizes its elements to 24px width and height:

const foundation = new MDCRippleFoundation({
  // ...
  computeBoundingRect: () => {
    const {left, top} = element.getBoundingClientRect();
    const dim = 24;
    return {
      left,
      top,
      width: dim,
      height: dim,
      right: left + dim,
      bottom: top + dim
    };
  }
});
this.ripple = new MDCRipple(this.root, foundation);

The util API

External frameworks and libraries can use the following utility methods when integrating a component.

Method SignatureDescription
util.supportsCssVariables(windowObj, forceRefresh = false) => BooleanDetermine whether the current browser supports CSS variables (custom properties)
util.getNormalizedEventCoords(ev, pageOffset, clientRect) => objectDetermines X/Y coordinates of an event normalized for touch events and ripples

NOTE: The function util.supportsCssVariables cache its results; forceRefresh will force recomputation, but is used mainly for testing and should not be necessary in normal use.

Caveats

Caveat: Safari 9

TL;DR ripples are disabled in Safari 9 because of a bug with CSS variables.

The ripple works by updating CSS variables used by pseudo-elements. Unfortunately, in Safari 9.1, there is a bug where updating a CSS variable on an element will not trigger a style recalculation on that element's pseudo-elements (try out this codepen in Chrome, and then in Safari 9.1 to see the issue). Webkit builds which have this bug fixed (e.g. the builds used in Safari 10+) support CSS 4 Hex Notation while those without the fix don't. We feature-detect whether we are working with a WebKit build that can handle our usage of CSS variables.

Caveat: Mobile Safari

TL;DR for CSS-only ripple styles to work as intended, register a touchstart event handler on the affected element or its ancestor.

Mobile Safari does not trigger :active styles noticeably by default, as documented in the Safari Web Content Guide. This effectively suppresses the intended pressed state styles for CSS-only ripple surfaces. This behavior can be remedied by registering a touchstart event handler on the element, or on any common ancestor of the desired elements.

See this StackOverflow answer for additional information on mobile Safari's behavior.