WILT: Behat, Mink and ShadowDom

Once again I'm rebuilding part of the site using more modern coding practices in an effort to keep skills up to date. Also it's fun. This time I'm looking at the GURPS character generator I wrong years ago. Of all the pages on my site, it's the most referenced in my apache logs so I'm looking at what I can do better. The statistics, advantages and skills are a lot of repeated components, and nowadays we've got custom elements and templates/ slots to make life easier. Currently, I'm playing with custom elements because all my components have a lot of javascript attached to them, and later on using templates may help but not for now.

Statistics are the simplest of the elements so far. You've got one score you change, which alters a lot of other scores and costs. So my strength attribute looks like

1<statistic-group id="strength" value="10" costper="10" abbr="ST" linked="hitpoints" labbr="HP" current>

The custom element is statistic-group and it has javascript to make it do things.

 1    class Statistic extends HTMLElement {
 2        constructor() {
 3            super();
 4
 5            // get config from attributes
 6            const value = this.getAttribute('value');
 7            const id = this.getAttribute('id');
 8            const abbr = this.getAttribute('abbr');
 9            const costper = this.getAttribute('costper');
10            const cost = (value - 10) * costper;
11            const labbr = this.getAttribute('labbr');
12            const current = this.hasAttribute('current');
13
14            // events
15            this.adjustValue = this.adjustValue.bind(this);
16
17            // create shadow dom root
18            this._root = this.attachShadow({
19                mode: 'open'
20            });
21            let currentAttr = `<span class="spacer"></span>`;
22            if (current) {
23                currentAttr = `<label class="current">current</label><input type="text" value="" disabled/>`;
24            }
25            this._root.innerHTML = `
26            <style>...</style>
27            <section style="position:relative;">
28            <label class="primary" for="${id}">${abbr}</label>
29            <input type="text" class="keyvalue" value="${value}" id="${id}" />
30            <span class="cost">${cost}</span>
31            <label class="secondary" for="l${id}">${labbr}</label>
32            <input type="text" value="${value}" id="l${id}" disabled />${currentAttr}
33            <span class="cost">&nbsp;&nbsp;&nbsp;</span>
34            </section>`;
35
36            // attach
37            this.valueField = this.shadowRoot.querySelector('input.keyvalue');
38            statistics_cost.value = parseInt(statistics_cost.value, 10) + cost;
39            this.dispatchEvent(new CustomEvent('statchange', {
40                bubbles: true,
41                composed: true,
42                detail: "composed"
43            }));
44        }
45        connectedCallback() {
46            this.valueField.addEventListener('input', this.adjustValue);
47            if (!this.hasAttribute('value')) {
48                this.setAttribute('value', 10);
49            }
50        }
51        adjustValue() {
52            let newValue = +this.shadowRoot.querySelector('input').value,
53                oldValue = +this.getAttribute('value'),
54                costPer = this.getAttribute('costper');
55            if (newValue > 20) {
56                newValue = 20;
57                this.shadowRoot.querySelector('input').value = 20;
58            } else if (isNaN(newValue)) {
59                newValue = 0;
60            }
61            statistics_cost.value = parseInt(statistics_cost.value) + (newValue - 10) * costPer - (oldValue - 10) *
62                costPer;
63            document.getElementById("spent").value = (parseInt(document.getElementById("spent").value, 10) || 0) + (
64                newValue - 10) * costPer - (oldValue - 10) * costPer;
65            this.shadowRoot.querySelector('span.cost').innerHTML = (newValue - 10) * costPer;
66            this.shadowRoot.querySelectorAll('input')[1].value = newValue;
67            this.setAttribute('value', newValue);
68            this.dispatchEvent(new CustomEvent('statchange', {
69                bubbles: true,
70                composed: true,
71                detail: "composed"
72            }));
73        }
74    }
75    window.customElements.define('statistic-group', Statistic);

The attribute itself informs the code what the cost-per-dot is and any linked attributes; and the GURPS page builds the rest. I can also attach events (note the statchange event) that the outer javascript can listen for and use to update other values like the total-character-spend.


This was useful, but I remembered untested code is unreliable, so I pulled out my trusted Behat and Mink test PDF and started writing tests. And they immediately failed on checking the value of statistics because they're not elements it expects - the shadowRoot holds the actual things I want to check. So now I've had to write new code into the FeatureContext.php for Mink to add finding values for events that have shadowRoot. And since shadowRoot is not generic, I'm looking forward to writing a lot of custom test handlers and custom test statements and... well, sure. Because coding is learning and this is new. Huzzah!