Blog-Archiv

Donnerstag, 15. Februar 2018

HTML Table with Fixed Header Revisited

Tables with a fixed header row, self-understood for desktop-applications, are not implemented by web browsers. We developers need to implement this. Hard to understand, but a fact. In a passed Blog I showed a minimalistic way to do that. Of course it can be done in many different ways, using CSS and JavaScript. Most people would download some libraries to find something suitable. This Blog tries to summarize ways to do it without library.

WARNING: I do not consider special browsers with proprietary interests, I write about main stream browsers that conform to w3c and whatwg standards. So the source code I present here may not work for any browser.


The Problem

A table header is part of the table, and normally body and header scroll together when the table was restricted to a certain height (mind that you won't see a scrollbar without an explicit height!). Some statements are necessary to make the body scroll independently beneath the header.

The Solutions

I focus on relevant CSS statements only. Nevertheless some colors and borders make it easy to see where the different containers are that make up the solution. A comment will point it out when a CSS statement is just decorative.

1: HTML + CSS "Pinboard" Solution

This solution is built by wrapping the <table> into two <div> elements. The outer one is a pinboard for the header. The inner one is a scrollpane for the table body. Pinning is done by CSS position: absolute.
Mind that for this solution all header cells need to get wrapped into <div> elements!

First Column
Second Column
Cell 1.1 Cell 1.2
Cell 2.1 Cell 2.2
Cell 3.1 Cell 3.2
Cell 4.1 Cell 4.2
Cell 5.1 Cell 4.2
Cell 4.1 Cell 4.2
Cell 5.1 Cell 5.2
Cell 6.1 Cell 6.2
Cell 7.1 Cell 7.2
Cell 8.1 Cell 8.2

Advantages:

  • You can set any width onto the outer div, even percentages
  • No JavaScript needed, no shivering due to scroll event handling

Disadvantages:

  • Can not center the header labels
  • No separator line between the header labels
  • Need to wrap all header cells into <div> elements
  • Need to wrap the table into two <div> elements

CSS:

  .headerPinboard  /* where the fixed header will be */
  {
    position: relative;  /* non-static position, to be parent for absolute */
    padding-top: 2em;  /* place for the fixed header, may vary */
    width: 100%;
  }
  
  .bodyScrollpane
  {
    height: 8em;  /* table height: without height no scrollbar ever! */
    overflow: auto;  /* show scrollbar when needed */
    border-top: 1px solid black;  /* separator line between header and table */
    resize: vertical;
  }
  
  .bodyScrollpane table
  {
    width: 100%;  /* else right side empty space */
  }
  
  .bodyScrollpane th > div  /* the table header cells */
  {
    position: absolute;  /* pinned to next non-static parent */
    top: 0.4em;  /* be at top of parent */
    padding-left: 0.4em;
  }
  
  .bodyScrollpane th
  {
    padding: 0;  /* else compressed empty row on top of table */
  }

HTML: You need to wrap all header cells into <div> elements, because a <th> element can not be separated from its table. The use of <th> is required, <thead> is optional.

<div class="headerPinboard">

  <div class="bodyScrollpane">
  
    <table>

      <thead>
        <tr>
          <th><div>First Column</div></th>
          <th><div>Second Column</div></th>
        </tr>
      </thead>
  
      <tbody>
        <tr>
          <td>....</td>
          <td>....</td>
        </tr>
      </tbody>

    </table>

  </div>  <!-- END bodyScrollpane -->

</div>  <!-- END headerPinboard -->

2: Pure CSS "Block" Solution

This doesn't use any wrapping element, but it needs fixed column widths, i.e. you must give each cell a fixed width, also the table itself needs a width. The table's width and the cells' widths must play together, else the head will not be aligned to columns. This can be found just by "try and error" (and may also be browser-specific).

First Column Second Column
Cell 1.1 Cell 1.2
Cell 2.1 Cell 2.2
Cell 3.1 Cell 3.2
Cell 4.1 Cell 4.2
Cell 5.1 Cell 4.2
Cell 4.1 Cell 4.2
Cell 5.1 Cell 5.2
Cell 6.1 Cell 6.2
Cell 7.1 Cell 7.2
Cell 8.1 Cell 8.2

Advantages:

  • No additional wrapping DIV elements are needed
  • Header labels are centered, vertical separator line is present
  • No JavaScript, no shivering

Disadvantages:

  • Neither full width nor natural width is achievable (which is a big minus!)
  • Width of table and columns must be fixed, percentages destroy the layout
  • Column widths must play together with table width, else header not aligned to columns

CSS:

  .fixedHeaderCssTable thead
  {
    display: block;
  }
  
  .fixedHeaderCssTable tbody
  {
    display: block;
    overflow: auto;
    height: 8em;
    resize: vertical;
  }
  
  /* overall width of table, sum of cell widths plus scrollbar */
  .fixedHeaderCssTable
  {
    width: 50.2em;
  }

  /* width for column 1 */
  .fixedHeaderCssTable th:nth-child(1), .fixedHeaderCssTable td:nth-child(1)
  {
    width: 17em;  /* table width depends on this */
  }
  
  /* width for column 2 */
  .fixedHeaderCssTable th:nth-child(2), .fixedHeaderCssTable td:nth-child(2)
  {
    width: 30em;  /* table width depends on this */
  }

HTML:

<table class="fixedHeaderCssTable">

  <thead>
    <tr>
      <th><div>First Column</div></th>
      <th><div>Second Column</div></th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>....</td>
      <td>....</td>
    </tr>
  </tbody>

</table>

3: JS Solution

This "transforms" the header on every scroll event to always be on top.

First Column Second Column
Cell 1.1 Cell 1.2
Cell 2.1 Cell 2.2
Cell 3.1 Cell 3.2
Cell 4.1 Cell 4.2
Cell 5.1 Cell 4.2
Cell 4.1 Cell 4.2
Cell 5.1 Cell 5.2
Cell 6.1 Cell 6.2
Cell 7.1 Cell 7.2
Cell 8.1 Cell 8.2

Advantages:

  • Both full width and natural width are achievable
  • Header labels are centered

Disadvantages:

  • Header shivers due to scroll event handling (big minus!)
  • Without CSS border-collapse: separate; workaround, header borders are missing generally, especially the vertical separator line between header cells
  • Border management is generally difficult when table has an outer border
  • Browser warning about unstable technique:
    This site appears to use a scroll-linked positioning effect. This may not work well with asynchronous panning; see https://developer.mozilla.org/docs/Mozilla/Performance/ScrollLinkedEffects for further details and to join the discussion on related tools and features!

CSS: the border-collapse: separate; and border-spacing: 0; statements add the missing header border. The elegant table's border-collapse: collapse; must be given up for that. But border management generally leaks here when the table has a border, because then you can see the tabĺe body above the header when you scroll!

  .fixedHeaderJsTable
  {
    overflow-y: auto;
    height: 10.5em;  /* without height no scrollbar */
    resize: vertical;
  }

  .fixedHeaderJsTable table
  {
    border-collapse: separate;
    border-spacing: 0;

    width: 100%;
  }

HTML:

<div class="fixedHeaderJsTable">

  <table>

    <thead>
      <tr>
        <th><div>First Column</div></th>
        <th><div>Second Column</div></th>
      </tr>
    </thead>

    <tbody>
      <tr>
        <td>....</td>
        <td>....</td>
      </tr>
    </tbody>

  </table>

</div>

JS:

var tables = document.querySelectorAll(".fixedHeaderJsTable");
for (var i = 0; i < tables.length; i++) {
    tables[i].addEventListener("scroll", function() {
        this.querySelector("thead").style.transform = "translate(0, "+this.scrollTop+"px)";
    });
}

Decorative Styling Part

For completeness, here is the purely decorative CSS of the example tables above.

  table {
    border: 3px solid blue;
    border-collapse: collapse;
    color: gray;
  }
  table tbody {
    background: lightBlue;
  }  
  table thead {
    background: lightGreen;
  }
  table td, table th {
    border: 1px solid black;
    padding: 0.5em;
  }

  /* "Pinboard" solution only: */
  .headerPinboard
  {
    border: 5px solid green;
    background: lightSalmon;
  }


Resume

Personally I prefer solution 1 (pinboard). It is the best compromise.

From stackoverflow forum:

This frozen-headers-for-a-table issue has been an open wound in HTML/CSS for a long time.

That's exactly what I feel. Each of the presented solutions has its weaknesses. Until browsers implement fixed headers we will see just workarounds.




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.