🛠️ Calculator: Library
The calculator code contains two parts:
Code for the calculator itself
Code for testing the calculator
We want to keep tests separate from the actual component’s code. Users shouldn’t need to include our tests when they use the component.
So we’ll create two files:
calculator.js: For storing the calculator’s code
calculator.test.js: For testing the calculator
We will start by importing calculator.js into main.js.
<script type="module" src="js/main.js"></script>
import './calculator.js'
Creating the Calculator
When we test UI, we need to make sure the test uses the same HTML as the actual calculator. The easiest way to do this is to create the HTML with JavaScript.
We’ll use a function called createCalculator to create the HTML.
function createCalculator () {
// ...
}
Here’s the code that goes into createCalculator. You should be able to create this code by yourself at this point. (See your index.html file for the required HTML).
function createCalculator () {
const calculator = document.createElement('div')
calculator.classList.add('calculator')
calculator.tabIndex = 0
calculator.innerHTML = `
<div class="calculator__display">0</div>
<div class="calculator__keys">
<button tabindex="-1" data-key="plus" data-button-type="operator"> + </button>
<button tabindex="-1" data-key="minus" data-button-type="operator"> − </button>
<button tabindex="-1" data-key="times" data-button-type="operator"> × </button>
<button tabindex="-1" data-key="divide" data-button-type="operator"> Ă· </button>
<button tabindex="-1" data-key="1" data-button-type="number"> 1 </button>
<button tabindex="-1" data-key="2" data-button-type="number"> 2 </button>
<button tabindex="-1" data-key="3" data-button-type="number"> 3 </button>
<button tabindex="-1" data-key="4" data-button-type="number"> 4 </button>
<button tabindex="-1" data-key="5" data-button-type="number"> 5 </button>
<button tabindex="-1" data-key="6" data-button-type="number"> 6 </button>
<button tabindex="-1" data-key="7" data-button-type="number"> 7 </button>
<button tabindex="-1" data-key="8" data-button-type="number"> 8 </button>
<button tabindex="-1" data-key="9" data-button-type="number"> 9 </button>
<button tabindex="-1" data-key="0" data-button-type="number"> 0 </button>
<button tabindex="-1" data-key="decimal" data-button-type="decimal"> . </button>
<button tabindex="-1" data-key="clear" data-button-type="clear"> AC </button>
<button tabindex="-1" data-key="equal" data-button-type="equal"> = </button>
</div>
</div>
`
return calculator
}
We will create this HTML inside a Calculator Factory Function.
export default function Calculator () {
const calculatorElement = createCalculator()
// ...
}
We will export it so both users and tests can use this HTML:
function Calculator () {
const calculatorElement = createCalculator()
const calculator = {
element: calculatorElement
}
return calculator
}
We can put the calculator in the DOM like this:
// main.js
import Calculator from './calculator.js'
// Create calculator
const calculator = Calculator().element
// Add calculator to DOM
const container = document.querySelector('.container')
container.appendChild(calculator)
You should see the Calculator in the DOM at this point:
Making the calculator work
We need an event listener to make the calculator work. Here’s the one we wrote previously:
calculatorButtonsDiv.addEventListener('click', event => {
if (!event.target.closest('button')) return
const button = event.target
const { buttonType } = button.dataset
// Release operator pressed state
const operatorKeys = [...calculatorButtonsDiv.children]
.filter(button => button.dataset.buttonType === 'operator')
operatorKeys.forEach(button => button.classList.remove('is-pressed'))
if (buttonType !== 'clear') {
const clearButton = calculator.querySelector('[data-button-type=clear]')
clearButton.textContent = 'CE'
}
switch (buttonType) {
case 'clear': handleClearKey(calculator, button); break
case 'number': handleNumberKeys(calculator, button); break
case 'decimal': handleDecimalKey(calculator); break
case 'operator': handleOperatorKeys(calculator, button); break
case 'equal': handleEqualKey(calculator); break
}
calculator.dataset.previousButtonType = buttonType
})
We will create the same event listener inside Calculator.
function Calculator () {
const calculatorElement = createCalculator()
const calculatorButtonsDiv = calculatorElement.querySelector('.calculator__keys')
// ...
calculatorButtonsDiv.addEventListener('click', event => {/* ... */})
return calculator
}
We can create a method for this callback. We’ll call this method handleClick.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleClick(event) {/* ... */}
}
calculatorButtonsDiv.addEventListener('click', calculator.handleClick)
return calculator
}
We will copy the code we wrote into handleClick.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleClick(event) {
if (!event.target.closest('button')) return
const button = event.target
const { buttonType } = button.dataset
// Release operator pressed state
const operatorKeys = [...calculatorButtonsDiv.children]
.filter(button => button.dataset.buttonType === 'operator')
operatorKeys.forEach(button => button.classList.remove('is-pressed'))
if (buttonType !== 'clear') {
const clearButton = calculator.querySelector('[data-button-type=clear]')
clearButton.textContent = 'CE'
}
switch (buttonType) {
case 'clear': handleClearKey(calculator, button); break
case 'number': handleNumberKeys(calculator, button); break
case 'decimal': handleDecimalKey(calculator); break
case 'operator': handleOperatorKeys(calculator, button); break
case 'equal': handleEqualKey(calculator); break
}
calculator.dataset.previousButtonType = buttonType
}
}
// Add Event Listeners
// Return the calculator
}
Next, we need to change all instances of calculator in handleClick to calculatorElement. We do this because calculatorElement points to the HTML element.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleClick(event) {
if (!event.target.closest('button')) return
const button = event.target
const { buttonType } = button.dataset
// Release operator pressed state
const operatorKeys = [...calculatorButtonsDiv.children]
.filter(button => button.dataset.buttonType === 'operator')
operatorKeys.forEach(button => button.classList.remove('is-pressed'))
if (buttonType !== 'clear') {
const clearButton = calculatorElement.querySelector('[data-button-type=clear]')
clearButton.textContent = 'CE'
}
switch (buttonType) {
case 'clear': handleClearKey(calculatorElement, button); break
case 'number': handleNumberKeys(calculatorElement, button); break
case 'decimal': handleDecimalKey(calculatorElement); break
case 'operator': handleOperatorKeys(calculatorElement, button); break
case 'equal': handleEqualKey(calculatorElement); break
}
calculatorElement.dataset.previousButtonType = buttonType
}
}
// Add Event Listeners
// Return the calculator
}
We can declare operatorKeys and clearButton upfront in Calculator. When we do this, we simplify handleClick a bit.
function Calculator () {
// Declare Variables
// ...
const operatorKeys = [...calculatorButtonsDiv.children]
.filter(button => button.dataset.buttonType === 'operator')
const clearButton = calculatorElement.querySelector('[data-button-type=clear]')
const calculator = {
// ...
handleClick(event) {
if (!event.target.closest('button')) return
const button = event.target
const { buttonType } = button.dataset
operatorKeys.forEach(button => button.classList.remove('is-pressed'))
if (buttonType !== 'clear') {
clearButton.textContent = 'CE'
}
// ...
}
}
// Add Event Listeners
// Return the calculator
}
Next, we need to work on handling keys. We will handle number keys first, so comment out the rest.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleClick(event) {
// ...
switch (buttonType) {
// case 'clear': handleClearKey(calculatorElement, button); break
case 'number': handleNumberKeys(calculatorElement, button); break
// case 'decimal': handleDecimalKey(calculatorElement); break
// case 'operator': handleOperatorKeys(calculatorElement, button); break
// case 'equal': handleEqualKey(calculatorElement); break
}
// ...
}
}
// Add Event Listeners
// Return the calculator
}
Handling Number Keys
We used the handleNumberKeys function to handle number keys previously. Here’s what it looks like:
function handleNumberKeys (calculator, button) {
const displayValue = getDisplayValue()
const { previousButtonType } = calculator.dataset
const { key } = button.dataset
if (displayValue === '0') {
display.textContent = key
} else {
display.textContent = displayValue + key
}
if (previousButtonType === 'operator') {
display.textContent = key
}
if (previousButtonType === 'equal') {
resetCalculator()
display.textContent = key
}
}
We can copy this function into Calculator as a method.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleNumberKeys (calculatorElement, button) {
const displayValue = getDisplayValue()
const { previousButtonType } = calculator.dataset
const { key } = button.dataset
if (displayValue === '0') {
display.textContent = key
} else {
display.textContent = displayValue + key
}
if (previousButtonType === 'operator') {
display.textContent = key
}
if (previousButtonType === 'equal') {
resetCalculator()
display.textContent = key
}
},
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
Next, we need to change calculator to calculatorElement. You should know the reason why.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleNumberKeys (calculatorElement, button) {
const displayValue = getDisplayValue()
const { previousButtonType } = calculatorElement.dataset
const { key } = button.dataset
// ...
},
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We don’t need to pass calculatorElement into handleNumberKeys since it is already in the lexical scope. We can omit this parameter.
function Calculator () {
// Declare Variables
const calculator = {
// ...
handleNumberKeys (button) { /*...*/ },
handleClick () {
switch(buttonType) {
case 'number': calculator.handleNumberKeys(button)
}
}
}
// Add Event Listeners
// Return the calculator
}
Getting display value
handleNumberKeys needs a getDisplayValue function to work. Again, we can copy getDisplayValue from our old code into Calculator.
function Calculator () {
// Declare Variables
const calculator = {
// ...
getDisplayValue () {
return calculatorElement.querySelector('.calculator__display').textContent
},
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We can make getDisplayValue easier to read by declaring the display upfront in Calculator.
function Calculator () {
// Declare Variables
const display = calculatorElement.querySelector('.calculator__display')
const calculator = {
// ...
getDisplayValue () {
return display.textContent
},
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We can change getDisplayValue into a getter function to make it easier to use.
function Calculator () {
// Declare variables
const calculator = {
// ...
get displayValue () {
return display.textContent
},
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We’ll use displayValue like this:
function Calculator () {
// Declare variables
const calculator = {
// ...
handleNumberKeys (button) {
const displayValue = calculator.displayValue
// ...
}
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
Setting display value
In handleNumberKeys, we changed the display’s value by setting the textContent property:
function Calculator () {
// Declare variables
const calculator = {
// ...
handleNumberKeys (button) {
// ...
if (displayValue === '0') {
display.textContent = key
} else {
display.textContent = displayValue + key
}
if (previousButtonType === 'operator') {
display.textContent = key
}
if (previousButtonType === 'equal') {
resetCalculator()
display.textContent = key
}
}
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We can create a setter function to make this more intuitive:
function Calculator () {
// Declare variables
const calculator = {
// ...
set displayValue () {
display.textContent = value
},
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We’ll use the displayValue setter function like this:
function Calculator () {
// Declare variables
const calculator = {
// ...
handleNumberKeys (button) {
// ...
if (displayValue === '0') {
calculator.displayValue = key
} else {
calculator.displayValue = displayValue + key
}
if (previousButtonType === 'operator') {
calculator.displayValue = key
}
if (previousButtonType === 'equal') {
resetCalculator()
calculator.displayValue = key
}
}
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
Resetting the calculator
handleNumberKeys also need a resetCalculator function to work. We’ll create a method for this too.
function Calculator () {
// Declare variables
const calculator = {
// ...
resetCalculator() {
// ...
},
handleNumberKeys (button) {
// ...
if (previousButtonType === 'equal') {
calculator.resetCalculator()
calculator.displayValue = key
}
}
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
Here’s what we wrote for resetCalculator previously:
const resetCalculator = _ => {
pressKeys('clear', 'clear')
console.assert(getDisplayValue() === '0', 'Clear calculator')
console.assert(!calculator.dataset.firstValue, 'No first value')
console.assert(!calculator.dataset.operator, 'No operator value')
console.assert(!calculator.dataset.modifierValue, 'No operator value')
}
The four console.assert lines are for testing the calculator. We don’t want to have test code inside the actual Calculator, so we’ll only copy the first line.
function Calculator () {
// Declare variables
const calculator = {
// ...
resetCalculator() {
pressKeys('clear', 'clear')
}
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
We know resetCalculator needs pressKeys to work. We’ll copy pressKeys into Calculator.
function Calculator () {
// Declare variables
const calculator = {
// ...
pressKeys (keys) {
keys.forEach(pressKey)
},
resetCalculator() {
calculator.pressKeys('clear', 'clear')
}
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
From here, we know pressKeys needs a pressKey function to work. We’ll copy it in as well.
function Calculator () {
// Declare variables
const calculator = {
// ...
pressKey (key) {
document.querySelector(`[data-key="${key}"]`).click()
},
pressKeys (keys) {
keys.forEach(calculator.pressKey)
},
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
Let’s make the querySelector statement more precise by searching within calculatorElement instead of document.
function Calculator () {
// Declare variables
const calculator = {
// ...
pressKey (key) {
calculatorElement.querySelector(`[data-key="${key}"]`).click()
},
// Other methods
// Listener Callbacks
}
// Add Event Listeners
// Return the calculator
}
The calculator should now work with number keys now.
Testing Number keys
We need to make sure the test uses the same HTML as the actual calculator. We made this possible by creating and exporting the HTML in Calculator. What’s next is to create another Calculator instance inside calculator.test.js.
// calculator.test.js
import Calculator from './calculator.js'
const calculator = Calculator()
Normally, we run tests with a test runner. But setting up a test running is out of scope of this course. To make things simple, we will run the test file by importing it into main.js.
// main.js
import './calculator.test.js`
Next, we want to test a Number key. Here, we want to:
Press a number key
Check the display
Make sure the display value is what we expect
We already have a runTest function that lets us do this. Here’s the code we wrote previously:
function runTest (test) {
pressKeys(...test.keys)
console.assert(getDisplayValue() === test.result, test.message)
resetCalculator()
}
runTest needs two functions — pressKeys and resetCalculator — to work. These functions are already built into the Calculator as methods.
function runTest (test) {
calculator.pressKeys(...test.keys)
console.assert(getDisplayValue() === test.result, test.message)
calculator.resetCalculator()
}
runTest also needs getDisplayValue to work. We exposed this display value via a getter function in Calculator. We can use this getter function in runTest:
function runTest (test) {
calculator.pressKeys(...test.keys)
console.assert(calculator.displayValue === test.result, test.message)
calculator.resetCalculator()
}
We can then copy our Number key test cases into calculator.test.js.
const tests = [
// Test Number Keys
{
message: 'Number key',
keys: ['2'],
result: '2'
}
]
And we’ll run the tests like this:
tests.forEach(runTest)
This should work. There aren’t any errors. But you’ll get an error if you add the second test into the mix:
const tests = [
// Test Number Keys
{
message: 'Number key',
keys: ['2'],
result: '2'
}, {
message: 'Number Number',
keys: ['3', '5'],
result: '35'
}
]
But why?
If you console.log the display value, you’ll see 235 instead of 35. The 2 comes from the previous test, which means resetCalculator isn’t working.
function runTest (test) {
calculator.pressKeys(...test.keys)
console.log(calculator.displayValue)
console.assert(calculator.displayValue === test.result, test.message)
calculator.resetCalculator()
}
resetCalculator doesn’t work for a good reason… It uses the clear key, but we haven’t implemented the clear key for this refactored version yet!
We’ll implement the clear key in the next lesson.