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.
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:
Kommentar veröffentlichen