// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview CrContainerShadowMixin holds logic for showing a drop shadow
* near the top of a container element, when the content has scrolled.
*
* Forked from ui/webui/resources/cr_elements/cr_container_shadow_mixin.ts
*
* Elements using this mixin are expected to define a #container element,
* which is the element being scrolled. If the #container element has a
* show-bottom-shadow attribute, a drop shadow will also be shown near the
* bottom of the container element, when there is additional content to scroll
* to. Examples:
*
* For both top and bottom shadows:
* <div id="container" show-bottom-shadow>...</div>
*
* For top shadow only:
* <div id="container">...</div>
*
* The mixin will take care of inserting an element with ID
* 'cr-container-shadow-top' which holds the drop shadow effect, and,
* optionally, an element with ID 'cr-container-shadow-bottom' which holds the
* same effect. A 'has-shadow' CSS class is automatically added to/removed from
* both elements while scrolling, as necessary. Note that the show-bottom-shadow
* attribute is inspected only during attached(), and any changes to it that
* occur after that point will not be respected.
*
* Clients should either use the existing shared styling in
* cr_shared_style.css, '#cr-container-shadow-[top/bottom]' and
* '#cr-container-shadow-[top/bottom].has-shadow', or define their own styles.
*/
import {assert} from '//resources/js/assert.js';
import {dedupingMixin, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
export enum CrContainerShadowSide {
TOP = 'top',
BOTTOM = 'bottom',
}
type Constructor<T> = new (...args: any[]) => T;
export const CrContainerShadowMixin = dedupingMixin(
<T extends Constructor<PolymerElement>>(superClass: T): T&
Constructor<CrContainerShadowMixinInterface> => {
class CrContainerShadowMixin extends superClass implements
CrContainerShadowMixinInterface {
private intersectionObserver_: IntersectionObserver|null = null;
private dropShadows_: Map<CrContainerShadowSide, HTMLDivElement> =
new Map();
private intersectionProbes_:
Map<CrContainerShadowSide, HTMLDivElement> = new Map();
private sides_: CrContainerShadowSide[]|null = null;
override connectedCallback() {
super.connectedCallback();
const hasBottomShadow =
this.getContainer_().hasAttribute('show-bottom-shadow');
this.sides_ = hasBottomShadow ?
[CrContainerShadowSide.TOP, CrContainerShadowSide.BOTTOM] :
[CrContainerShadowSide.TOP];
this.sides_!.forEach(side => {
// The element holding the drop shadow effect to be shown.
const shadow = document.createElement('div');
shadow.id = `cr-container-shadow-${side}`;
shadow.classList.add('cr-container-shadow');
this.dropShadows_.set(side, shadow);
this.intersectionProbes_.set(side, document.createElement('div'));
});
this.getContainer_().parentNode!.insertBefore(
this.dropShadows_.get(CrContainerShadowSide.TOP)!,
this.getContainer_());
this.getContainer_().prepend(
this.intersectionProbes_.get(CrContainerShadowSide.TOP)!);
if (hasBottomShadow) {
this.getContainer_().parentNode!.insertBefore(
this.dropShadows_.get(CrContainerShadowSide.BOTTOM)!,
this.getContainer_().nextSibling);
this.getContainer_().append(
this.intersectionProbes_.get(CrContainerShadowSide.BOTTOM)!);
}
this.enableShadowBehavior(true);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.enableShadowBehavior(false);
}
private getContainer_(): HTMLElement {
return this.shadowRoot!.querySelector('#container')!;
}
private getIntersectionObserver_(): IntersectionObserver {
const callback = (entries: IntersectionObserverEntry[]) => {
// In some rare cases, there could be more than one entry per
// observed element, in which case the last entry's result
// stands.
for (const entry of entries) {
const target = entry.target;
this.sides_!.forEach(side => {
if (target === this.intersectionProbes_.get(side)) {
this.dropShadows_.get(side)!.classList.toggle(
'has-shadow', entry.intersectionRatio === 0);
}
});
}
};
return new IntersectionObserver(
callback, {root: this.getContainer_(), threshold: 0});
}
/**
* @param enable Whether to enable the mixin or disable it.
* This function does nothing if the mixin is already in the
* requested state.
*/
enableShadowBehavior(enable: boolean) {
// Behavior is already enabled/disabled. Return early.
if (enable === !!this.intersectionObserver_) {
return;
}
if (!enable) {
this.intersectionObserver_!.disconnect();
this.intersectionObserver_ = null;
return;
}
this.intersectionObserver_ = this.getIntersectionObserver_();
// Need to register the observer within a setTimeout() callback,
// otherwise the drop shadow flashes once on startup, because of the
// DOM modifications earlier in this function causing a relayout.
window.setTimeout(() => {
if (this.intersectionObserver_) {
// In case this is already detached.
this.intersectionProbes_.forEach(probe => {
this.intersectionObserver_!.observe(probe);
});
}
});
}
/**
* Shows the shadows. The shadow mixin must be disabled before
* calling this method, otherwise the intersection observer might
* show the shadows again.
*/
showDropShadows() {
assert(!this.intersectionObserver_);
assert(this.sides_);
for (const side of this.sides_) {
this.dropShadows_.get(side)!.classList.toggle('has-shadow', true);
}
}
}
return CrContainerShadowMixin;
});
export interface CrContainerShadowMixinInterface {
enableShadowBehavior(enable: boolean): void;
showDropShadows(): void;
}