7GUIs
7GUIs is a programming benchmark to compare implementations in terms of their notation.
Essential:
github.com/getify/You-Dont-Know-JS
Description:
7GUIs, also known as 7 Graphical User Interfaces, stand as a diligent comparison between diverse approaches to GUI development. This specific benchmark defines seven tasks that represent typical challenges in GUI programming; in addition, it provides a recommended set of evaluation dimensions, as identifying and propagating better approaches to GUI programming (ultimately pushing programming further) is not an easy task.
In this case, we will use vanilla HTML, CSS, and JavaScript for each task - and try our best to survive all of them.
Table of Contents
1. Counter
Challenge
Understanding the basic ideas of a language (or toolkit).
Criteria
▪ Build a frame containing a label or read-only textfield and a button.
▪ Initially, the value in the textfield is 0.
▪ Each click of the button increases the value in textfield by 1.
Counter serves as a gentle introduction to the basics of the language, paradigm, and toolkit for one of the simplest GUI applications imaginable. Thus, Counter reveals the required scaffolding and how the very basic features work together to build a GUI application. A good solution will have almost no scaffolding:
Demo:
Although this example has been adjusted to this site’s needs, here’s how we could achieve a similar result with plain JavaScript.
First, let’s introduce the HTML section and its corresponding styles for a simple counter:
Example:
index.html
----------
// ...
<section id="counter">
<h1>Counter</h1>
<div id="body">
<output>0</output>
<button type="button">COUNT</button>
</div>
</section>
//...
styles.css
----------
#counter {
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
--text-color: hsl(0, 0%, 13%);
}
section#counter {
display: flex;
flex-direction: column;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
}
#counter h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
grid-area: top;
text-align: center;
}
#counter div#body {
display: flex;
gap: 16px;
padding: 16px;
}
#counter button {
background: var(--text-color);
border: none;
color: var(--background-color);
cursor: pointer;
font-size: 14px;
letter-spacing: 0.05em;
padding: 12px 24px;
}
#counter output {
border: 1px solid black;
font-weight: 700;
padding: 8px 16px;
}
Now, let’s focus on the JavaScript part, which we’ll keep as simple as the rest:
Example:
index.js
--------
(function () {
const button = document.querySelector("button");
const output = document.querySelector("output");
let count = 0;
button.onclick = function () {
count++;
output.textContent = count;
};
})();
/*
Keep in mind that this is just one of the ways to
achieve the expected result. It is not perfect, yet
follows the basics of the language, as well as the
ideas of GUI programming.
Your applications do not have to be obscure nor
complicated. For now, what matters is that the
desired results are accomplished.
*/
2. Temperature Converter
Challenge
Bidirectional data flow, user-provided text input.
Criteria:
The task is to build a frame containing 2 textfields representing the temperature in Celsius and Fahrenheit.
▪ Initially, both textfields are empty.
▪ When the user enters a numerical value into a textfield, the corresponding value in the other is automatically updated.
▪ When the user enters a non-numerical string into a textfield, the value in the other is not updated.
▪ Celsius to Fahrenheit formula F = C * (9/5) + 32.
▪ Fahrenheit to Celsius formula: C = (F - 32) * (5/9).
Temperature Converter increases the complexity of Counter by having bidirectional data flow between the Celsius and Fahrenheit inputs and the need to check the user input for validity. A good solution will make the bidirectional dependency very clear with minimal boilerplate code.
Demo:
Let’s see what happens now when it comes to the HTML and CSS part:
Example:
index.html
----------
// ...
<form id="tempConverter">
<h1>Temperature Converter</h1>
<div class="row">
<input id="celsius" oninput="onCelsiusInput(event)" step=".01" type="number" autofocus />
<label for="celsius">Celsius</label> =
<input id="fahrenheit" oninput="onFahrenheitInput(event)" step=".01" type="number" />
<label for="fahrenheit">Fahrenheit</label>
</div>
</form>
//...
styles.css
----------
#tempConverter {
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
--text-color: hsl(0, 0%, 13%);
}
form#tempConverter {
display: flex;
flex-direction: column;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
}
#tempConverter h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
text-align: center;
}
#tempConverter div.row {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
#tempConverter label {
cursor: pointer;
}
#tempConverter input {
width: 100%;
background: transparent;
border: 1px solid var(--text-color);
padding: 8px 16px;
}
And the JavaScript logic behind it:
Example:
index.js
--------
const CEL_ID = "celsius";
const celInput = document.querySelector(`#${CEL_ID}`);
const farInput = document.querySelector("#fahrenheit");
function celToFar(cel) {
return (cel * 9) / 5 + 32;
}
function farToCel(far) {
return ((far - 32) * 5) / 9;
}
function formatVal(value) {
return value.toFixed(2);
}
function changeInputVal(elem, value) {
elem.value = formatVal(value);
}
function clearInput(elem) {
elem.value = "";
}
function onInput(event) {
const { value, id } = event.target;
const otherInput = id === CEL_ID ? farInput : celInput;
if (value === "") {
clearInput(otherInput);
return;
}
{
const otherValue = id === CEL_ID ? celToFar(value) : farToCel(value);
changeInputVal(otherInput, otherValue);
}
}
celInput.oninput = onInput;
farInput.oninput = onInput;
Now, coffee break before we keep going!
3. Flight Booker
Challenge
The simplification of using textfields for date input instead of specialized date picking widgets.
Criteria
The task is to build a frame containing a combobox with two options (“one-way flight” and “return flight”), two textfields representing the start and return date, and a button for submitting the selected flight.
▪ The return textfield is enabled if the combobox’s value is “return flight”.
▪ When the “return flight” date is strictly before time or wrong-formatted, it’s not possible to submit.
▪ When submitting, a message is displayed informing the user of their selection.
▪ Initially, the combobox has the value “one-way flight”, and both textfields have the same (arbitrary) date. It is implied that the return textfield is disabled.
The focus of Flight Booker lies on modelling constraints between widgets on the one hand, and modelling constraints within a widget on the other hand. Such constraints are very common in everyday interactions with GUI applications, and a good solution for Flight Booker should make the constraints clear, succinct, and explicit in the source code, not hidden behind a lot of scaffolding.
Demo:
Now, let’s delve into the HTML section and its corresponding styles:
Example:
index.html
----------
// ...
<form id="flightBooker">
<h1>Flight Booker</h1>
<div id="body">
<label for="oneWayOrReturn">One way or return:</label>
<select id="oneWayOrReturn">
<option value="oneWay">one-way flight</option>
<option value="returnFlight">return flight</option>
</select>
<label for="departure">Departure Date (format: DD.MM.YYYY):</label>
<input id="departure" value="25.12.1999" />
<label for="return">Return Date (format: DD.MM.YYYY):</label>
<input disabled id="return" value="25.12.1999" />
<button id="book" type="submit">BOOK</button>
</div>
</form>
//...
styles.css
----------
#flightBooker {
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--text-color: hsl(0, 0%, 13%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
}
form#flightBooker {
display: flex;
flex-direction: column;
width: 100%;
max-width: 340px;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
}
#flightBooker h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
text-align: center;
}
#flightBooker div#body {
display: flex;
flex-direction: column;
width: 100%;
padding: 16px;
}
#flightBooker label {
font-size: 14px;
}
#flightBooker input,
#flightBooker select {
margin: 4px 0 24px 0;
background: transparent;
border: 1px solid var(--text-color);
padding: 8px 16px;
}
#flightBooker input[aria-invalid="true"] {
background: tomato;
}
#flightBooker select {
cursor: pointer;
}
#flightBooker button {
background: var(--text-color);
border: none;
color: var(--background-color);
font-size: 14px;
letter-spacing: 0.05em;
padding: 12px 24px;
cursor: pointer;
}
#flightBooker input:disabled,
#flightBooker button:disabled {
opacity: 0.3;
pointer-events: none;
}
Now, as for the JavaScript part, let’s keep it simple:
Example:
index.js
--------
const ARIA_INVALID = "aria-invalid";
const oneWayOrReturnValues = {
oneWay: "oneWay",
returnFlight: "returnFlight",
};
const elems = {
departure: document.querySelector("#departure"),
oneWayOrReturn: document.querySelector("#oneWayOrReturn"),
return: document.querySelector("#return"),
book: document.querySelector("#book"),
};
// DOM setters.
function setError(elem) {
elem.setAttribute(ARIA_INVALID, true);
}
function clearError(elem) {
elem.setAttribute(ARIA_INVALID, false);
}
function disable(elem) {
elem.disabled = true;
}
function enable(elem) {
elem.disabled = false;
}
function showConfirmation() {
const message =
elems.oneWayOrReturn.value === oneWayOrReturnValues.oneWay
? `You have booked a one-way flight on ${elems.departure.value}.`
: `You have booked a return flight, departing on ${elems.departure.value} & returning on ${elems.return.value}.`;
window.alert(message);
}
// Validators.
function isOneWay() {
return elems.oneWayOrReturn.value === oneWayOrReturnValues.oneWay;
}
function isValidDate(date) {
return date instanceof Date && !isNaN(date);
}
function inputValueToDate(value) {
const split = value.split(".");
const [day, month, year] = split;
return new Date(`${year}-${month}-${day}`);
}
function isBadFormat(value) {
const split = value.split(".");
if (split.length !== 3) return true;
const date = inputValueToDate(value);
return !isValidDate(date);
}
function isEarlyReturn() {
const start = inputValueToDate(elems.departure.value).valueOf();
const end = inputValueToDate(elems.return.value).valueOf();
return end < start;
}
// State.
const states = {
oneWay: {
badDepartureFormat: "oneWay.BadDepartureFormat",
valid: "oneWay.valid",
},
returnFlight: {
badDepartureFormat: "returnFlight.badDepartureFormat",
badReturnFormat: "returnFlight.badReturnFormat",
badDepartureAndReturnFormat: "returnFlight.badDepartureAndReturnFormat",
earlyReturn: "returnFlight.earlyReturn",
valid: "returnFlight.valid",
},
};
function onStateChange(state) {
switch (state) {
case states.oneWay.badDepartureFormat:
disable(elems.book);
disable(elems.return);
setError(elems.departure);
clearError(elems.return);
break;
case states.oneWay.valid:
enable(elems.book);
disable(elems.return);
clearError(elems.departure);
clearError(elems.return);
break;
case states.returnFlight.badDepartureAndReturnFormat:
disable(elems.book);
enable(elems.return);
setError(elems.departure);
setError(elems.return);
break;
case states.returnFlight.badDepartureFormat:
disable(elems.book);
enable(elems.return);
setError(elems.departure);
clearError(elems.return);
break;
case states.returnFlight.badReturnFormat:
disable(elems.book);
enable(elems.return);
clearError(elems.departure);
setError(elems.return);
break;
case states.returnFlight.earlyReturn:
disable(elems.book);
enable(elems.return);
clearError(elems.departure);
setError(elems.return);
break;
case states.returnFlight.valid:
enable(elems.book);
enable(elems.return);
clearError(elems.departure);
clearError(elems.return);
break;
default:
throw Error(`Unknown state: ${state}`);
}
}
function calcState() {
const isBadDepart = isBadFormat(elems.departure.value);
const isBadReturn = isBadFormat(elems.return.value);
if (isOneWay()) {
return isBadDepart ? states.oneWay.badDepartureFormat : states.oneWay.valid;
}
if (isBadDepart && isBadReturn) {
return states.returnFlight.badDepartureAndReturnFormat;
}
if (isBadDepart) {
return states.returnFlight.badDepartureFormat;
}
if (isBadReturn) {
return states.returnFlight.badReturnFormat;
}
if (isEarlyReturn()) {
return states.returnFlight.earlyReturn;
}
return states.returnFlight.valid;
}
function onInputChange() {
const state = calcState();
onStateChange(state);
}
// DOM listeners.
elems.oneWayOrReturn.onchange = onInputChange;
elems.departure.oninput = onInputChange;
elems.return.oninput = onInputChange;
elems.book.onclick = function (event) {
event.preventDefault();
showConfirmation();
};
4. Timer
Challenge
Concurrency, competing user/signal interactions, responsiveness.
Criteria
In this case, we have to build a frame containing a gauge for an elapsed time. It will incorporate:
▪ A label which shows the elapsed time as a numerical value.
▪ A slider by which the duration of the timer can be adjusted while the timer is running.
▪ A reset button.
When it comes to certain functionalities, we can’t forget that:
▪ Adjusting the slider must immediately reflect on the duration and gauge.
▪ When the elapsed time ≥ duration, the timer stops (full gauge).
▪ If the duration is increased (meaning that duration > elapsed time), the timer starts to tick until elapsed time ≥ duration again.
▪ Clicking the reset button will reset the elapsed time to zero.
Timer deals with concurrency in the sense that a timer process that updates the elapsed time runs concurrently to the user’s interactions with the GUI application. Also, the fact that the slider adjustments must be reflected immediately tests the responsiveness of the solution, forcing us to make it clear that the signal is a timer tick.
Demo:
As always, let’s try it with not much scaffolding involved. HTML and CSS time:
Example:
index.html
----------
// ...
<section id="timer">
<h1>Timer</h1>
<div id="body">
<section id="gauge">
Elapsed Time:
<progress value="0" max="100">0%</progress>
</section>
<output id="durationOutput">0s</output>
<form>
<div class="duration">
<label for="durationInput">Duration:</label>
<input id="durationInput" max="100" min="0" step="1" type="range" value="0" />
</div>
<button type="button">RESET</button>
</form>
</div>
</section>
//...
styles.css
----------
#timer {
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
--text-color: hsl(0, 0%, 13%);
}
section#timer {
display: flex;
flex-direction: column;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
}
#timer h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
text-align: center;
}
#timer div#body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
#timer section#gauge {
display: flex;
align-items: center;
gap: 8px;
}
#timer div.duration {
display: flex;
}
#timer form {
display: flex;
flex-direction: column;
gap: 16px;
}
#timer input {
flex-grow: 1;
margin-left: 16px;
cursor: pointer;
}
#timer button {
background: var(--text-color);
color: var(--background-color);
border: none;
font-size: 14px;
letter-spacing: 0.05em;
padding: 12px 24px;
cursor: pointer;
}
Now, let’s check the JavaScript implications:
Example:
index.js
--------
let intervalID = null;
const STEP_MS = 100;
const elems = {
durationInput: document.querySelector("#durationInput"),
durationOutput: document.querySelector("#durationOutput"),
progress: document.querySelector("progress"),
reset: document.querySelector("button"),
};
// Displaying values.
function setDurationDisplay() {
const { elapsedTimeMS } = values;
const currentValue = elems.durationOutput.textContent;
const newValue = `${elapsedTimeMS / 1000}s`;
if (currentValue !== newValue) {
elems.durationOutput.textContent = newValue;
}
}
function setProgressDisplay() {
const { durationMS, elapsedTimeMS } = values;
const currentValue = elems.progress.value;
const newValue = durationMS === 0 ? 0 : Math.min(elapsedTimeMS / durationMS, 1) * 100;
if (currentValue !== newValue) {
elems.progress.value = newValue;
elems.progress.textContent = `${newValue}%`;
}
}
function setDisplayValues() {
setDurationDisplay();
setProgressDisplay();
}
// Values.
function onValuesChange() {
const { durationMS, elapsedTimeMS } = values;
setDisplayValues();
if (elapsedTimeMS >= durationMS) {
stopTimer();
return;
}
startTimer();
}
const values = {
set durationMS(value) {
this._durationMS_ = value;
onValuesChange();
},
set elapseTimeMS(value) {
this._elapsedTimeMS_ = value;
onValuesChange();
},
get durationMS() {
return this._durationMS_ || 0;
},
get elapsedTimeMS() {
return this._elapsedTimeMS_ || 0;
},
};
// Timer.
function stopTimer() {
if (intervalID) {
clearInterval(intervalID);
intervalID = null;
}
}
function startTimer() {
if (!intervalID) {
intervalID = setInterval(() => {
const { elapsedTimeMS } = values;
values.elapseTimeMS = elapsedTimeMS + STEP_MS;
}, STEP_MS);
}
}
// DOM listeners.
elems.durationInput.oninput = function (event) {
const { value } = event.target;
values.durationMS = value * 1000;
};
elems.reset.onclick = function () {
values.elapseTimeMS = 0;
};
5. CRUD
Challenge
Separating the domain and presentation logic, managing mutation, building a non-trivial layout.
Criteria
In this case, we’ll build a frame containing prefix, name, and surname textfields (with labels), a list box, and buttons allowing to create, update, and delete the data.
List box:
▪ The list box will present a view of the data in the database that consists of a list of names.
▪ At most one entry can be selected in the box at a time.
Textfield:
▪ By entering a string into the prefix textfield, the user can filter the names whose surname start with the entered prefix.
Buttons:
▪ Clicking the create button will append the resulting name from concatenating the strings in the name and surname textfields to the list box.
▪ The update and delete buttons are enabled if an entry in the list box is selected.
▪ In contrast to the create button, the update button will not append the resulting name but instead replace the selected entry with the new name.
▪ The delete button will remove the selected entry.
CRUD (Create, Read, Update, and Delete) represents a typical graphical business application. The primary challenge is the separation of domain and presentation logic in the source code that is more or less forced on the implementer due to the ability to filter the view by a prefix.
What matters the most here is that the approach to managing the mutation of the list of names is tested, and a good solution should have a clear separation between the domain and presentation logic without much overhead, a mutation management that is fast but not error-prone, and a natural representation of the layout.
Demo:
Let’s see how to achieve it by starting with the HTML and its according styles:
Example:
index.html
----------
// ...
<form id="crud">
<h1>CRUD</h1>
<div id="body">
<div id="filterLayout">
<label for="filter">Filter prefix:</label>
<input id="filter" />
</div>
<ol id="listBox"></ol>
<div id="names">
<label for="name">Name:</label>
<input id="name" />
<label for="surname">Surname:</label>
<input id="surname" />
</div>
<div id="buttons">
<button id="create" type="button">CREATE</button>
<button id="update" type="button" disabled>UPDATE</button>
<button id="delete" type="button" disabled>DELETE</button>
</div>
</div>
</form>
//...
styles.css
----------
#crud {
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
--text-color: hsl(0, 0%, 13%);
}
form#crud {
display: flex;
flex-direction: column;
width: 100%;
max-width: 640px;
height: 340px;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
}
#crud h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
text-align: center;
}
#crud div#body {
display: grid;
grid:
"filter ." auto
"listbox names" 1fr
"buttons buttons" auto
/ 1fr 1fr;
grid-gap: 16px;
flex-grow: 1;
padding: 16px;
}
#crud div#filterLayout {
display: flex;
align-items: center;
grid-area: filter;
justify-content: space-between;
}
#crud ol#listBox {
grid-area: listbox;
border: 1px solid var(--text-color);
list-style: none;
overflow: auto;
}
#crud ol#listBox > li {
overflow: hidden;
padding: 8px 16px;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
#crud ol#listBox > li.selected {
background: var(--text-color);
color: var(--background-color);
pointer-events: none;
}
#crud ol#listBox > li.hide {
display: none;
}
#crud div#names {
grid-area: names;
display: grid;
grid:
". ." auto
". ." auto
/ auto auto;
grid-gap: 8px;
align-content: start;
}
#crud div#buttons {
grid-area: buttons;
}
#crud input {
width: 100%;
max-width: 160px;
margin-left: 8px;
background: transparent;
border: 1px solid var(--text-color);
padding: 8px 16px;
}
#crud label {
white-space: nowrap;
}
#crud button {
background: var(--text-color);
border: none;
color: var(--background-color);
font-family: inherit;
font-size: 14px;
letter-spacing: 0.05em;
padding: 12px 24px;
cursor: pointer;
}
#crud button:disabled {
opacity: 0.3;
pointer-events: none;
}
Now, the JavaScript part could look somehow like this:
Example:
index.js
--------
(function () {
let selected = null;
const classes = {
hide: "hide",
selected: "selected",
};
const names = {};
const elems = {
create: document.querySelector("button#create"),
delete: document.querySelector("button#delete"),
filter: document.querySelector("input#filter"),
listBox: document.querySelector("ol#listBox"),
name: document.querySelector("input#name"),
surname: document.querySelector("input#surname"),
update: document.querySelector("button#update"),
};
// Validators.
function isValidInput(value) {
return typeof value === "string" && value.length > 0;
}
function isValidNameInputValues() {
const name = elems.name.value;
const surname = elems.surname.value;
return isValidInput(name) && isValidInput(surname);
}
function isValidFilterInputValue() {
const { value } = elems.filter;
return isValidInput(value);
}
function isNameAvailable(name) {
return !names[name];
}
// Formatting.
function capitalize(string) {
return string[0].toUpperCase() + string.slice(1);
}
function formatNameInputValues() {
const name = elems.name.value;
const surname = elems.surname.value;
return `${capitalize(surname)}, ${capitalize(name)}`;
}
function getFilterRegex() {
const { value } = elems.filter;
const pattern = `^${value.toLowerCase()}`;
return new RegExp(pattern);
}
function clearInputs() {
elems.filter.value = "";
elems.name.value = "";
elems.surname.value = "";
}
function addName(name) {
const li = document.createElement("li");
const content = document.createTextNode(name);
li.append(content);
li.onclick = onNameClick;
names[name] = {
value: name,
elem: li,
};
renderNames();
}
function removeName() {
const { elem } = names[selected];
elem.onclick = null;
elems.listBox.removeChild(elem);
delete names[selected];
clearSelect();
}
function derenderNames() {
const items = elems.listBox.children;
for (let i = 0; i < items; i++) {
elems.listBox.removeChild(items[i]);
}
}
function renderNames() {
derenderNames();
Object.keys(names)
.sort()
.forEach((name) => {
elems.listBox.append(names[name].elem);
});
}
// Filter.
function clearFilterInput() {
elems.filter.value = "";
}
function clearFilteredNames() {
const items = document.querySelectorAll("ol#listBox > .hide");
for (let i = 0; i < items.length; i++) {
items[i].classList.remove("hide");
}
elems.filter.value = "";
}
function clearFilter() {
clearFilterInput();
clearFilteredNames();
}
// Select.
function select(name) {
clearSelect();
selected = name;
names[name].elem.classList.add(classes.selected);
elems.delete.disabled = false;
elems.update.disabled = false;
}
function clearSelect() {
if (!selected) {
return;
}
names[selected]?.elem.classList.remove(classes.selected);
elems.delete.disabled = true;
elems.update.disabled = true;
}
// DOM listeners.
function onNameClick(event) {
const name = event.target.textContent;
select(name);
}
function onCreateButtonClick() {
if (isValidNameInputValues()) {
const formatted = formatNameInputValues();
if (isNameAvailable(formatted)) {
addName(formatted);
clearInputs();
clearFilter();
}
}
}
function onDeleteClick() {
removeName();
}
function onUpdateClick() {
if (isValidNameInputValues()) {
const formatted = formatNameInputValues();
if (isNameAvailable(formatted)) {
removeName();
addName(formatted);
clearInputs();
clearFilter();
}
}
}
function onFilterInput() {
if (isValidFilterInputValue()) {
const items = elems.listBox.children;
const filter = getFilterRegex();
clearSelect();
for (let i = 0; i < items.length; i++) {
const elem = items[i];
const name = elem.textContent;
if (filter.test(name.toLowerCase())) {
elem.classList.remove(classes.hide);
} else {
elem.classList.add(classes.hide);
}
}
return;
}
clearFilteredNames();
}
function addEventListeners() {
elems.create.onclick = onCreateButtonClick;
elems.update.onclick = onUpdateClick;
elems.delete.onclick = onDeleteClick;
elems.filter.oninput = onFilterInput;
}
addEventListeners();
})();
6. Circle Drawer
Challenge
Understanding the basic ideas of a language/toolkit.
Criteria
With Circle Draw, we will have to build a frame containing an undo and redo button, as well as a canvas area.
Let’s take into account, however, the following requirements:
▪ Left-clicking inside an empty area inside the canvas will create a circle with a fixed diameter.
▪ Right-clicking a selected circle will make a popup “Adjust diameter” menu appear.
▪ Clicking on “Adjust diameter” will open another frame with a slider that adjusts the diameter of the selected circle.
▪ Closing this frame will mark the last diameter as significant for the undo/redo history.
▪ Clicking undo will remove the last significant change.
▪ Clicking redo will reapply the last undoed change.
Circle Drawer’s goal is, among other things, to test how good the common challenge of implementing an undo/redo functionality for a GUI application can be solved. Moreover, Circle Drawer tests how dialog control (the challenge of retaining context between successive GUI operations) is achieved in the source code.
Demo:
If it sounds simple enough, let’s get started with some HTML and CSS:
Example:
index.html
----------
// ...
<section id="circleDrawer">
<h1>Circle Drawer</h1>
<div id="body">
<button id="undo" disabled>UNDO</button>
<button id="redo" disabled>REDO</button>
<svg></svg>
<section class="hide" id="menu">
<button class="close" id="closeMenu" type="button">x</button>
<button id="openAdjustDiameter">ADJUST DIAMETER</button>
</section>
<form class="hide" id="adjustDiameter">
<button class="close" id="closeForm" type="button">x</button>
<div id="adjustDiameterBody">
<label for="diameter"></label>
<input id="diameter" type="range" step="1" min="8" max="100" />
</div>
</form>
</div>
</section>
//...
styles.css
----------
#circleDrawer {
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
--text-color: hsl(0, 0%, 13%);
}
section#circleDrawer {
position: relative;
display: flex;
flex-direction: column;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
}
#circleDrawer h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
text-align: center;
}
#circleDrawer div#body {
display: grid;
grid:
"undo redo" auto
"svg svg" auto
/ auto auto;
grid-gap: 16px;
padding: 16px;
}
#circleDrawer button#undo {
grid-area: undo;
justify-self: end;
}
#circleDrawer button#redo {
grid-area: redo;
justify-self: start;
}
#circleDrawer svg {
width: 360px;
height: 240px;
grid-area: svg;
border: 1px solid var(--text-color);
}
#circleDrawer svg.disabled {
pointer-events: none;
}
#circleDrawer circle:hover,
#circleDrawer circle.selected {
fill: gray;
}
#circleDrawer section#menu {
display: flex;
flex-direction: column;
position: absolute;
bottom: 32px;
left: 50%;
border: 1px solid var(--text-color);
border-radius: 4px;
box-shadow: var(--shadow);
transform: translateX(-50%);
}
#circleDrawer form#adjustDiameter {
position: absolute;
display: flex;
flex-direction: column;
width: calc(100% - 16px);
bottom: 32px;
left: 50%;
background: var(--background-color);
border: 1px solid var(--text-color);
border-radius: 4px;
box-shadow: var(--shadow);
font-size: 14px;
transform: translateX(-50%);
}
#circleDrawer div#adjustDiameterBody {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 16px;
}
#circleDrawer button.close {
border-bottom: 1px solid var(--background-color);
font-size: 16px;
padding: 0;
padding-left: 8px;
text-align: left;
}
#circleDrawer button {
background: var(--text-color);
border: none;
color: var(--background-color);
font-size: 14px;
letter-spacing: 0.05em;
padding: 12px 24px;
cursor: pointer;
}
#circleDrawer button:disabled {
opacity: 0.3;
pointer-events: none;
}
#circleDrawer section#menu.hide,
#circleDrawer form#adjustDiameter.hide {
display: none;
}
Now, the logic behind it:
Example:
index.js
--------
(function () {
const DEFAULT_DIAMETER = 24;
const SVG_NAME_SPACE = "http://www.w3.org/2000/svg";
const classes = {
disabled: "disabled",
selected: "selected",
};
const changes = [];
const changeTypes = {
circleAdded: "circleAdded",
diameterChange: "diameterChange",
};
const elems = {
form: document.querySelector("form#adjustDiameter"),
input: document.querySelector("input#diameter"),
label: document.querySelector("label[for='diameter']"),
menu: document.querySelector("section#menu"),
redo: document.querySelector("button#redo"),
svg: document.querySelector("svg"),
undo: document.querySelector("button#undo"),
closeForm: document.querySelector("button#closeForm"),
closeMenu: document.querySelector("button#closeMenu"),
openAdjustDiameter: document.querySelector("button#openAdjustDiameter"),
};
let selected = null;
let changesUndone = [];
function hide(elem) {
elem.classList.add("hide");
}
function show(elem) {
elem.classList.remove("hide");
}
function enable(elem) {
elem.disabled = false;
}
function disable(elem) {
elem.disabled = true;
}
function select(circle) {
circle.classList.add(classes.selected);
selected = {
circle,
diameter: circle.getAttribute("r") * 2,
};
}
function deselect() {
selected.circle.classList.remove(classes.selected);
selected = null;
}
function loadForm() {
const x = selected.circle.getAttribute("cx");
const y = selected.circle.getAttribute("cy");
elems.input.value = selected.diameter;
elems.label.textContent = `Adjust diameter of circle at (${x}, ${y}).`;
show(elems.form);
}
function disableSvg() {
elems.svg.classList.add(classes.disabled);
}
function enableSvg() {
elems.svg.classList.remove(classes.disabled);
}
// Undo / Redo.
function undoDiameterChange(change) {
const { oldDiameter } = change;
change.circle.setAttribute("r", oldDiameter / 2);
}
function redoDiameterChange(change) {
const { newDiameter } = change;
change.circle.setAttribute("r", newDiameter / 2);
}
function resetChangesUndone() {
changesUndone = [];
disable(elems.redo);
}
// Logging.
function logChange(change) {
changes.push(change);
enable(elems.undo);
}
function logCircleAdd(circle) {
logChange({
type: changeTypes.circleAdded,
circle,
});
}
function logDiameterChange() {
const newDiameter = selected.circle.getAttribute("r") * 2;
const oldDiameter = selected.diameter;
if (newDiameter !== oldDiameter) {
logChange({
type: changeTypes.diameterChange,
circle: selected.circle,
oldDiameter,
newDiameter,
});
resetChangesUndone();
}
}
// Circle.
function mouseToSvgCoords(event) {
const invertedSVGMatrix = elems.svg.getScreenCTM().inverse();
const point = elems.svg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
return point.matrixTransform(invertedSVGMatrix);
}
function removeCircle(circle) {
circle.onclick = null;
circle.contextmenu = null;
elems.svg.removeChild(circle);
}
function addCircle(circle) {
circle.onclick = onCircleClick;
circle.oncontextmenu = onCircleRightClick;
elems.svg.append(circle);
}
function createCircle(coords) {
const { x, y } = coords;
const circle = document.createElementNS(SVG_NAME_SPACE, "circle");
circle.setAttribute("r", DEFAULT_DIAMETER);
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("fill", "transparent");
circle.setAttribute("stroke", "black");
return circle;
}
// State.
const states = {
default: "default",
menuOpen: "menuOpen",
formOpen: "formOpen",
};
const state = {
set change(value) {
this.value = value;
onStateChange(value);
},
value: states.default,
};
function onStateChange(state) {
switch (state) {
case states.default:
if (changes.length > 0) {
enable(elems.undo);
}
if (changesUndone.length > 0) {
enable(elems.redo);
}
enableSvg();
deselect();
hide(elems.menu);
hide(elems.form);
break;
case states.menuOpen:
show(elems.menu);
disable(elems.undo);
disable(elems.redo);
disableSvg();
break;
case states.formOpen:
break;
default:
throw Error(`Unknown state: ${state}`);
}
}
// DOM listeners.
function onSvgClick(event) {
const coords = mouseToSvgCoords(event);
const circle = createCircle(coords);
addCircle(circle);
logCircleAdd(circle);
resetChangesUndone();
}
function onCircleClick(event) {
event.stopPropagation();
}
function onCircleRightClick(event) {
event.preventDefault();
select(event.target);
state.change = states.menuOpen;
}
function onAdjustDiameterClick() {
loadForm();
hide(elems.menu);
state.change = states.formOpen;
}
function onMenuCloseClick() {
state.change = states.default;
}
function onDiameterInput(event) {
const { value } = event.target;
selected.circle.setAttribute("r", value / 2);
}
function onFormCloseClick() {
logDiameterChange();
state.change = states.default;
}
function onUndoClick() {
if (changes.length > 0) {
const change = changes.pop();
changesUndone.push(change);
switch (change.type) {
case changeTypes.circleAdded:
removeCircle(change.circle);
break;
case changeTypes.diameterChange:
undoDiameterChange(change);
break;
default:
throw Error(`Unknown change type: ${change.type}.`);
}
}
if (changes.length === 0) {
disable(elems.undo);
}
enable(elems.redo);
}
function onRedoClick() {
if (changesUndone.length > 0) {
const change = changesUndone.pop();
changes.push(change);
switch (change.type) {
case changeTypes.circleAdded:
addCircle(change.circle);
break;
case changeTypes.diameterChange:
redoDiameterChange(change);
break;
default:
throw Error(`Unknown change type: ${change.type}.`);
}
}
if (changesUndone.length === 0) {
disable(elems.redo);
}
enable(elems.undo);
}
function addEventListeners() {
elems.undo.onclick = onUndoClick;
elems.redo.onclick = onRedoClick;
elems.svg.onclick = onSvgClick;
elems.input.oninput = onDiameterInput;
elems.closeForm.onclick = onFormCloseClick;
elems.closeMenu.onclick = onMenuCloseClick;
elems.openAdjustDiameter.onclick = onAdjustDiameterClick;
}
addEventListeners();
})();
7. Cells
Challenge
Change propagation, widget customization, implementing a more authentic/involved GUI application.
Criteria
Now, we will create a simple but usable spreadsheet application. How? Following these requirements, of course:
▪ The spreadsheet should be scrollable.
▪ The rows should be numbered from 0 to 99, and the columns from A to Z.
▪ Double-clicking a cell showing a formula’s result should allow the user to change such formula.
▪ After having finished editing, the formula is parsed, evaluated, and its updated value is shown.
Cells is a more authentic task that tests if a particular approach also scales to a somewhat bigger application. The two primary GUI-related challenges are intelligent propagation of changes and widget customization. Admittedly, there is a substantial part that is not necessarily very GUI-related, but that is just the nature of a more authentic challenge! Finally, the domain-specific code should be clearly separated from the GUI-specific code, and the resulting spreadsheet widget has to be reusable.
Demo:
Curious about how to code something similar? Let’s start with the HTML and CSS wombo-combo, then:
Example:
index.html
----------
// ...
<section id="cells">
<h1>Cells</h1>
<div id="tableLayout">
<table>
<thead>
<tr id="colHeadings">
<th id="topLeftSquare" class="rowHeading"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
//...
styles.css
----------
#cells {
--cell-width: 120px;
--cell-height: 32px;
--row-heading-cell-width: 40px;
--background-color: hsl(0, 0%, 100%);
--border-color: hsl(0, 0%, 13%);
--cell-border-color: hsl(0, 0%, 62%);
--selected-color: hsl(0, 0%, 11%);
--shadow: 0 3px 6px hsla(0, 0%, 0%, 0.16), 0 3px 6px hsla(0, 0%, 0%, 0.23);
--text-color: hsl(0, 0%, 13%);
}
section#cells {
display: flex;
flex-direction: column;
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: var(--shadow);
overflow: hidden;
}
#cells #tableLayout {
position: relative;
width: 100%;
flex-grow: 1;
overflow: auto;
}
#cells h1 {
background: var(--text-color);
color: var(--background-color);
font-size: 16px;
text-align: center;
}
#cells table {
position: absolute;
width: calc(var(--cell-width) * 26 + var(--row-heading-cell-width));
top: 0;
left: 0;
border-collapse: separate;
border-spacing: 0;
border-style: hidden;
}
#cells tr {
height: var(--cell-height);
}
#cells th:not(.rowHeading) {
position: sticky;
width: var(--cell-width);
top: 0;
background: var(--background-color);
border: 1px solid var(--cell-border-color);
z-index: 2;
}
#cells th.rowHeading {
position: sticky;
width: var(--row-heading-cell-width);
left: 0;
background: var(--background-color);
border: 1px solid var(--cell-border-color);
z-index: 1;
}
#cells #topLeftSquare {
top: 0;
z-index: 2;
}
#cells td {
width: var(--cell-width);
border: 1px solid var(--cell-border-color);
}
#cells input {
width: 100%;
height: var(--cell-height);
background: transparent;
border: none;
padding: 0 4px;
}
#cells input:focus {
outline: none;
outline: 2px solid var(--selected-color);
}
And the JavaScript chunk of code:
Example:
index.js
--------
(function () {
let selected = null;
const letters = [
`A`, `B`, `C`, `D`, `E`, `F`, `G`, `H`, `I`, `J`, `K`, `L`, `M`, `N`, `O`,
`P`, `Q`, `R`, `S`, `T`, `U`, `V`, `W`, `X`, `Y`, `Z`,
];
const ROW_COUNT = 100;
const FORMULA_SYMBOL = `=`;
const ERROR = "error";
const classes = {
rowHeading: "rowHeading",
};
const keyCodes = {
enter: 13,
escape: 27,
};
const cells = {};
const elems = {
colHeadingsRow: document.querySelector("#cells tr#colHeadings"),
table: document.querySelector("#cells table"),
tBody: document.querySelector("#cells tbody"),
};
function getCellValue(id) {
const value = cells[id]?.computedValue;
return value === undefined ? "" : value;
}
function isCellId(symbol) {
const regex = new RegExp(/^([A-Za-z])([0-9]{1,2})$/);
return regex.test(symbol);
}
function recalcCell(id) {
const formula = cells[id]?.formula;
if (!formula) {
return;
}
let computedValue;
try {
const value = evaluateFormula(formula);
computedValue = value;
} catch (error) {
computedValue = ERROR;
}
cells[id] = {
...cells[id],
computedValue,
};
cells[id].elem.value = computedValue;
cells[id].children?.forEach((id) => {
recalcCell(id);
});
}
// Children.
function addChild(parentId, childId) {
const children = cells[parentId]?.children || [];
if (children.includes(childId)) {
return;
}
children.push(childId);
cells[parentId] = {
...cells[parentId],
children,
};
}
function removeChild(parentId, childId) {
const children = cells[parentId]?.children || [];
if (!children.includes(childId)) {
return;
}
cells[parentId].children = children.filter((id) => id !== childId);
}
function removeCellParents(id) {
const formula = cells[id]?.formula;
if (!formula) {
return;
}
getFormulaParents(formula).forEach((parentId) => {
removeChild(parentId, id);
});
}
function recalcChildren(id) {
cells[id]?.children?.forEach((childId) => {
recalcCell(childId);
});
}
// Formula.
function isFormula(value) {
return value.startsWith(FORMULA_SYMBOL);
}
function splitFormula(formula) {
return formula
.replace(/\s+/g, "")
.split(/(=|\+|\-|\*|\/|\(|\)|,)/)
.filter((char) => char !== "");
}
function expandRange(range) {
const [start, end] = range.split(":");
const startLetter = start.charAt(0);
const startRow = parseInt(start.slice(1), 10);
const endLetter = end.charAt(0);
const endRow = parseInt(end.slice(1), 10);
const values = [];
for (
let letter = startLetter.charCodeAt(0);
letter <= endLetter.charCodeAt(0);
letter++
) {
for (let row = startRow; row <= endRow; row++) {
const cellId = String.fromCharCode(letter) + row;
const cellValue = getCellValue(cellId);
const numericValue = parseFloat(cellValue);
if (!isNaN(numericValue)) {
values.push(numericValue);
}
}
}
return values;
}
function evaluateFormula(formula) {
const [firstSymbol, ...rest] = splitFormula(formula);
if (firstSymbol !== FORMULA_SYMBOL) {
throw new Error("Invalid formula");
}
const formulaWithValues = rest.map((symbol) => {
if (isCellId(symbol)) {
return getCellValue(symbol);
} else if (["SUM", "SUBTRACT", "MULTIPLY", "DIVIDE"].includes(symbol.toUpperCase())) {
return symbol.toUpperCase();
} else if (symbol.match(/^[A-Za-z][0-9]{1,2}:[A-Za-z][0-9]{1,2}$/)) {
return expandRange(symbol);
}
return symbol;
});
return processCustomFunctions(formulaWithValues);
}
function processCustomFunctions(formulaWithValues) {
let operator = null;
let stack = [];
formulaWithValues.forEach(symbol => {
if (["SUM", "SUBTRACT", "MULTIPLY", "DIVIDE"].includes(symbol)) {
operator = symbol;
} else if (Array.isArray(symbol)) {
stack.push(...symbol);
} else if (!isNaN(symbol)) {
stack.push(parseFloat(symbol));
}
});
stack = stack.filter(value => !isNaN(value));
if (operator === "SUM") {
return stack.reduce((a, b) => a + b, 0);
} else if (operator === "SUBTRACT") {
return stack.reduce((a, b) => a - b);
} else if (operator === "MULTIPLY") {
return stack.reduce((a, b) => a * b, 1);
} else if (operator === "DIVIDE") {
return stack.reduce((a, b) => a / b);
}
return stack[0] || "";
}
function getFormulaParents(formula) {
return splitFormula(formula).filter((symbol) => isCellId(symbol));
}
// Handling different inputs.
function handleEmptyInput(elem) {
const { id } = elem;
removeCellParents(id);
cells[id] = {
...cells[id],
formula: null,
computedValue: null,
};
recalcChildren(id);
}
function handleFormulaInput(elem, formula) {
let computedValue;
const { id } = elem;
removeCellParents(id);
try {
const value = evaluateFormula(formula);
const parents = getFormulaParents(formula);
computedValue = value;
parents.forEach((parentId) => {
addChild(parentId, id);
});
} catch (error) {
computedValue = ERROR;
}
cells[id] = {
...cells[id],
computedValue,
elem,
formula,
};
elem.value = computedValue;
recalcChildren(id);
}
function handleNumberInput(elem, number) {
const { id } = elem;
removeCellParents(id);
cells[id] = {
...cells[id],
computedValue: number,
formula: null,
};
recalcChildren(id);
}
function parseInput(elem) {
const { id, value: newValue } = elem;
const oldValue = cells[id]?.formula || cells[id]?.computedValue;
if (newValue === "" && !oldValue) {
return;
}
if (newValue === oldValue) {
elem.value = cells[id].computedValue;
return;
}
if (newValue === "") {
handleEmptyInput(elem);
} else if (isFormula(newValue)) {
handleFormulaInput(elem, newValue);
} else {
handleNumberInput(elem, newValue);
}
}
// DOM manipulation.
function setReadOnly(elem) {
elem.setAttribute("readonly", "readonly");
}
function removeReadOnly(elem) {
elem.removeAttribute("readonly");
}
// DOM listeners.
function onDoubleClick(event) {
const elem = event.target;
selected = elem;
const formula = cells[elem.id]?.formula;
removeReadOnly(elem);
if (formula) {
elem.value = formula;
}
}
function onBlur(event) {
const elem = event.target;
if (elem === selected) {
parseInput(elem);
setReadOnly(elem);
selected = null;
}
}
function onKeyUp(event) {
if (Object.values(keyCodes).includes(event.keyCode)) {
onBlur(event);
}
}
// Rendering.
function renderColHeadings() {
letters.forEach((letter) => {
const th = document.createElement("th");
th.textContent = letter;
elems.colHeadingsRow.append(th);
});
}
function renderRows() {
Array(ROW_COUNT)
.fill(undefined)
.forEach((_, i) => {
const tr = document.createElement("tr");
const th = document.createElement("th");
th.textContent = `${i}`;
th.classList.add(classes.rowHeading);
tr.append(th);
letters.forEach((letter) => {
const td = document.createElement("td");
const input = document.createElement("input");
input.id = `${letter}${i}`;
input.setAttribute("readonly", "readonly");
input.addEventListener("dblclick", onDoubleClick);
input.addEventListener("blur", onBlur);
input.addEventListener("keyup", onKeyUp);
td.append(input);
tr.append(td);
});
elems.tBody.append(tr);
});
}
function render() {
renderColHeadings();
renderRows();
}
render();
})();
As for the possible formulas, here’s some insight on it (note that ,
implies two specific cells, while :
implies
an entire range of them):
Formula | Example | Description |
---|---|---|
Sum | =SUM(B2:B3) | Calculates the sum of a range of cells. |
Subtract | =SUBTRACT(A2, B2) | Subtracts one value from another. |
Multiply | =MULTIPLY(A2, B2) | Multiplies two values. |
Divide | =DIVIDE(A2, B2) | Divides one value by another. |
Feel free to share with me your own version of the 7GUIs - and happy coding! 🚀