Last active
August 17, 2019 18:41
-
-
Save menosprezzi/9d4de98960d343a4283e268073402b6c to your computer and use it in GitHub Desktop.
Stencil's Component Behavior
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ComponentInterface } from '@stencil/core'; | |
import { extendMethod } from '../lang/extend-method'; | |
/** | |
* Represents a Component that implements a Behavior Modification API. | |
*/ | |
export interface ComponentBase extends ComponentInterface { | |
/** | |
* The main native nativeElement from the component. | |
* @Element | |
*/ | |
host: HTMLElement; | |
/** | |
* The unload lifecycle hook. | |
* Note that Stencil only calls componentDidUnload if it is declared in the component. | |
*/ | |
componentDidUnload: () => void; | |
/** | |
* If this behavior is applied to a native element. | |
*/ | |
native?: boolean; | |
} | |
/** | |
* Represents a Component's Behavior Modification. | |
*/ | |
export class ComponentBehavior<T extends ComponentBase> { | |
/** | |
* The internal component that this instance is attached. | |
*/ | |
component: T; | |
/** | |
* A hook called before the component load. | |
*/ | |
beforeAttach?(): Promise<any> | void; | |
/** | |
* A hook called after the component load. | |
*/ | |
attach?(): Promise<any> | void; | |
/** | |
* A hook called before the component destroy. | |
*/ | |
detach?(): Promise<any> | void; | |
constructor(component: T) { | |
this.component = component; | |
if (this.beforeAttach) { this.beforeAttach(); } | |
// Native Elements support | |
if (this.component.native) { | |
console.warn('Attaching a behavior to a native element', this.component); | |
this.component.host.remove = () => { | |
this.component.componentDidUnload(); | |
Element.prototype.remove.apply(this.component.host); | |
}; | |
setTimeout(() => this.component.componentDidLoad(), 0); | |
} | |
extendMethod(this.component, 'componentDidLoad', componentDidLoad => { | |
if (componentDidLoad) { componentDidLoad(); } | |
if (this.attach) { this.attach(); } | |
}); | |
extendMethod(this.component, 'componentDidUnload', componentDidUnload => { | |
if (componentDidUnload) { componentDidUnload(); } | |
if (this.detach) { this.detach(); } | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Component, Element, Event, EventEmitter, Prop, Watch } from '@stencil/core'; | |
import { FocusBehavior, FocusableComponent } from '../../../behaviors/focus-behavior'; | |
import { Bind } from '../../../utils/lang/bind'; | |
/** | |
* Layout webcomponent. | |
*/ | |
@Component({ | |
tag: 'ac-layout', | |
styleUrl: 'ac-layout.scss', | |
shadow: false | |
}) | |
export class AcLayout implements FocusableComponent { | |
@Element() host: HTMLAcLayoutElement; | |
focusBehavior = new FocusBehavior(this); | |
focusTarget: HTMLElement; | |
hasFocus: boolean; | |
/** | |
* Collapse a nav drawer. | |
*/ | |
@Prop({ mutable: true, reflectToAttr: true }) collapsed: 'nav-left'; | |
@Event() contentScroll: EventEmitter<{top: number, left: number}>; | |
@Watch('collapsed') | |
collapsedDidUpdate() { | |
this.hasFocus = !!this.collapsed; | |
} | |
componentDidLoad() { | |
this.focusTarget = this.host.querySelector('.ac-layout__nav-left-container ac-navdrawer'); | |
} | |
componentDidUnload() {} | |
whenBlur(element) { | |
console.log(element, element.dataset); | |
if (!element.dataset.navdrawer && this.collapsed) { | |
this.collapsed = null; | |
} | |
} | |
@Bind | |
private handleContentScroll(ev) { | |
this.contentScroll.emit({ top: ev.target.scrollTop, left: ev.target.scrollLeft }); | |
} | |
hostData() { | |
return { | |
class: { | |
[`ac-layout--${this.collapsed}-collapsed`]: !!this.collapsed, | |
} | |
}; | |
} | |
render() { | |
return [ | |
<div class="ac-layout__nav-left-container"> | |
<slot name="nav-left" /> | |
</div>, | |
<div class="ac-layout__content-container"> | |
<slot name="header" /> | |
<div class="ac-layout__content-scroll" onScroll={this.handleContentScroll}> | |
<slot name="content" /> | |
</div> | |
</div> | |
]; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Extend a method functionality, applying monkeypatch method. | |
* @link https://www.audero.it/blog/2016/12/05/monkey-patching-javascript/ | |
* @param target The target object. | |
* @param method The name of the method that will be extended. | |
* @param extend The method's patch. | |
*/ | |
export function extendMethod(target: any, method: string, extend: (original: Function, args?: any[]) => any) { | |
const original = target[method]; | |
target['__' + method + '__patch_list'] = target['__' + method + '__patches'] || []; | |
const patchList = target['__' + method + '__patch_list']; | |
patchList.push(extend); | |
target[method] = function(...args) { | |
let lastResult; | |
for (const patch of patchList) { | |
lastResult = patch.bind(this)(original ? original.bind(this) : null, args); | |
} | |
return lastResult; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import debug from 'debug/src/browser'; | |
import { ComponentBase, ComponentBehavior } from '../utils/stencil/component-behavior'; | |
const log = debug('solar:FocusBehavior'); | |
/** | |
* Implements a Focus logic in a component, providing a control for blur clicks. | |
*/ | |
export class FocusBehavior extends ComponentBehavior<FocusableComponent> { | |
/** | |
* Check if a target node branch has a data-toggle that match the host id. | |
*/ | |
static checkBypassNode(focusElt, target) { | |
let isBypassNode = false; | |
if (focusElt.id) { | |
const bypassNode = document.body | |
.querySelector(`[data-toggle="${focusElt.id}"]`); | |
isBypassNode = bypassNode ? bypassNode.contains(target) : false; | |
} | |
return isBypassNode; | |
} | |
/** | |
* Filter all the clicks in the body and calls the `whenBlur` from the component if match an outside click. | |
* @param ev A Click Event. | |
*/ | |
private handleBodyClick = (ev: any) => { | |
if (this.component.hasFocus) { | |
const focusElt = this.component.focusTarget || this.component.host; | |
const isClickingOutsideTheTarget = ev.target.closest(focusElt.tagName.toLowerCase()) !== focusElt; | |
if (isClickingOutsideTheTarget && !FocusBehavior.checkBypassNode(focusElt, ev.target)) { | |
log('Clicked outside', focusElt); | |
this.component.whenBlur(ev.target); | |
} | |
} | |
}; | |
/** | |
* Setup the event listener to the body. | |
*/ | |
attach() { | |
document.body.addEventListener('click', this.handleBodyClick); | |
} | |
/** | |
* Remove the event listener to the body. | |
*/ | |
detach() { | |
document.body.removeEventListener('click', this.handleBodyClick); | |
} | |
} | |
/** | |
* Represents a component that implements the focus logic. | |
*/ | |
export interface FocusableComponent extends ComponentBase { | |
/** | |
* The instance of the behavior applied in the component. | |
*/ | |
focusBehavior: FocusBehavior; | |
/** | |
* Called when the behavior detects a click outside of the component. | |
*/ | |
whenBlur: (element: HTMLElement) => void; | |
/** | |
* Used to control the focus state. | |
*/ | |
hasFocus: boolean; | |
/** | |
* The target to be checked. If it is null, the host will be used. | |
*/ | |
focusTarget?: HTMLElement; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment