Blog-Archiv

Montag, 5. Februar 2018

ES6 Mixin Example Pushbutton 3D

ES6: ?

In this Blog I will present a compound ES6 mixin example called "Pushbutton 3D". It contains a lot of small modules that are mixed together to manage the 3D effects and events for a push button. Mind that this is not a web component implementation but just an ES6 exercise.

Below you see two buttons. Try to click them. They are focusable by keyboard navigation, both ENTER and SPACE keys would trigger them when focused. Any time they are triggered they will display "Action Performed!" below. The animation speed, shadow thickness and direction, border radius, everything is configurable.

Component One
Component Two
....

Tests


Actions
Expected Results
Press left mouse over green button, hold Animated 3D lowering effect
Release left mouse over button Animated 3D raising effect, action is performed
Press TAB to move focus to blue button Button shows a thin focus-border, may be browser-specific
Press and release SPACE key Same as "Press left mouse" and "Release left mouse"
Press and release right mouse over button No effect except default browser context menu
Press left mouse over button Same as "Press left mouse"
Drag mouse out of button while pressed Animated 3D raising effect
Release mouse NO action is performed!
Press left mouse over button Same as "Press left mouse"
Press TAB key Animated 3D raising effect
Release mouse NO action is performed!
Press left mouse on button, press ENTER key, hold both Same as "Press left mouse"
Release left mouse while holding ENTER key No effect
Again press left mouse, then release ENTER key No effect
Release left mouse (none is pressed any more) Same as "Release left mouse", action is performed
Press left mouse over button, hold Same as "Press left mouse"
Press ENTER, hold No effect
Drag mouse out of button while pressed, release No effect
Release ENTER key Same as "Release left mouse", action is performed
Press left mouse over button, hold Same as "Press left mouse"
Press ENTER key, hold No effect
Release left mouse No effect
Click left mouse outside button while holding ENTER Animated 3D raising effect, but NO action is performed!

Source Code

The source code for pushbutton-3D could be much shorter, but it wouldn't be reusable then. I implemented it via ES6 mixins, each responsible for a different aspect like mouse-click, keyboard, shadow painting, and so on. Each mixin is in its own module. Remember that ES6 demands one file per module. The 11 files below are well readable and commented, so I won't introduce them further. Here is the final application that uses them:

<script type="module">

    import { configuration, css, PushButton3d } from "./modules/component/push-button-3d.js"
    
    function actionPerformed(event) {
        // TODO
    }
    
    const componentOne = document.getElementById("componentOne")
    const configOne = configuration()
    configOne.actionPerformed = actionPerformed
    new PushButton3d(componentOne, configOne)
    
    const componentTwo = document.getElementById("componentTwo")
    const configTwo = configuration()
    configTwo.horizontalShadow = "-10px"
    configTwo.verticalShadow = "-15px"
    configTwo.animationMillis = 543
    configTwo.actionPerformed = actionPerformed
    const style = css()
    style.padding = "1.2em"
    style["border-radius"] = "2em"
    style["border-width"] = "3px"
    new PushButton3d(componentTwo, configTwo, style)
     
</script>

Click to expand the sources in module directory tree below.

modules
lang
mixin.js
/**
 * Mixin inheritance builder.
 * @param SuperClassOrFactory required, the basic super-class that given factories
 *     should extend, or a factory in case no basic class to extend is needed.
 * @param classFactories optional, arbitrary number of arguments that are class-factories.
 * @return a class created by extending SuperClass (when given) and all classes created
 *     by given factories.
 */
export default function mix(SuperClassOrFactory, ...classFactories) {
    const superClassIsFactory = (SuperClassOrFactory.prototype === undefined)
    const factories = superClassIsFactory ? [ SuperClassOrFactory, ...classFactories ] : classFactories
    let resultClass = superClassIsFactory ? undefined : SuperClassOrFactory
    
    /* "last wins" override strategy */
    for (const classFactory of factories) {
        if (classFactory.prototype !== undefined)
            throw new Error("Class factory seems to be a class: "+classFactory)
            
        resultClass = classFactory(resultClass)
    }
    return resultClass
}
event
event-source.js
export default function getEventSource(event) {
    return (event !== undefined) ? (event.currentTarget || event.srcElement) : undefined
}
mouse-click-observable.js
export default (SuperClass = Object) => class MouseClickObservable extends SuperClass
{
    constructor(eventSource) {
        super(eventSource)
        
        eventSource.addEventListener("mousedown", (event) => this.mouseDown(event))
        eventSource.addEventListener("mouseup", (event) => this.mouseUp(event))
    }

    mouseDown(event) {
        this.mouseDownTime = new Date().getTime()
        this.isMouseDown = true
        
        if (this.isLeftMouseButton(event))
            this.leftMouseDown(event)
    }
    
    leftMouseDown(event) {
    }
    
    mouseUp(event) {
        if (this.isLeftMouseButton(event))
            this.leftMouseUp(event)
        
        this.isMouseDown = false
    }
    
    leftMouseUp(event) {
    }
    
    isLeftMouseButton(event) {
        return (event.button === 0)
    }
    
    getMillisSinceMouseDown() {
        return (this.mouseDownTime > 0) ? (new Date().getTime() - this.mouseDownTime) : 0
    }
}
mouse-move-observable.js
export default (SuperClass = Object) => class MouseMoveObservable extends SuperClass
{
    constructor(eventSource) {
        super(eventSource)
        
        eventSource.addEventListener("mouseover", (event) => this.mouseOver(event))
        eventSource.addEventListener("mouseout", (event) => this.mouseOut(event))
    }

    mouseOver(event) {
        this.isMouseOver = true
    }
    
    mouseOut(event) {
        this.isMouseOver = false
    }
}
keyboard-observable.js
export default (SuperClass = Object) => class KeyboardObservable extends SuperClass
{
    constructor(eventSource) {
        super(eventSource)
        
        eventSource.addEventListener("keydown", (event) => this.keyDown(event))
        eventSource.addEventListener("keyup", (event) => this.keyUp(event))
    }

    keyDown(event) {
        this.keyDownTime = new Date().getTime()
        this.isKeyDown = true
    }
    
    keyUp(event) {
        this.isKeyDown = false
    }

    isSubmitKey(event) {
        return (event.key === " " || event.key === "Enter")
    }
    
    isScrollDown(event) {
        return (event.key === " ")  /* spacebar scrolls down */
    }
    
    isFocusChangeKey(event) {
        return (event.key === "Tab")
    }
    
    getMillisSinceKeyDown() {
        return (this.keyDownTime > 0) ? (new Date().getTime() - this.keyDownTime) : 0
    }
}
focus-observable.js
export default (SuperClass = Object) => class FocusObservable extends SuperClass
{
    constructor(eventSource) {
        super(eventSource)
        
        eventSource.setAttribute("tabindex", 0) /* make focusable by keyboard */
        
        eventSource.addEventListener("focus", (event) => this.focus(event))
        eventSource.addEventListener("blur", (event) => this.blur(event))
    }

    focus(event) {
        this.isFocused = true
    }
    
    blur(event) {
        this.isFocused = false
    }
}
style
movable.js
export default (SuperClass = Object) => class Movable extends SuperClass
{
    constructor(eventSource) {
        super(eventSource)
        
        this.positionLeftTop = this.originalPositionLeftTop = new PositionLeftTop(eventSource)
    }
    
    moveRelative(eventSource, leftDelta, topDelta) {
        this.positionLeftTop = setPositionLeftTop(eventSource, leftDelta, topDelta)
    }
    
    resetMove(eventSource) {
        this.positionLeftTop = setPositionLeftTop(
            eventSource,
            this.originalPositionLeftTop.left,
            this.originalPositionLeftTop.top,
            this.originalPositionLeftTop.position)
    }
    
    halfPixels(pixels) {
        const p = parseInt(pixels)
        return Math.round(p / 2)+"px"
    }
}


class PositionLeftTop
{
    constructor(eventSource) {
        const style = eventSource.style
        
        this.left = (style["left"] || "0")
        this.top = (style["top"] || "0")
        this.position = (style["position"] || "static")
    }
}

function setPositionLeftTop(eventSource, leftDelta, topDelta, position) {
    const style = eventSource.style
    
    if (position !== undefined)
        style.position = position
    else
        style.position = "relative"
    
    style["left"] = leftDelta
    style["top"] = topDelta
        
    return new PositionLeftTop(eventSource)
}
shadowable.js
export default (SuperClass = Object) => class Shadowable extends SuperClass
{
    constructor(eventSource) {
        super(eventSource)
        
        this.shadow = this.originalShadow = getShadow(eventSource)
    }
    
    setShadow(eventSource, newShadow) {
        this.shadow = setShadow(eventSource, newShadow) /* is NOT a self-call! */
    }
    
    resetShadow(eventSource) {
        this.shadow = setShadow(eventSource, this.originalShadow)
    }
}

function getShadow(eventSource) {
    const style = eventSource.style
    return (style["box-shadow"] || "")  /* undefined would not cause any change */
}

function setShadow(eventSource, shadow) {
    const style = eventSource.style
    return (style["box-shadow"] = shadow)
}
component
action-button.js
import mix from "../lang/mixin.js"
import MouseClickObservable from "../event/mouse-click-observable.js"
import KeyboardObservable from "../event/keyboard-observable.js"

/**
 * An action-button must be triggerable by keyboard and mouse.
 * This mixin redirects left-mouse-up, ENTER and SPACE keys to actionPerformed().
 * It also provides a convenience getMillisSinceEvent() for both keyboard and mouse.
 */
export default (SuperClass = Object) => class ActionButton extends mix(
        SuperClass,
        MouseClickObservable,
        KeyboardObservable)
{
    actionPerformed(event) {
    }
    
    mouseDown(event) {
        if ( ! this.isKeyDown )
            super.mouseDown(event)
        else
            this.isMouseDown = true  /* avoid calling leftMouseDown() */
    }
    
    mouseUp(event) {
        if ( ! this.isMouseDown )
            return
        
        if ( ! this.isKeyDown ) {
            super.mouseUp(event)  /* will call leftMouseUp() */
            
            if (this.isLeftMouseButton(event))
                this.actionPerformed(event)
        }
        else
            this.isMouseDown = false  /* avoid calling leftMouseUp() */
    }
    
    keyDown(event) {
        super.keyDown(event)
        
        if ( ! this.isMouseDown && this.isSubmitKey(event) )
            this.leftMouseDown(event)
            
        if (this.isScrollDown(event))  /* stop spacebar scrolling down */
            event.preventDefault()
    }
    
    keyUp(event) {
        if ( ! this.isKeyDown )
            return
        
        if ( ! this.isMouseDown && this.isSubmitKey(event) ) {
            this.leftMouseUp(event)
            this.actionPerformed(event)
        }
            
        super.keyUp(event)
    }
    
    getMillisSinceEvent() {
        const lastKeyEvent = this.getMillisSinceKeyDown()
        const lastMouseEvent = this.getMillisSinceMouseDown()
        
        if (lastKeyEvent <= 0)  /* no valid value */
            return lastMouseEvent
            
        if (lastMouseEvent <= 0)  /* no valid value */
            return lastKeyEvent
            
        return Math.min(lastKeyEvent, lastMouseEvent)  /* both have a valid value */
    }
}
action-button-focusable.js
import mix from "../lang/mixin.js"
import ActionButton from "./action-button.js"
import FocusObservable from "../event/focus-observable.js"
import MouseMoveObservable from "../event/mouse-move-observable.js"

/**
 * Adds focus-lost and mouse-dragged-out to action button.
 */
export default (SuperClass = Object) => class ActionButtonFocusable extends mix(
        SuperClass,
        ActionButton,
        FocusObservable,
        MouseMoveObservable)
{
    mouseOut(event) {
        if (this.isMouseDown) {  /* mouse dragging outside while pressed */
            if ( ! this.isKeyDown )
                this.leftMouseUp(event)  /* release the pressed button */
                
            this.isMouseDown = false
        }
        super.mouseOut(event)
    }
    
    keyUp(event) {
        super.keyUp(event)
        
        if ( this.isMouseDown && ! this.isMouseOver )  /* pressed mouse has been dragged outside */
            this.mouseUp(event)  /* release the still pressed button */
    }
    
    blur(event) {
        super.blur(event)
        
        if (this.isKeyDown) {
            this.keyUp(event)
            this.leftMouseUp(event)  /* must do this because event is not SPACE or ENTER */
        }
        else if (this.isMouseDown) {
            this.leftMouseUp(event)
        }
        this.isMouseDown = false
        this.isKeyDown = false
    }
}
push-button-3d.js
import mix from "../lang/mixin.js"
import getEventSource from "../event/event-source.js"
import ActionButtonFocusable from "./action-button-focusable.js"
import Shadowable from "../style/shadowable.js"
import Movable from "../style/movable.js"

/**
 * Returns a configuration object with default values.
 * The shadow's blur-range will be always the same as horizontalShadow.
 * @return constructor parameter object containing:
 *     animationMillis how fast button will go down and up, default 200;
 *     horizontalShadow integer value in pixels, when negative, shadow will go to left, "4px";
 *     verticalShadow integer value in pixels, when negative, shadow will go to top, "6px";
 *     actionPerformer the function to call when user presses button, with event as parameter.
 */
export function configuration() {
    return {
        animationMillis: 200,
        horizontalShadow: "4px",
        verticalShadow: "6px",
        actionPerformer: function(event) {
            alert("Please pass an actionPerformed(event) function in constructor configuration!")
        }
    }
}

/**
 * Returns CSS settings with default values.
 */
export function css() {
    return {
        padding: "0.8em",
        border: "1px solid",
        "border-radius": "0.6em",
        display: "inline-block",
        cursor: "pointer",
        "user-select": "none"  /* prevent button label selection */
    }
}

/**
 * Styles a given element to be a 3D push-button.
 * @param element the element to customize.
 * @param config animation configuration as delivered by configuration().
 * @param styles CSS as delivered by css().
 */
export class PushButton3d extends mix(
        ActionButtonFocusable,
        Shadowable,
        Movable)
{
    constructor(element, config = configuration(), styles = css()) {
        initializeStyles(element, styles)
        initializeConfiguration(element, config)
        
        super(element)  /* backup values for resets */
        
        this.config = config
        animate(element, config)
    }
    
    actionPerformed(event) {
        super.actionPerformed(event)
        this.config.actionPerformed(event)
    }
    
    leftMouseDown(event) {
        super.leftMouseDown(event)
        
        const eventSource = getEventSource(event)
        
        this.setShadow(eventSource, "0 0")  /* remove shadow on push */
        this.moveRelative(  /* move button half way to where shadow was */
            eventSource,
            this.halfPixels(this.config.horizontalShadow),
            this.halfPixels(this.config.verticalShadow))
        
        if (this.config.mouseDownColor)
            this.setColor(eventSource, this.config.mouseDownColor)
    }
    
    leftMouseUp(event) {
        super.leftMouseUp(event)
        
        const eventSource = getEventSource(event)
        const elapsed = this.getMillisSinceEvent()
        const delay = this.config.animationMillis - elapsed
        
        setTimeout(
            () => {
                this.resetShadow(eventSource)
                this.resetMove(eventSource)
            },
            Math.max(delay, 1)
        )
    }
}


function initializeStyles(element, styles) {
    for (let key in styles)
        if (styles.hasOwnProperty(key) && styles[key])
            element.style[key] = styles[key]
}

function initializeConfiguration(element, config) {
    /* we need to move this button slightly when pressed */
    element.style["position"] = "relative"
    /* must set top and left, else 1st animation missing */
    element.style["top"] = "0"
    element.style["left"] = "0"
    
    /* must set shadow, else 1st animation missing */
    const blurNumber = parseInt(config.horizontalShadow)
    const blur = Math.abs(blurNumber)+"px"
    element.style["box-shadow"] =
        config.horizontalShadow+" "+
        config.verticalShadow+" "+
        blur /* assuming horizontal shadow is also blur-amount */
}

function animate(element, config) {
    const animationSeconds = (config.animationMillis / 1000).toFixed(3)+"s"
    element.style["transition"] = 
        "box-shadow "+animationSeconds+
        ", left "+animationSeconds+
        ", top "+animationSeconds
}

Resume

A long tedious implementation, but reusable code! Maybe you won't have to fix a bug in more than one place. The details like multiple constructor execution in mixins may frighten you, as they did me. Don't forget super calls in overrides. Always write this.callSomething() in classes, not callSomething(). Like John von Neumann said: In mathematics you don't understand things. You just get used to them.




Keine Kommentare: